From 52b12e0d65cced2a2a3c1b5d7d264ce7576bf89e Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 14:15:38 -0400 Subject: [PATCH 001/139] feat: new demo page --- demo/App.css | 31 +++++++++++++++++++++++ demo/App.tsx | 26 +++++++++++++++++++ demo/index.css | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++ demo/main.tsx | 7 ++++++ index.html | 2 +- 5 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 demo/App.css create mode 100644 demo/App.tsx create mode 100644 demo/index.css create mode 100644 demo/main.tsx diff --git a/demo/App.css b/demo/App.css new file mode 100644 index 000000000..5299c273e --- /dev/null +++ b/demo/App.css @@ -0,0 +1,31 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; +} + +.label { + display: inline-block; +} + +#plot { + text-align: center; + width: 1000px; + position: relative; +} +#plot canvas { + outline: 1px solid #bdbdbd; + border-radius: 5px; +} + +.desc { + max-width: 800px; + margin: 1em 0; +} + +.top-corner { + position: absolute; + top: 0; + right: 0; + padding: 0.5em 2em; +} diff --git a/demo/App.tsx b/demo/App.tsx new file mode 100644 index 000000000..3f5d243ad --- /dev/null +++ b/demo/App.tsx @@ -0,0 +1,26 @@ +import React, { useState, useEffect } from "react"; +import "./App.css"; + + +function App() { + const [fps, setFps] = useState(120); + + useEffect(() => { + // Create the new plot + const plotElement = document.getElementById("plot") as HTMLDivElement; + plotElement.innerHTML = ""; + + }, []); + + return ( + <> +

HiGlass/Gosling tracks with new renderer

+ +
+
+
+ + ); +} + +export default App; diff --git a/demo/index.css b/demo/index.css new file mode 100644 index 000000000..6119ad9a8 --- /dev/null +++ b/demo/index.css @@ -0,0 +1,68 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/demo/main.tsx b/demo/main.tsx new file mode 100644 index 000000000..a4d5ce7eb --- /dev/null +++ b/demo/main.tsx @@ -0,0 +1,7 @@ +import ReactDOM from 'react-dom/client' +import App from './App.tsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + +) diff --git a/index.html b/index.html index 8e8b18537..793e9ff70 100644 --- a/index.html +++ b/index.html @@ -19,6 +19,6 @@
- + From cafc6598cc74552b0180ce2447838745af23b474 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 14:22:25 -0400 Subject: [PATCH 002/139] feat: upgrade pixi --- package.json | 4 +- pnpm-lock.yaml | 704 +++++++++++++++++++++---------------------------- 2 files changed, 297 insertions(+), 411 deletions(-) diff --git a/package.json b/package.json index 935e46f04..ab4df93ad 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "test-ct": "playwright test -c playwright-ct.config.ts" }, "peerDependencies": { - "pixi.js": "^6.3.0", + "pixi.js": "^7.4.0", "react": "^16.6.3 || ^17.0.0 || ^18.0.0", "react-dom": "^16.6.3 || ^17.0.0 || ^18.0.0" }, @@ -129,7 +129,7 @@ "knip": "^2.30.0", "npm-run-all": "^4.1.5", "pixelmatch": "^5.3.0", - "pixi.js": "^6.3.0", + "pixi.js": "^7.4.0", "pngjs": "^7.0.0", "prettier": "^2.0.5", "react": "^18.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cfcca04a..e89f8e780 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,7 +70,7 @@ dependencies: version: 3.1.1 higlass: specifier: ^1.13.4 - version: 1.13.4(pixi.js@6.5.10)(react-dom@18.2.0)(react@18.2.0) + version: 1.13.4(pixi.js@7.4.0)(react-dom@18.2.0)(react@18.2.0) higlass-register: specifier: ^0.3.0 version: 0.3.0 @@ -239,8 +239,8 @@ devDependencies: specifier: ^5.3.0 version: 5.3.0 pixi.js: - specifier: ^6.3.0 - version: 6.5.10 + specifier: ^7.4.0 + version: 7.4.0 pngjs: specifier: ^7.0.0 version: 7.0.0 @@ -974,426 +974,318 @@ packages: engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} dev: true - /@pixi/accessibility@6.5.10(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/utils@6.5.10): - resolution: {integrity: sha512-URrI1H+1kjjHSyhY1QXcUZ8S3omdVTrXg5y0gndtpOhIelErBTC9NWjJfw6s0Rlmv5+x5VAitQTgw9mRiatDgw==} + /@pixi/accessibility@7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/events@7.4.0): + resolution: {integrity: sha512-muosfpn333YNz2s7mtoVlKvcXswFOJ4r+5rePn3r/95KQIpuB+xX6pETuzGq0p8uOpKxtkNokGj5s2dyM0blHA==} peerDependencies: - '@pixi/core': 6.5.10 - '@pixi/display': 6.5.10 - '@pixi/utils': 6.5.10 + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0 + '@pixi/events': 7.4.0 dependencies: - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) - '@pixi/display': 6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/utils': 6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10) + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0(@pixi/core@7.4.0) + '@pixi/events': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) - /@pixi/app@6.5.10(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/utils@6.5.10): - resolution: {integrity: sha512-VsNHLajZ5Dbc/Zrj7iWmIl3eu6Fec+afjW/NXXezD8Sp3nTDF0bv5F+GDgN/zSc2gqIvPHyundImT7hQGBDghg==} + /@pixi/app@7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0): + resolution: {integrity: sha512-9pDB974rfuObG5YHvR7kdWhDiIV26b0GeC4vHRQB3bkmltguMi8SCQ9WQKH3WwRLaflzf9EMZpgX10cU1gLgKg==} peerDependencies: - '@pixi/core': 6.5.10 - '@pixi/display': 6.5.10 - '@pixi/math': 6.5.10 - '@pixi/utils': 6.5.10 - dependencies: - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) - '@pixi/display': 6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/math': 6.5.10 - '@pixi/utils': 6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10) - - /@pixi/compressed-textures@6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/loaders@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10): - resolution: {integrity: sha512-41NT5mkfam47DrkB8xMp3HUZDt7139JMB6rVNOmb3u2vm+2mdy9tzi5s9nN7bG9xgXlchxcFzytTURk+jwXVJA==} - peerDependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10 - '@pixi/loaders': 6.5.10 - '@pixi/settings': 6.5.10 - '@pixi/utils': 6.5.10 - dependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) - '@pixi/loaders': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/utils@6.5.10) - '@pixi/settings': 6.5.10(@pixi/constants@6.5.10) - '@pixi/utils': 6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10) - - /@pixi/constants@6.5.10: - resolution: {integrity: sha512-PUF2Y9YISRu5eVrVVHhHCWpc/KmxQTg3UH8rIUs8UI9dCK41/wsPd3pEahzf7H47v7x1HCohVZcFO3XQc1bUDw==} - - /@pixi/core@6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10): - resolution: {integrity: sha512-Gdzp5ENypyglvsh5Gv3teUZnZnmizo4xOsL+QqmWALdFlJXJwLJMVhKVThV/q/095XR6i4Ou54oshn+m4EkuFw==} + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0 + dependencies: + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0(@pixi/core@7.4.0) + + /@pixi/assets@7.4.0(@pixi/core@7.4.0): + resolution: {integrity: sha512-Z7J2ZYSZ41Pr3CK0IXgtVV1HiLm1sG0AOZHAPMwB82wNdIDvmWowo/LkXvQmSHFLxFlEz1hWOdOFs1daWAeIfg==} peerDependencies: - '@pixi/constants': 6.5.10 - '@pixi/extensions': 6.5.10 - '@pixi/math': 6.5.10 - '@pixi/runner': 6.5.10 - '@pixi/settings': 6.5.10 - '@pixi/ticker': 6.5.10 - '@pixi/utils': 6.5.10 - dependencies: - '@pixi/constants': 6.5.10 - '@pixi/extensions': 6.5.10 - '@pixi/math': 6.5.10 - '@pixi/runner': 6.5.10 - '@pixi/settings': 6.5.10(@pixi/constants@6.5.10) - '@pixi/ticker': 6.5.10(@pixi/extensions@6.5.10)(@pixi/settings@6.5.10) - '@pixi/utils': 6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10) - '@types/offscreencanvas': 2019.7.3 - - /@pixi/display@6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10): - resolution: {integrity: sha512-NxFdDDxlbH5fQkzGHraLGoTMucW9pVgXqQm13TSmkA3NWIi/SItHL4qT2SI8nmclT9Vid1VDEBCJFAbdeuQw1Q==} + '@pixi/core': 7.4.0 + dependencies: + '@pixi/core': 7.4.0 + '@types/css-font-loading-module': 0.0.12 + + /@pixi/color@7.4.0: + resolution: {integrity: sha512-Qgn3OSW9SFCQ8wrm524anENwIAeRTORC014LkTqaBQrpuOUHrx11SCy4kNFaQyZWO1DCTe4m8g/foCK7zJM7cg==} + dependencies: + '@pixi/colord': 2.9.6 + + /@pixi/colord@2.9.6: + resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==} + + /@pixi/compressed-textures@7.4.0(@pixi/assets@7.4.0)(@pixi/core@7.4.0): + resolution: {integrity: sha512-M9bpOFeUPuss57mbRtJOD8cGh+X8xsfx8YMBqWzQTAfbA8hsTQ+O4arbMTyIxqZnaTvpmhlhTKwaVaI2V15NAg==} peerDependencies: - '@pixi/constants': 6.5.10 - '@pixi/math': 6.5.10 - '@pixi/settings': 6.5.10 - '@pixi/utils': 6.5.10 + '@pixi/assets': 7.4.0 + '@pixi/core': 7.4.0 dependencies: - '@pixi/constants': 6.5.10 - '@pixi/math': 6.5.10 - '@pixi/settings': 6.5.10(@pixi/constants@6.5.10) - '@pixi/utils': 6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10) + '@pixi/assets': 7.4.0(@pixi/core@7.4.0) + '@pixi/core': 7.4.0 - /@pixi/extensions@6.5.10: - resolution: {integrity: sha512-EIUGza+E+sCy3dupuIjvRK/WyVyfSzHb5XsxRaxNrPwvG1iIUIqNqZ3owLYCo4h17fJWrj/yXVufNNtUKQccWQ==} + /@pixi/constants@7.4.0: + resolution: {integrity: sha512-jQMPMRqkOTjI4D0cHWqvu+pofw6bIa8861x2vp2kNsmM2zcBO/b01AlmILi5pEDk0nTumgzgmVHZ7dtT9KxfQw==} - /@pixi/extract@6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/math@6.5.10)(@pixi/utils@6.5.10): - resolution: {integrity: sha512-hXFIc4EGs14GFfXAjT1+6mzopzCMWeXeai38/Yod3vuBXkkp8+ksen6kE09vTnB9l1IpcIaCM+XZEokuqoGX2A==} + /@pixi/core@7.4.0: + resolution: {integrity: sha512-X6UiDzmdd2oRK3zQggDrWNIlw5rjZakByRIwI6MRgj17FGkpNkCY78dO1snZ6qnpUoo5M03aSUCFCfq6LKA5Bg==} + dependencies: + '@pixi/color': 7.4.0 + '@pixi/constants': 7.4.0 + '@pixi/extensions': 7.4.0 + '@pixi/math': 7.4.0 + '@pixi/runner': 7.4.0 + '@pixi/settings': 7.4.0 + '@pixi/ticker': 7.4.0 + '@pixi/utils': 7.4.0 + + /@pixi/display@7.4.0(@pixi/core@7.4.0): + resolution: {integrity: sha512-l+K6H9CqB2tQltpaxal3dIPPAOWhBWszrJm5EbK5sVVQFcaWXgeS/Hmniz0DhT7OpPmstcx4nii9hZgRkmMmEg==} peerDependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10 - '@pixi/math': 6.5.10 - '@pixi/utils': 6.5.10 - dependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) - '@pixi/math': 6.5.10 - '@pixi/utils': 6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10) - - /@pixi/filter-alpha@6.5.10(@pixi/core@6.5.10): - resolution: {integrity: sha512-GWHLJvY0QOIDRjVx0hdUff6nl/PePQg84i8XXPmANrvA+gJ/eSRTQRmQcdgInQfawENADB/oRqpcCct6IAcKpQ==} + '@pixi/core': 7.4.0 + dependencies: + '@pixi/core': 7.4.0 + + /@pixi/events@7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0): + resolution: {integrity: sha512-9hshDahiFDbl3ZJt9cqutST+2aIZ8/bT29VVFuN2f0ZHatbEHVl46jqu0IL8d+TAlNUr+SI/JEaPA6/MR9sH6w==} peerDependencies: - '@pixi/core': 6.5.10 + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0 dependencies: - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0(@pixi/core@7.4.0) + + /@pixi/extensions@7.4.0: + resolution: {integrity: sha512-bX0aw6z2D9bJ5NOsrbuWXnBR7sy2z+dyq2EQ2/t0dF6Si764r8FiA0QUGFn9NJO1FTnB9LLjz7q4c0XaWF3mcg==} - /@pixi/filter-blur@6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/settings@6.5.10): - resolution: {integrity: sha512-LJsRocVOdM9hTzZKjP+jmkfoL1nrJi5XpR0ItgRN8fflOC7A7Ln4iPe7nukbbq3H7QhZSunbygMubbO6xhThZw==} + /@pixi/extract@7.4.0(@pixi/core@7.4.0): + resolution: {integrity: sha512-PLOdi8LxnRBRTKLx5plA9hWsIObiQ44tKMcyaLIESXNoUGE3135Aih10Hg1whrQcG4n9EqRjNak7LtwKRylRbg==} peerDependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10 - '@pixi/settings': 6.5.10 + '@pixi/core': 7.4.0 dependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) - '@pixi/settings': 6.5.10(@pixi/constants@6.5.10) + '@pixi/core': 7.4.0 - /@pixi/filter-color-matrix@6.5.10(@pixi/core@6.5.10): - resolution: {integrity: sha512-C2S44/EoWTrhqedLWOZTq9GZV5loEq1+MhyK9AUzEubWGMHhou1Juhn2mRZ7R6flKPCRQNKrXpStUwCAouud3Q==} + /@pixi/filter-alpha@7.4.0(@pixi/core@7.4.0): + resolution: {integrity: sha512-1KjdTcU4drduzF1HDu1clxZgM7b6lfE1CKESlY5CizJSMMGcycOUQRq/TWK54xrsJTyPWwNu5ojma6dcIqLOrw==} peerDependencies: - '@pixi/core': 6.5.10 + '@pixi/core': 7.4.0 dependencies: - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) + '@pixi/core': 7.4.0 - /@pixi/filter-displacement@6.5.10(@pixi/core@6.5.10)(@pixi/math@6.5.10): - resolution: {integrity: sha512-fbblMYyPX/hO3Tpoaa4tOBYxqp4TxjNrz6xyt15tKSVxWQElk+Tx98GJ+aaBoiHOKt8ezzHplStWoHG++JIv/w==} + /@pixi/filter-blur@7.4.0(@pixi/core@7.4.0): + resolution: {integrity: sha512-XUrhswyuc4+flpDL0fQcRuei8ctgYCdTxCuetSqpS+qdf4gOJyq5UyCwDycJiudZD6+R23svUX5OQOPwkWTsNA==} peerDependencies: - '@pixi/core': 6.5.10 - '@pixi/math': 6.5.10 + '@pixi/core': 7.4.0 dependencies: - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) - '@pixi/math': 6.5.10 + '@pixi/core': 7.4.0 - /@pixi/filter-fxaa@6.5.10(@pixi/core@6.5.10): - resolution: {integrity: sha512-wbHL9UtY3g7jTyvO8JaZks6DqV8AO5c96Hfu0zfndWBPs79Ul6/sq3LD2eE+yq5vK5T2R9Sr4s54ls1JT3Sppg==} + /@pixi/filter-color-matrix@7.4.0(@pixi/core@7.4.0): + resolution: {integrity: sha512-Ap5Fh6iJo5Mk6xMTia5KAWj9G0b4F3LiqrrWkM0y9gGzD5ei85Hd+XHHJtzWi+d4P/EWv7KlND6SnVcTZFgV4A==} peerDependencies: - '@pixi/core': 6.5.10 + '@pixi/core': 7.4.0 dependencies: - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) + '@pixi/core': 7.4.0 - /@pixi/filter-noise@6.5.10(@pixi/core@6.5.10): - resolution: {integrity: sha512-CX+/06NVaw3HsjipZVb7aemkca0TC8I6qfKI4lx2ugxS/6G6zkY5zqd8+nVSXW4DpUXB6eT0emwfRv6N00NvuA==} + /@pixi/filter-displacement@7.4.0(@pixi/core@7.4.0): + resolution: {integrity: sha512-fcFLxFge2V6o7LqIsz/goDTMbwLdHjGggbu9/t4+byNP5f+S2TTR3oT4nulTYhNQph5vyllhSPJgHoqXXRhTwg==} peerDependencies: - '@pixi/core': 6.5.10 + '@pixi/core': 7.4.0 dependencies: - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) + '@pixi/core': 7.4.0 - /@pixi/graphics@6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/sprite@6.5.10)(@pixi/utils@6.5.10): - resolution: {integrity: sha512-KPHGJ910fi8bRQQ+VcTIgrK+bKIm8yAQaZKPqMtm14HzHPGcES6HkgeNY1sd7m8J4aS9btm5wOSyFu0p5IzTpA==} + /@pixi/filter-fxaa@7.4.0(@pixi/core@7.4.0): + resolution: {integrity: sha512-W4l01ca9hJpjAfswRkw6UaCNh76E9ymigSVIBzhUUFwjfvVvIh7+O9SnEzkTVHsY15ANsznD0XZjgt3pW/wFbg==} peerDependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10 - '@pixi/display': 6.5.10 - '@pixi/math': 6.5.10 - '@pixi/sprite': 6.5.10 - '@pixi/utils': 6.5.10 - dependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) - '@pixi/display': 6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/math': 6.5.10 - '@pixi/sprite': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/utils': 6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10) - - /@pixi/interaction@6.5.10(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10): - resolution: {integrity: sha512-v809pJmXA2B9dV/vdrDMUqJT+fBB/ARZli2YRmI2dPbEbkaYr8FNmxCAJnwT8o+ymTx044Ie820hn9tVrtMtfA==} + '@pixi/core': 7.4.0 + dependencies: + '@pixi/core': 7.4.0 + + /@pixi/filter-noise@7.4.0(@pixi/core@7.4.0): + resolution: {integrity: sha512-q2+CWODAJO79j0StJ+xakX4D8r8w/RLURRiyG+focTIj1ws/7sdDmDsV+jmeKm6pEktwgA3JYWIKZUnezlGf8g==} peerDependencies: - '@pixi/core': 6.5.10 - '@pixi/display': 6.5.10 - '@pixi/math': 6.5.10 - '@pixi/ticker': 6.5.10 - '@pixi/utils': 6.5.10 - dependencies: - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) - '@pixi/display': 6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/math': 6.5.10 - '@pixi/ticker': 6.5.10(@pixi/extensions@6.5.10)(@pixi/settings@6.5.10) - '@pixi/utils': 6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10) - - /@pixi/loaders@6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/utils@6.5.10): - resolution: {integrity: sha512-AuK7mXBmyVsDFL9DDFPB8sqP8fwQ2NOktvu98bQuJl0/p/UeK/0OAQnF3wcf3FeBv5YGXfNHL21c2DCisjKfTg==} + '@pixi/core': 7.4.0 + dependencies: + '@pixi/core': 7.4.0 + + /@pixi/graphics@7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/sprite@7.4.0): + resolution: {integrity: sha512-9GcXbP/iTFEA5xwXx6sSwGyIYPd6XVhFJR7ALqqnlYC+FvvvHPoh7cN3HPa1Aw9dWpNRKUKuNcoOYPmd0O0aJA==} peerDependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10 - '@pixi/utils': 6.5.10 + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0 + '@pixi/sprite': 7.4.0 dependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) - '@pixi/utils': 6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10) + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0(@pixi/core@7.4.0) + '@pixi/sprite': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) - /@pixi/math@6.5.10: - resolution: {integrity: sha512-fxeu7ykVbMGxGV2S3qRTupHToeo1hdWBm8ihyURn3BMqJZe2SkZEECPd5RyvIuuNUtjRnmhkZRnF3Jsz2S+L0g==} + /@pixi/math@7.4.0: + resolution: {integrity: sha512-9WCWKX5z/VOYGpsnXXQ73vg/IT+bUXCLO6miXuS5YPXNfw9RpvdV4ZgFmuQwPNM7wfFk5T7Uvfr8ZJRBCfKhZw==} - /@pixi/mesh-extras@6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/math@6.5.10)(@pixi/mesh@6.5.10)(@pixi/utils@6.5.10): - resolution: {integrity: sha512-UCG7OOPPFeikrX09haCibCMR0jPQ4UJ+4HiYiAv/3dahq5eEzBx+yAwVtxcVCjonkTf/lu5SzmHdzpsbHLx5aw==} - peerDependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10 - '@pixi/math': 6.5.10 - '@pixi/mesh': 6.5.10 - '@pixi/utils': 6.5.10 - dependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) - '@pixi/math': 6.5.10 - '@pixi/mesh': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/utils': 6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10) - - /@pixi/mesh@6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10): - resolution: {integrity: sha512-tUNPsdp5/t/yRsCmfxIcufIfbQVzgAlMNgQ1igWOkSxzhB7vlEbZ8ZLLW5tQcNyM/r7Nhjz+RoB+RD+/BCtvlA==} + /@pixi/mesh-extras@7.4.0(@pixi/core@7.4.0)(@pixi/mesh@7.4.0): + resolution: {integrity: sha512-YMI72eDruRd3iUIxfFNW+siuwvvrBv4/A9GDeBySKdfqbMOnzi0GLjxvF88bcP7eujdJQDwzTnAV4hW0UNIkjw==} peerDependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10 - '@pixi/display': 6.5.10 - '@pixi/math': 6.5.10 - '@pixi/settings': 6.5.10 - '@pixi/utils': 6.5.10 - dependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) - '@pixi/display': 6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/math': 6.5.10 - '@pixi/settings': 6.5.10(@pixi/constants@6.5.10) - '@pixi/utils': 6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10) - - /@pixi/mixin-cache-as-bitmap@6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/sprite@6.5.10)(@pixi/utils@6.5.10): - resolution: {integrity: sha512-HV4qPZt8R7uuPZf1XE5S0e3jbN4+/EqgAIkueIyK3Em+0IO1rCmIbzzYxFPxkElMUu5VvN1r4hXK846z9ITnhw==} + '@pixi/core': 7.4.0 + '@pixi/mesh': 7.4.0 + dependencies: + '@pixi/core': 7.4.0 + '@pixi/mesh': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) + + /@pixi/mesh@7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0): + resolution: {integrity: sha512-Ql5B3q8UD898LTKTPAkveOU72tN9xD8CsLPuvmPSrjpE5FlyRhrS90JzD26/sz6H3B7Kfu2gRjilmujCzNvuWA==} peerDependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10 - '@pixi/display': 6.5.10 - '@pixi/math': 6.5.10 - '@pixi/settings': 6.5.10 - '@pixi/sprite': 6.5.10 - '@pixi/utils': 6.5.10 - dependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) - '@pixi/display': 6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/math': 6.5.10 - '@pixi/settings': 6.5.10(@pixi/constants@6.5.10) - '@pixi/sprite': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/utils': 6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10) - - /@pixi/mixin-get-child-by-name@6.5.10(@pixi/display@6.5.10): - resolution: {integrity: sha512-YYd9wjnI/4aKY0H5Ij413UppVZn3YE1No2CZrNevV6WbhylsJucowY3hJihtl9mxkpwtaUIyWMjmphkbOinbzA==} + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0 + dependencies: + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0(@pixi/core@7.4.0) + + /@pixi/mixin-cache-as-bitmap@7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/sprite@7.4.0): + resolution: {integrity: sha512-wFkwU19dCyY5m0JxiKf6UJwvR8XaGDWA/0VXZelBF+WwIj54uKjN4lNSnSApHHByFfq9BRka7B5C1fU9eZNOzg==} peerDependencies: - '@pixi/display': 6.5.10 + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0 + '@pixi/sprite': 7.4.0 dependencies: - '@pixi/display': 6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0(@pixi/core@7.4.0) + '@pixi/sprite': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) - /@pixi/mixin-get-global-position@6.5.10(@pixi/display@6.5.10)(@pixi/math@6.5.10): - resolution: {integrity: sha512-A83gTZP9CdQAyrAvOZl1P707Q0QvIC0V8UnBAMd4GxuhMOXJtXVPCdmfPVXUrfoywgnH+/Bgimq5xhsXTf8Hzg==} + /@pixi/mixin-get-child-by-name@7.4.0(@pixi/display@7.4.0): + resolution: {integrity: sha512-GAWXSNnYtZyppxGVpt0lN2Iq6Z1MYuGeE/X5rYd5yO+Ra9VbUaslTRxf2y8H1TTWOPCIs8mcSTNdJTgElSfqbQ==} peerDependencies: - '@pixi/display': 6.5.10 - '@pixi/math': 6.5.10 + '@pixi/display': 7.4.0 dependencies: - '@pixi/display': 6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/math': 6.5.10 + '@pixi/display': 7.4.0(@pixi/core@7.4.0) - /@pixi/particle-container@6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/sprite@6.5.10)(@pixi/utils@6.5.10): - resolution: {integrity: sha512-CCNAdYGzKoOc3FtK2kyWCNjygdHppeOEqqK189yhg3yRSsvby+HMms/cM6bLK/4Vf6mFoAy1na3w/oXpqTR2Ag==} + /@pixi/mixin-get-global-position@7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0): + resolution: {integrity: sha512-u2EKXi7sv1zG2exk/bpjozBTOElBAsHnA0sHe0kz6sELpNBjv4g2n0Hwfl+qd69S+60zfN44ER+ihbFUWgD5VA==} peerDependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10 - '@pixi/display': 6.5.10 - '@pixi/math': 6.5.10 - '@pixi/sprite': 6.5.10 - '@pixi/utils': 6.5.10 - dependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) - '@pixi/display': 6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/math': 6.5.10 - '@pixi/sprite': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/utils': 6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10) - - /@pixi/polyfill@6.5.10: - resolution: {integrity: sha512-KDTWyr285VvPM8GGTVIZAhmxGrOlTznUGK/9kWS3GtrogwLWn41S/86Yej1gYvotVyUomCcOok33Jzahb+vX1w==} + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0 dependencies: - object-assign: 4.1.1 - promise-polyfill: 8.3.0 + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0(@pixi/core@7.4.0) - /@pixi/prepare@6.5.10(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/graphics@6.5.10)(@pixi/settings@6.5.10)(@pixi/text@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10): - resolution: {integrity: sha512-PHMApz/GPg7IX/7+2S98criN2+Mp+fgiKpojV9cnl0SlW2zMxfAHBBi8zik9rHBgjx8X6d6bR0MG1rPtb6vSxQ==} + /@pixi/particle-container@7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/sprite@7.4.0): + resolution: {integrity: sha512-y3cB2EvgzfOm/pw4qBFsKOVoRzhzLy/FFj92DbD3bL5a6Z+YtKblkeWw3P5exzZJBTRn9sEk1vhzBb1HM/WEJw==} peerDependencies: - '@pixi/core': 6.5.10 - '@pixi/display': 6.5.10 - '@pixi/graphics': 6.5.10 - '@pixi/settings': 6.5.10 - '@pixi/text': 6.5.10 - '@pixi/ticker': 6.5.10 - '@pixi/utils': 6.5.10 - dependencies: - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) - '@pixi/display': 6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/graphics': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/sprite@6.5.10)(@pixi/utils@6.5.10) - '@pixi/settings': 6.5.10(@pixi/constants@6.5.10) - '@pixi/text': 6.5.10(@pixi/core@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/sprite@6.5.10)(@pixi/utils@6.5.10) - '@pixi/ticker': 6.5.10(@pixi/extensions@6.5.10)(@pixi/settings@6.5.10) - '@pixi/utils': 6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10) - - /@pixi/runner@6.5.10: - resolution: {integrity: sha512-4HiHp6diCmigJT/DSbnqQP62OfWKmZB7zPWMdV1AEdr4YT1QxzXAW1wHg7dkoEfyTHqZKl0tm/zcqKq/iH7tMA==} - - /@pixi/settings@6.5.10(@pixi/constants@6.5.10): - resolution: {integrity: sha512-ypAS5L7pQ2Qb88yQK72bXtc7sD8OrtLWNXdZ/gnw5kwSWCFaOSoqhKqJCXrR5DQtN98+RQefwbEAmMvqobhFyw==} + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0 + '@pixi/sprite': 7.4.0 + dependencies: + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0(@pixi/core@7.4.0) + '@pixi/sprite': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) + + /@pixi/prepare@7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/graphics@7.4.0)(@pixi/text@7.4.0): + resolution: {integrity: sha512-qMRf0SPVYW6k0ZG19SdddwH/FErywEzkJtS7pCVrFy31RP4dF+ZunEffKNPm3Kf5b94JXd6+lIAxDy4tDVqXNQ==} peerDependencies: - '@pixi/constants': 6.5.10 + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0 + '@pixi/graphics': 7.4.0 + '@pixi/text': 7.4.0 + dependencies: + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0(@pixi/core@7.4.0) + '@pixi/graphics': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/sprite@7.4.0) + '@pixi/text': 7.4.0(@pixi/core@7.4.0)(@pixi/sprite@7.4.0) + + /@pixi/runner@7.4.0: + resolution: {integrity: sha512-TIfocv2TD4xHOiGSpeu2y3GMN09cKEpxiS/rswdCU/aacfgSyvjEmskL/dbq/PSA4FCmjVHLyjgNPvd79WPZhQ==} + + /@pixi/settings@7.4.0: + resolution: {integrity: sha512-ODWmSVfLnn384xFsXp+NNV6mQ+AwoeI4FtN+tMcJ+w/qQTi+eq6VLIpgqNx2Z/TJESI4HY4jxL6qz4SJEs7SMA==} dependencies: - '@pixi/constants': 6.5.10 + '@pixi/constants': 7.4.0 + '@types/css-font-loading-module': 0.0.12 + ismobilejs: 1.1.1 - /@pixi/sprite-animated@6.5.10(@pixi/core@6.5.10)(@pixi/sprite@6.5.10)(@pixi/ticker@6.5.10): - resolution: {integrity: sha512-x1kayucAqpVbNk+j+diC/7sQGQsAl6NCH1J2/EEaiQjlV3GOx1MXS9Tft1N1Y1y7otbg1XsnBd60/Yzcp05pxA==} + /@pixi/sprite-animated@7.4.0(@pixi/core@7.4.0)(@pixi/sprite@7.4.0): + resolution: {integrity: sha512-SVIO78hHqVvBg5kh13TES0oqmjBhjeQmCgXVzT1nC62Vxh/6AAd9JOKid706lXoqRgw7H7OhdunEWL6J2zN4KA==} peerDependencies: - '@pixi/core': 6.5.10 - '@pixi/sprite': 6.5.10 - '@pixi/ticker': 6.5.10 + '@pixi/core': 7.4.0 + '@pixi/sprite': 7.4.0 dependencies: - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) - '@pixi/sprite': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/ticker': 6.5.10(@pixi/extensions@6.5.10)(@pixi/settings@6.5.10) + '@pixi/core': 7.4.0 + '@pixi/sprite': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) - /@pixi/sprite-tiling@6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/sprite@6.5.10)(@pixi/utils@6.5.10): - resolution: {integrity: sha512-lDFcPuwExrdJhli+WmjPivChjeCG6NiRl36iQ8n2zVi/MYVv9qfKCA6IdU7HBWk1AZdsg6KUTpwfmVLUI+qz3w==} + /@pixi/sprite-tiling@7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/sprite@7.4.0): + resolution: {integrity: sha512-q0wjrdhvqnfSRNYIJ0KHUIT0nARvlmBoKBtjEZLAnk1jQCFzrJIg4qfmsBNDSOzMVaAxAot0EbOLjld6EZmf8w==} peerDependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10 - '@pixi/display': 6.5.10 - '@pixi/math': 6.5.10 - '@pixi/sprite': 6.5.10 - '@pixi/utils': 6.5.10 - dependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) - '@pixi/display': 6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/math': 6.5.10 - '@pixi/sprite': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/utils': 6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10) - - /@pixi/sprite@6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10): - resolution: {integrity: sha512-UiK+8LgM9XQ/SBDKjRgZ8WggdOSlFRXqiWjEZVmNkiyU8HvXeFzWPRhpc8RR1zDwAUhZWKtMhF8X/ba9m+z2lg==} + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0 + '@pixi/sprite': 7.4.0 + dependencies: + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0(@pixi/core@7.4.0) + '@pixi/sprite': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) + + /@pixi/sprite@7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0): + resolution: {integrity: sha512-+yQdq3aKS59s9uxiW066geWLCKYTRjtbdgE2qtyUP4pK/bYanWVWash7K8P3qVX8NQsQKjGvNPoa2fkP6MBE1Q==} peerDependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10 - '@pixi/display': 6.5.10 - '@pixi/math': 6.5.10 - '@pixi/settings': 6.5.10 - '@pixi/utils': 6.5.10 - dependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) - '@pixi/display': 6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/math': 6.5.10 - '@pixi/settings': 6.5.10(@pixi/constants@6.5.10) - '@pixi/utils': 6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10) - - /@pixi/spritesheet@6.5.10(@pixi/core@6.5.10)(@pixi/loaders@6.5.10)(@pixi/math@6.5.10)(@pixi/utils@6.5.10): - resolution: {integrity: sha512-7uOZ1cYyYtPb0ZEgXV1SZ8ujtluZNY0TL5z3+Qc8cgGGZK/MaWG7N6Wf+uR4BR2x8FLNwcyN5IjbQDKCpblrmg==} + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0 + dependencies: + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0(@pixi/core@7.4.0) + + /@pixi/spritesheet@7.4.0(@pixi/assets@7.4.0)(@pixi/core@7.4.0): + resolution: {integrity: sha512-wztt4ne71AWDY4WMyuoMUrZlYVeKkubRTqT9HcPYxDEClxZAz1ggsr03PB4RGHbNQkVC1ImrAi9fa0D0PkyPYg==} peerDependencies: - '@pixi/core': 6.5.10 - '@pixi/loaders': 6.5.10 - '@pixi/math': 6.5.10 - '@pixi/utils': 6.5.10 - dependencies: - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) - '@pixi/loaders': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/utils@6.5.10) - '@pixi/math': 6.5.10 - '@pixi/utils': 6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10) - - /@pixi/text-bitmap@6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/loaders@6.5.10)(@pixi/math@6.5.10)(@pixi/mesh@6.5.10)(@pixi/settings@6.5.10)(@pixi/text@6.5.10)(@pixi/utils@6.5.10): - resolution: {integrity: sha512-g/iFIMGp6Pfi0BvX6Ykp48Z6JXVgKOrc7UCIR9CM21wYcCiQGqtdFwstV236xk6/D8NToUtSOcifhtQ28dVTdQ==} + '@pixi/assets': 7.4.0 + '@pixi/core': 7.4.0 + dependencies: + '@pixi/assets': 7.4.0(@pixi/core@7.4.0) + '@pixi/core': 7.4.0 + + /@pixi/text-bitmap@7.4.0(@pixi/assets@7.4.0)(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/mesh@7.4.0)(@pixi/text@7.4.0): + resolution: {integrity: sha512-OkYixlqMW9b1EHtEbSP9mgZEqI0WLN1KP4h2EyJk0LC9lH2Ybp3v7ZGHKAetGkSCt8PXY5AfXbcWtm+TgTWbJw==} peerDependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10 - '@pixi/display': 6.5.10 - '@pixi/loaders': 6.5.10 - '@pixi/math': 6.5.10 - '@pixi/mesh': 6.5.10 - '@pixi/settings': 6.5.10 - '@pixi/text': 6.5.10 - '@pixi/utils': 6.5.10 - dependencies: - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) - '@pixi/display': 6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/loaders': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/utils@6.5.10) - '@pixi/math': 6.5.10 - '@pixi/mesh': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/settings': 6.5.10(@pixi/constants@6.5.10) - '@pixi/text': 6.5.10(@pixi/core@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/sprite@6.5.10)(@pixi/utils@6.5.10) - '@pixi/utils': 6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10) - - /@pixi/text@6.5.10(@pixi/core@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/sprite@6.5.10)(@pixi/utils@6.5.10): - resolution: {integrity: sha512-ikwkonLJ+6QmEVW8Ji9fS5CjrKNbU4mHzYuwRQas/VJQuSWgd0myCcaw6ZbF1oSfQe70HgbNOR0sH8Q3Com0qg==} + '@pixi/assets': 7.4.0 + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0 + '@pixi/mesh': 7.4.0 + '@pixi/text': 7.4.0 + dependencies: + '@pixi/assets': 7.4.0(@pixi/core@7.4.0) + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0(@pixi/core@7.4.0) + '@pixi/mesh': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) + '@pixi/text': 7.4.0(@pixi/core@7.4.0)(@pixi/sprite@7.4.0) + + /@pixi/text-html@7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/sprite@7.4.0)(@pixi/text@7.4.0): + resolution: {integrity: sha512-HOSKLynkL4cXQdv7zMst7+vISKp4ueCdJpV2zwQJnwVa/dHKlMULQ4+F5yxbtgAF8fYcH3iNfFLaraFlx1hL5A==} peerDependencies: - '@pixi/core': 6.5.10 - '@pixi/math': 6.5.10 - '@pixi/settings': 6.5.10 - '@pixi/sprite': 6.5.10 - '@pixi/utils': 6.5.10 - dependencies: - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) - '@pixi/math': 6.5.10 - '@pixi/settings': 6.5.10(@pixi/constants@6.5.10) - '@pixi/sprite': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/utils': 6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10) - - /@pixi/ticker@6.5.10(@pixi/extensions@6.5.10)(@pixi/settings@6.5.10): - resolution: {integrity: sha512-UqX1XYtzqFSirmTOy8QAK4Ccg4KkIZztrBdRPKwFSOEiKAJoGDCSBmyQBo/9aYQKGObbNnrJ7Hxv3/ucg3/1GA==} + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0 + '@pixi/sprite': 7.4.0 + '@pixi/text': 7.4.0 + dependencies: + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0(@pixi/core@7.4.0) + '@pixi/sprite': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) + '@pixi/text': 7.4.0(@pixi/core@7.4.0)(@pixi/sprite@7.4.0) + + /@pixi/text@7.4.0(@pixi/core@7.4.0)(@pixi/sprite@7.4.0): + resolution: {integrity: sha512-yVVeWYH6N+E38R+D7tvOVwDhbFxrInZ7fkOllfePu3KaKsUXbjklgtKUyPREs1LGJC8ffrpCPo1k9BVmwFA4Eg==} peerDependencies: - '@pixi/extensions': 6.5.10 - '@pixi/settings': 6.5.10 + '@pixi/core': 7.4.0 + '@pixi/sprite': 7.4.0 dependencies: - '@pixi/extensions': 6.5.10 - '@pixi/settings': 6.5.10(@pixi/constants@6.5.10) + '@pixi/core': 7.4.0 + '@pixi/sprite': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) - /@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10): - resolution: {integrity: sha512-4f4qDMmAz9IoSAe08G2LAxUcEtG9jSdudfsMQT2MG+OpfToirboE6cNoO0KnLCvLzDVE/mfisiQ9uJbVA9Ssdw==} - peerDependencies: - '@pixi/constants': 6.5.10 - '@pixi/settings': 6.5.10 + /@pixi/ticker@7.4.0: + resolution: {integrity: sha512-GaDmk27tEpPfUVgVTNQWGuOYGu6ehqmVSGxecCv4No5KHP52+LihTC4YHO06zRxfyrIOgafooDL/vQiEMqas8g==} + dependencies: + '@pixi/extensions': 7.4.0 + '@pixi/settings': 7.4.0 + '@pixi/utils': 7.4.0 + + /@pixi/utils@7.4.0: + resolution: {integrity: sha512-VBnxNGGg/uj7k1wmvyNZei2qpbFNN/kdQ2/mwNXJtFcFymVfijNZWRUNobpSRE/yHx40WGYzSm3ZJZrF4WxFzA==} dependencies: - '@pixi/constants': 6.5.10 - '@pixi/settings': 6.5.10(@pixi/constants@6.5.10) + '@pixi/color': 7.4.0 + '@pixi/constants': 7.4.0 + '@pixi/settings': 7.4.0 '@types/earcut': 2.1.4 earcut: 2.2.4 - eventemitter3: 3.1.2 + eventemitter3: 4.0.7 url: 0.11.3 /@pkgjs/parseargs@0.11.0: @@ -1502,6 +1394,9 @@ packages: resolution: {integrity: sha512-qQR1dr2rGIHYlJulmr8Ioq3De0Le9E4MJ5AiaeAETJJpndT1uUNHsGFK3L/UIu+rbkQSdj8J/w2bCsBZc/Y5fQ==} dev: true + /@types/css-font-loading-module@0.0.12: + resolution: {integrity: sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==} + /@types/d3-array@3.2.1: resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} dev: true @@ -1728,9 +1623,6 @@ packages: resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} dev: true - /@types/offscreencanvas@2019.7.3: - resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} - /@types/pixelmatch@5.2.6: resolution: {integrity: sha512-wC83uexE5KGuUODn6zkm9gMzTwdY5L0chiK+VrKcDfEjzxh1uadlWTvOmAbCpnM9zx/Ww3f8uKlYQVnO/TrqVg==} dependencies: @@ -3693,8 +3585,8 @@ packages: engines: {node: '>=0.10.0'} dev: true - /eventemitter3@3.1.2: - resolution: {integrity: sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==} + /eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} /eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} @@ -4142,7 +4034,7 @@ packages: slugid: 3.2.0 dev: false - /higlass@1.13.4(pixi.js@6.5.10)(react-dom@18.2.0)(react@18.2.0): + /higlass@1.13.4(pixi.js@7.4.0)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-mIr+Yi5aR0zBAB9iqlqTz/K3ZusrGz8f20g5DHaaRZ/FzMOcFsdL+9ESIPndPZrN2Y/GQ4CHilNjPTTSncIOTg==} engines: {node: '>=0.12.0'} peerDependencies: @@ -4173,7 +4065,7 @@ packages: genbank-parser: 1.2.4 ndarray: 1.0.19 pako: 1.0.11 - pixi.js: 6.5.10 + pixi.js: 7.4.0 prismjs: 1.29.0 prop-types: 15.8.1 pub-sub-es: 2.1.1 @@ -4606,6 +4498,9 @@ packages: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} dev: true + /ismobilejs@1.1.1: + resolution: {integrity: sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==} + /istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -5672,45 +5567,39 @@ packages: pngjs: 6.0.0 dev: true - /pixi.js@6.5.10: - resolution: {integrity: sha512-Z2mjeoISml2iuVwT1e/BQwERYM2yKoiR08ZdGrg8y5JjeuVptfTrve4DbPMRN/kEDodesgQZGV/pFv0fE9Q2SA==} - dependencies: - '@pixi/accessibility': 6.5.10(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/utils@6.5.10) - '@pixi/app': 6.5.10(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/utils@6.5.10) - '@pixi/compressed-textures': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/loaders@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/constants': 6.5.10 - '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) - '@pixi/display': 6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/extensions': 6.5.10 - '@pixi/extract': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/math@6.5.10)(@pixi/utils@6.5.10) - '@pixi/filter-alpha': 6.5.10(@pixi/core@6.5.10) - '@pixi/filter-blur': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/settings@6.5.10) - '@pixi/filter-color-matrix': 6.5.10(@pixi/core@6.5.10) - '@pixi/filter-displacement': 6.5.10(@pixi/core@6.5.10)(@pixi/math@6.5.10) - '@pixi/filter-fxaa': 6.5.10(@pixi/core@6.5.10) - '@pixi/filter-noise': 6.5.10(@pixi/core@6.5.10) - '@pixi/graphics': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/sprite@6.5.10)(@pixi/utils@6.5.10) - '@pixi/interaction': 6.5.10(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) - '@pixi/loaders': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/utils@6.5.10) - '@pixi/math': 6.5.10 - '@pixi/mesh': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/mesh-extras': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/math@6.5.10)(@pixi/mesh@6.5.10)(@pixi/utils@6.5.10) - '@pixi/mixin-cache-as-bitmap': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/sprite@6.5.10)(@pixi/utils@6.5.10) - '@pixi/mixin-get-child-by-name': 6.5.10(@pixi/display@6.5.10) - '@pixi/mixin-get-global-position': 6.5.10(@pixi/display@6.5.10)(@pixi/math@6.5.10) - '@pixi/particle-container': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/sprite@6.5.10)(@pixi/utils@6.5.10) - '@pixi/polyfill': 6.5.10 - '@pixi/prepare': 6.5.10(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/graphics@6.5.10)(@pixi/settings@6.5.10)(@pixi/text@6.5.10)(@pixi/ticker@6.5.10)(@pixi/utils@6.5.10) - '@pixi/runner': 6.5.10 - '@pixi/settings': 6.5.10(@pixi/constants@6.5.10) - '@pixi/sprite': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/utils@6.5.10) - '@pixi/sprite-animated': 6.5.10(@pixi/core@6.5.10)(@pixi/sprite@6.5.10)(@pixi/ticker@6.5.10) - '@pixi/sprite-tiling': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/math@6.5.10)(@pixi/sprite@6.5.10)(@pixi/utils@6.5.10) - '@pixi/spritesheet': 6.5.10(@pixi/core@6.5.10)(@pixi/loaders@6.5.10)(@pixi/math@6.5.10)(@pixi/utils@6.5.10) - '@pixi/text': 6.5.10(@pixi/core@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10)(@pixi/sprite@6.5.10)(@pixi/utils@6.5.10) - '@pixi/text-bitmap': 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10)(@pixi/display@6.5.10)(@pixi/loaders@6.5.10)(@pixi/math@6.5.10)(@pixi/mesh@6.5.10)(@pixi/settings@6.5.10)(@pixi/text@6.5.10)(@pixi/utils@6.5.10) - '@pixi/ticker': 6.5.10(@pixi/extensions@6.5.10)(@pixi/settings@6.5.10) - '@pixi/utils': 6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10) + /pixi.js@7.4.0: + resolution: {integrity: sha512-c2q3NG06RcSzgcyNieuC/ogzdaBKRoZvBlAiPdL8ubhJyEVCoSA+zitjsCe/m3t5cVrrjPnwo81ps+fg908hBw==} + dependencies: + '@pixi/accessibility': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/events@7.4.0) + '@pixi/app': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) + '@pixi/assets': 7.4.0(@pixi/core@7.4.0) + '@pixi/compressed-textures': 7.4.0(@pixi/assets@7.4.0)(@pixi/core@7.4.0) + '@pixi/core': 7.4.0 + '@pixi/display': 7.4.0(@pixi/core@7.4.0) + '@pixi/events': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) + '@pixi/extensions': 7.4.0 + '@pixi/extract': 7.4.0(@pixi/core@7.4.0) + '@pixi/filter-alpha': 7.4.0(@pixi/core@7.4.0) + '@pixi/filter-blur': 7.4.0(@pixi/core@7.4.0) + '@pixi/filter-color-matrix': 7.4.0(@pixi/core@7.4.0) + '@pixi/filter-displacement': 7.4.0(@pixi/core@7.4.0) + '@pixi/filter-fxaa': 7.4.0(@pixi/core@7.4.0) + '@pixi/filter-noise': 7.4.0(@pixi/core@7.4.0) + '@pixi/graphics': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/sprite@7.4.0) + '@pixi/mesh': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) + '@pixi/mesh-extras': 7.4.0(@pixi/core@7.4.0)(@pixi/mesh@7.4.0) + '@pixi/mixin-cache-as-bitmap': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/sprite@7.4.0) + '@pixi/mixin-get-child-by-name': 7.4.0(@pixi/display@7.4.0) + '@pixi/mixin-get-global-position': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) + '@pixi/particle-container': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/sprite@7.4.0) + '@pixi/prepare': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/graphics@7.4.0)(@pixi/text@7.4.0) + '@pixi/sprite': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) + '@pixi/sprite-animated': 7.4.0(@pixi/core@7.4.0)(@pixi/sprite@7.4.0) + '@pixi/sprite-tiling': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/sprite@7.4.0) + '@pixi/spritesheet': 7.4.0(@pixi/assets@7.4.0)(@pixi/core@7.4.0) + '@pixi/text': 7.4.0(@pixi/core@7.4.0)(@pixi/sprite@7.4.0) + '@pixi/text-bitmap': 7.4.0(@pixi/assets@7.4.0)(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/mesh@7.4.0)(@pixi/text@7.4.0) + '@pixi/text-html': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/sprite@7.4.0)(@pixi/text@7.4.0) /pkg-types@1.0.3: resolution: {integrity: sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==} @@ -5798,9 +5687,6 @@ packages: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} dev: true - /promise-polyfill@8.3.0: - resolution: {integrity: sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==} - /prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} dependencies: From 3acfc54018bdaaafb145d129c1e586fc57cf8641 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 14:33:36 -0400 Subject: [PATCH 003/139] feat: pixi-manager --- src/pixi-manager/index.ts | 1 + src/pixi-manager/pixi-manager.ts | 81 ++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 src/pixi-manager/index.ts create mode 100644 src/pixi-manager/pixi-manager.ts diff --git a/src/pixi-manager/index.ts b/src/pixi-manager/index.ts new file mode 100644 index 000000000..7f349908c --- /dev/null +++ b/src/pixi-manager/index.ts @@ -0,0 +1 @@ +export { PixiManager } from './pixi-manager'; diff --git a/src/pixi-manager/pixi-manager.ts b/src/pixi-manager/pixi-manager.ts new file mode 100644 index 000000000..ed47e034b --- /dev/null +++ b/src/pixi-manager/pixi-manager.ts @@ -0,0 +1,81 @@ +import * as PIXI from 'pixi.js'; + +/** + * A wrapper class for PIXI.Application + */ +export class PixiManager { + app: PIXI.Application; + containerElement: HTMLDivElement; + + constructor(width: number, height: number, container: HTMLDivElement, fps: (fps: number) => void) { + this.app = new PIXI.Application({ + width, + height, + antialias: false, // When this is true, rendering is slower + resolution: 2, // Higher resolution + autoDensity: true, // When resolution is set, this should be true so things are scaled correctly + view: document.createElement('canvas'), + backgroundColor: 0xffffff, + eventMode: 'static', + eventFeatures: { + move: false, + globalMove: false, + click: false, + wheel: false + } + }); + + this.containerElement = container; + container.appendChild(this.app.view); + // Add FPS counter + this.app.ticker.add(() => { + fps(this.app.ticker.FPS); + }); + } + + /** + * Returns a PIXI container and an overlay div for a given position + * @param position + * @returns + */ + makeContainer(position: { x: number; y: number; width: number; height: number }): { + pixiContainer: PIXI.Container; + overlayDiv: HTMLDivElement; + } { + const pContainer = new PIXI.Container(); + pContainer.position.set(position.x, position.y); + this.app.stage.addChild(pContainer); + + const plotDiv = createOverlayElement(position); + this.containerElement.appendChild(plotDiv); + + return { pixiContainer: pContainer, overlayDiv: plotDiv }; + } + + destroy(): void { + this.app.destroy(); + } +} + +/** + * Creates an absolute positioned div element + * @param position + * @returns + */ +export function createOverlayElement(position: { + x: number; + y: number; + width: number; + height: number; +}): HTMLDivElement { + const overlay = document.createElement('div'); + + overlay.style.position = 'absolute'; + overlay.style.left = `${position.x}px`; + overlay.style.top = `${position.y}px`; + overlay.style.width = `${position.width}px`; + overlay.style.height = `${position.height}px`; + overlay.id = `overlay-${Math.random().toString(36).substring(7)}`; // Add random id + + return overlay; +} From c0dd70843453b589244eeabbbbc594fd0de4c7ee Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 14:33:49 -0400 Subject: [PATCH 004/139] feat: alias --- tsconfig.json | 2 ++ vite.config.js | 1 + 2 files changed, 3 insertions(+) diff --git a/tsconfig.json b/tsconfig.json index a99a47df5..40d35e8ac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,6 +31,7 @@ "@gosling-lang/gosling-genomic-axis": ["./src/tracks/gosling-genomic-axis/index.ts"], "@gosling-lang/gosling-brush": ["./src/tracks/gosling-brush/index.ts"], "@gosling-lang/dummy-track": ["./src/tracks/dummy-track/index.ts"], + "pixi-manager": ["./src/pixi-manager/index.ts"], "@data-fetchers": ["./src/data-fetchers/index.ts"], "zlib": ["./src/alias/zlib.ts"] }, @@ -40,6 +41,7 @@ "files": [ "src/index.ts", "editor/index.tsx", + "demo/main.tsx", ], "include": ["src", "src/**/*.d.ts", "editor", "e2e"], "exclude": [ diff --git a/vite.config.js b/vite.config.js index a33be7028..800efa6dc 100644 --- a/vite.config.js +++ b/vite.config.js @@ -73,6 +73,7 @@ const alias = { '@gosling-lang/gosling-genomic-axis': path.resolve(__dirname, './src/tracks/gosling-genomic-axis/index.ts'), '@gosling-lang/gosling-brush': path.resolve(__dirname, './src/tracks/gosling-brush/index.ts'), '@gosling-lang/dummy-track': path.resolve(__dirname, './src/tracks/dummy-track/index.ts'), + '@pixi-manager': path.resolve(__dirname, './src/pixi-manager/index.ts'), '@data-fetchers': path.resolve(__dirname, './src/data-fetchers/index.ts'), zlib: path.resolve(__dirname, './src/alias/zlib.ts'), stream: path.resolve(__dirname, './node_modules/stream-browserify') // gmod/gff uses stream-browserify From 9f00ad86ab4c0f52cee2a40d47ba033896f584a7 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 14:33:59 -0400 Subject: [PATCH 005/139] feat: add to demo --- demo/App.tsx | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index 3f5d243ad..544291df8 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -1,26 +1,28 @@ -import React, { useState, useEffect } from "react"; -import "./App.css"; +import React, { useState, useEffect } from 'react'; +import { PixiManager } from '@pixi-manager'; +import './App.css'; function App() { - const [fps, setFps] = useState(120); + const [fps, setFps] = useState(120); - useEffect(() => { - // Create the new plot - const plotElement = document.getElementById("plot") as HTMLDivElement; - plotElement.innerHTML = ""; + useEffect(() => { + // Create the new plot + const plotElement = document.getElementById('plot') as HTMLDivElement; + plotElement.innerHTML = ''; + // Initialize the PixiManager. This will be used to get containers and overlay divs for the plots + const pixiManager = new PixiManager(1000, 600, plotElement, setFps); + }, []); - }, []); + return ( + <> +

HiGlass/Gosling tracks with new renderer

- return ( - <> -

HiGlass/Gosling tracks with new renderer

- -
-
-
- - ); +
+
+
+ + ); } export default App; From 1107a7ec9932f0c6d4eb96e1cc976a58cff3d152 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 14:37:31 -0400 Subject: [PATCH 006/139] refactor: add react import --- demo/main.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/demo/main.tsx b/demo/main.tsx index a4d5ce7eb..e59e2b2e3 100644 --- a/demo/main.tsx +++ b/demo/main.tsx @@ -1,7 +1,6 @@ -import ReactDOM from 'react-dom/client' -import App from './App.tsx' -import './index.css' +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; -ReactDOM.createRoot(document.getElementById('root')!).render( - -) +ReactDOM.createRoot(document.getElementById('root')!).render(); From 6e4824684ec84a19e12c5e8fb7877c885cb69fff Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 15:07:40 -0400 Subject: [PATCH 007/139] feat: remove higlass dependencies, add vendored dependencies --- package.json | 32 ++- pnpm-lock.yaml | 672 +++++++++++++------------------------------------ 2 files changed, 191 insertions(+), 513 deletions(-) diff --git a/package.json b/package.json index ab4df93ad..266bba59c 100644 --- a/package.json +++ b/package.json @@ -61,27 +61,32 @@ "bezier-js": "4.0.3", "buffer": "^6.0.3", "css-element-queries": "^1.2.3", - "d3-array": "^2.5.1", - "d3-color": "^2.0.0", - "d3-dsv": "^2.0.0", + "d3-array": "^3.2.4", + "d3-brush": "^3.0.0", + "d3-color": "^3.1.0", + "d3-drag": "^3.0.0", + "d3-dsv": "^3.0.1", "d3-format": "^3.1.0", - "d3-scale": "^3.2.1", - "d3-scale-chromatic": "^2.0.0", - "d3-shape": "^2.0.0", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.1.0", + "d3-selection": "^3.0.0", + "d3-shape": "^3.2.0", + "d3-transition": "^3.0.1", + "d3-zoom": "^3.0.0", "events": "^3.3.0", "fflate": "^0.7.1", "generic-filehandle": "^3.0.1", - "higlass": "^1.13.4", - "higlass-register": "^0.3.0", - "higlass-text": "^0.1.1", "json-stringify-pretty-compact": "^2.0.0", "jspdf": "^2.3.1", "lodash-es": "^4.17.21", "monaco-editor": "^0.27.0", "nanoevents": "^7.0.1", + "ndarray": "^1.0.19", + "pub-sub-es": "^2.1.1", "pubsub-js": "^1.9.3", "quick-lru": "^6.1.1", "rbush": "^3.0.1", + "slugid": "^3.0.0", "stream-browserify": "^3.0.0", "threads": "^1.6.4" }, @@ -91,13 +96,15 @@ "@types/d3": "^7.4.3", "@types/d3-array": "^3.2.1", "@types/d3-color": "^3.1.3", - "@types/d3-drag": "^2.0.0", - "@types/d3-dsv": "^3.0.1", + "@types/d3-drag": "^3.0.7", + "@types/d3-dsv": "^3.0.7", "@types/d3-format": "^3.0.4", "@types/d3-scale": "^4.0.8", "@types/d3-scale-chromatic": "^3.0.3", - "@types/d3-selection": "^2.0.0", + "@types/d3-selection": "^3.0.10", "@types/d3-shape": "^3.1.6", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", "@types/lodash-es": "^4.17.5", "@types/node": "^18.6.2", "@types/pixelmatch": "^5.2.5", @@ -116,7 +123,6 @@ "c8": "^7.11.2", "conventional-changelog-cli": "^2.1.1", "d3-drag": "^2.0.0", - "d3-selection": "^2.0.0", "esbuild": "^0.12.25", "eslint": "^8.19.0", "eslint-config-prettier": "^8.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e89f8e780..4066ed9b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,26 +39,41 @@ dependencies: specifier: ^1.2.3 version: 1.2.3 d3-array: - specifier: ^2.5.1 - version: 2.12.1 + specifier: ^3.2.4 + version: 3.2.4 + d3-brush: + specifier: ^3.0.0 + version: 3.0.0 d3-color: - specifier: ^2.0.0 - version: 2.0.0 + specifier: ^3.1.0 + version: 3.1.0 + d3-drag: + specifier: ^3.0.0 + version: 3.0.0 d3-dsv: - specifier: ^2.0.0 - version: 2.0.0 + specifier: ^3.0.1 + version: 3.0.1 d3-format: specifier: ^3.1.0 version: 3.1.0 d3-scale: - specifier: ^3.2.1 - version: 3.2.1 + specifier: ^4.0.2 + version: 4.0.2 d3-scale-chromatic: - specifier: ^2.0.0 - version: 2.0.0 + specifier: ^3.1.0 + version: 3.1.0 + d3-selection: + specifier: ^3.0.0 + version: 3.0.0 d3-shape: - specifier: ^2.0.0 - version: 2.1.0 + specifier: ^3.2.0 + version: 3.2.0 + d3-transition: + specifier: ^3.0.1 + version: 3.0.1(d3-selection@3.0.0) + d3-zoom: + specifier: ^3.0.0 + version: 3.0.0 events: specifier: ^3.3.0 version: 3.3.0 @@ -68,15 +83,6 @@ dependencies: generic-filehandle: specifier: ^3.0.1 version: 3.1.1 - higlass: - specifier: ^1.13.4 - version: 1.13.4(pixi.js@7.4.0)(react-dom@18.2.0)(react@18.2.0) - higlass-register: - specifier: ^0.3.0 - version: 0.3.0 - higlass-text: - specifier: ^0.1.1 - version: 0.1.6 json-stringify-pretty-compact: specifier: ^2.0.0 version: 2.0.0 @@ -92,6 +98,12 @@ dependencies: nanoevents: specifier: ^7.0.1 version: 7.0.1 + ndarray: + specifier: ^1.0.19 + version: 1.0.19 + pub-sub-es: + specifier: ^2.1.1 + version: 2.1.1 pubsub-js: specifier: ^1.9.3 version: 1.9.4 @@ -101,6 +113,9 @@ dependencies: rbush: specifier: ^3.0.1 version: 3.0.1 + slugid: + specifier: ^3.0.0 + version: 3.2.0 stream-browserify: specifier: ^3.0.0 version: 3.0.0 @@ -125,11 +140,11 @@ devDependencies: specifier: ^3.1.3 version: 3.1.3 '@types/d3-drag': - specifier: ^2.0.0 - version: 2.0.0 + specifier: ^3.0.7 + version: 3.0.7 '@types/d3-dsv': - specifier: ^3.0.1 - version: 3.0.1 + specifier: ^3.0.7 + version: 3.0.7 '@types/d3-format': specifier: ^3.0.4 version: 3.0.4 @@ -140,11 +155,17 @@ devDependencies: specifier: ^3.0.3 version: 3.0.3 '@types/d3-selection': - specifier: ^2.0.0 - version: 2.0.0 + specifier: ^3.0.10 + version: 3.0.10 '@types/d3-shape': specifier: ^3.1.6 version: 3.1.6 + '@types/d3-transition': + specifier: ^3.0.8 + version: 3.0.8 + '@types/d3-zoom': + specifier: ^3.0.8 + version: 3.0.8 '@types/lodash-es': specifier: ^4.17.5 version: 4.17.5 @@ -196,12 +217,6 @@ devDependencies: conventional-changelog-cli: specifier: ^2.1.1 version: 2.2.2 - d3-drag: - specifier: ^2.0.0 - version: 2.0.0 - d3-selection: - specifier: ^2.0.0 - version: 2.0.0 esbuild: specifier: ^0.12.25 version: 0.12.29 @@ -872,14 +887,6 @@ packages: engines: {node: '>=6.9.0'} dev: true - /@icons/material@0.2.4(react@18.2.0): - resolution: {integrity: sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==} - peerDependencies: - react: '*' - dependencies: - react: 18.2.0 - dev: false - /@isaacs/cliui@8.0.2: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -984,6 +991,7 @@ packages: '@pixi/core': 7.4.0 '@pixi/display': 7.4.0(@pixi/core@7.4.0) '@pixi/events': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) + dev: true /@pixi/app@7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0): resolution: {integrity: sha512-9pDB974rfuObG5YHvR7kdWhDiIV26b0GeC4vHRQB3bkmltguMi8SCQ9WQKH3WwRLaflzf9EMZpgX10cU1gLgKg==} @@ -993,6 +1001,7 @@ packages: dependencies: '@pixi/core': 7.4.0 '@pixi/display': 7.4.0(@pixi/core@7.4.0) + dev: true /@pixi/assets@7.4.0(@pixi/core@7.4.0): resolution: {integrity: sha512-Z7J2ZYSZ41Pr3CK0IXgtVV1HiLm1sG0AOZHAPMwB82wNdIDvmWowo/LkXvQmSHFLxFlEz1hWOdOFs1daWAeIfg==} @@ -1001,14 +1010,17 @@ packages: dependencies: '@pixi/core': 7.4.0 '@types/css-font-loading-module': 0.0.12 + dev: true /@pixi/color@7.4.0: resolution: {integrity: sha512-Qgn3OSW9SFCQ8wrm524anENwIAeRTORC014LkTqaBQrpuOUHrx11SCy4kNFaQyZWO1DCTe4m8g/foCK7zJM7cg==} dependencies: '@pixi/colord': 2.9.6 + dev: true /@pixi/colord@2.9.6: resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==} + dev: true /@pixi/compressed-textures@7.4.0(@pixi/assets@7.4.0)(@pixi/core@7.4.0): resolution: {integrity: sha512-M9bpOFeUPuss57mbRtJOD8cGh+X8xsfx8YMBqWzQTAfbA8hsTQ+O4arbMTyIxqZnaTvpmhlhTKwaVaI2V15NAg==} @@ -1018,9 +1030,11 @@ packages: dependencies: '@pixi/assets': 7.4.0(@pixi/core@7.4.0) '@pixi/core': 7.4.0 + dev: true /@pixi/constants@7.4.0: resolution: {integrity: sha512-jQMPMRqkOTjI4D0cHWqvu+pofw6bIa8861x2vp2kNsmM2zcBO/b01AlmILi5pEDk0nTumgzgmVHZ7dtT9KxfQw==} + dev: true /@pixi/core@7.4.0: resolution: {integrity: sha512-X6UiDzmdd2oRK3zQggDrWNIlw5rjZakByRIwI6MRgj17FGkpNkCY78dO1snZ6qnpUoo5M03aSUCFCfq6LKA5Bg==} @@ -1033,6 +1047,7 @@ packages: '@pixi/settings': 7.4.0 '@pixi/ticker': 7.4.0 '@pixi/utils': 7.4.0 + dev: true /@pixi/display@7.4.0(@pixi/core@7.4.0): resolution: {integrity: sha512-l+K6H9CqB2tQltpaxal3dIPPAOWhBWszrJm5EbK5sVVQFcaWXgeS/Hmniz0DhT7OpPmstcx4nii9hZgRkmMmEg==} @@ -1040,6 +1055,7 @@ packages: '@pixi/core': 7.4.0 dependencies: '@pixi/core': 7.4.0 + dev: true /@pixi/events@7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0): resolution: {integrity: sha512-9hshDahiFDbl3ZJt9cqutST+2aIZ8/bT29VVFuN2f0ZHatbEHVl46jqu0IL8d+TAlNUr+SI/JEaPA6/MR9sH6w==} @@ -1049,9 +1065,11 @@ packages: dependencies: '@pixi/core': 7.4.0 '@pixi/display': 7.4.0(@pixi/core@7.4.0) + dev: true /@pixi/extensions@7.4.0: resolution: {integrity: sha512-bX0aw6z2D9bJ5NOsrbuWXnBR7sy2z+dyq2EQ2/t0dF6Si764r8FiA0QUGFn9NJO1FTnB9LLjz7q4c0XaWF3mcg==} + dev: true /@pixi/extract@7.4.0(@pixi/core@7.4.0): resolution: {integrity: sha512-PLOdi8LxnRBRTKLx5plA9hWsIObiQ44tKMcyaLIESXNoUGE3135Aih10Hg1whrQcG4n9EqRjNak7LtwKRylRbg==} @@ -1059,6 +1077,7 @@ packages: '@pixi/core': 7.4.0 dependencies: '@pixi/core': 7.4.0 + dev: true /@pixi/filter-alpha@7.4.0(@pixi/core@7.4.0): resolution: {integrity: sha512-1KjdTcU4drduzF1HDu1clxZgM7b6lfE1CKESlY5CizJSMMGcycOUQRq/TWK54xrsJTyPWwNu5ojma6dcIqLOrw==} @@ -1066,6 +1085,7 @@ packages: '@pixi/core': 7.4.0 dependencies: '@pixi/core': 7.4.0 + dev: true /@pixi/filter-blur@7.4.0(@pixi/core@7.4.0): resolution: {integrity: sha512-XUrhswyuc4+flpDL0fQcRuei8ctgYCdTxCuetSqpS+qdf4gOJyq5UyCwDycJiudZD6+R23svUX5OQOPwkWTsNA==} @@ -1073,6 +1093,7 @@ packages: '@pixi/core': 7.4.0 dependencies: '@pixi/core': 7.4.0 + dev: true /@pixi/filter-color-matrix@7.4.0(@pixi/core@7.4.0): resolution: {integrity: sha512-Ap5Fh6iJo5Mk6xMTia5KAWj9G0b4F3LiqrrWkM0y9gGzD5ei85Hd+XHHJtzWi+d4P/EWv7KlND6SnVcTZFgV4A==} @@ -1080,6 +1101,7 @@ packages: '@pixi/core': 7.4.0 dependencies: '@pixi/core': 7.4.0 + dev: true /@pixi/filter-displacement@7.4.0(@pixi/core@7.4.0): resolution: {integrity: sha512-fcFLxFge2V6o7LqIsz/goDTMbwLdHjGggbu9/t4+byNP5f+S2TTR3oT4nulTYhNQph5vyllhSPJgHoqXXRhTwg==} @@ -1087,6 +1109,7 @@ packages: '@pixi/core': 7.4.0 dependencies: '@pixi/core': 7.4.0 + dev: true /@pixi/filter-fxaa@7.4.0(@pixi/core@7.4.0): resolution: {integrity: sha512-W4l01ca9hJpjAfswRkw6UaCNh76E9ymigSVIBzhUUFwjfvVvIh7+O9SnEzkTVHsY15ANsznD0XZjgt3pW/wFbg==} @@ -1094,6 +1117,7 @@ packages: '@pixi/core': 7.4.0 dependencies: '@pixi/core': 7.4.0 + dev: true /@pixi/filter-noise@7.4.0(@pixi/core@7.4.0): resolution: {integrity: sha512-q2+CWODAJO79j0StJ+xakX4D8r8w/RLURRiyG+focTIj1ws/7sdDmDsV+jmeKm6pEktwgA3JYWIKZUnezlGf8g==} @@ -1101,6 +1125,7 @@ packages: '@pixi/core': 7.4.0 dependencies: '@pixi/core': 7.4.0 + dev: true /@pixi/graphics@7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/sprite@7.4.0): resolution: {integrity: sha512-9GcXbP/iTFEA5xwXx6sSwGyIYPd6XVhFJR7ALqqnlYC+FvvvHPoh7cN3HPa1Aw9dWpNRKUKuNcoOYPmd0O0aJA==} @@ -1112,9 +1137,11 @@ packages: '@pixi/core': 7.4.0 '@pixi/display': 7.4.0(@pixi/core@7.4.0) '@pixi/sprite': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) + dev: true /@pixi/math@7.4.0: resolution: {integrity: sha512-9WCWKX5z/VOYGpsnXXQ73vg/IT+bUXCLO6miXuS5YPXNfw9RpvdV4ZgFmuQwPNM7wfFk5T7Uvfr8ZJRBCfKhZw==} + dev: true /@pixi/mesh-extras@7.4.0(@pixi/core@7.4.0)(@pixi/mesh@7.4.0): resolution: {integrity: sha512-YMI72eDruRd3iUIxfFNW+siuwvvrBv4/A9GDeBySKdfqbMOnzi0GLjxvF88bcP7eujdJQDwzTnAV4hW0UNIkjw==} @@ -1124,6 +1151,7 @@ packages: dependencies: '@pixi/core': 7.4.0 '@pixi/mesh': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) + dev: true /@pixi/mesh@7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0): resolution: {integrity: sha512-Ql5B3q8UD898LTKTPAkveOU72tN9xD8CsLPuvmPSrjpE5FlyRhrS90JzD26/sz6H3B7Kfu2gRjilmujCzNvuWA==} @@ -1133,6 +1161,7 @@ packages: dependencies: '@pixi/core': 7.4.0 '@pixi/display': 7.4.0(@pixi/core@7.4.0) + dev: true /@pixi/mixin-cache-as-bitmap@7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/sprite@7.4.0): resolution: {integrity: sha512-wFkwU19dCyY5m0JxiKf6UJwvR8XaGDWA/0VXZelBF+WwIj54uKjN4lNSnSApHHByFfq9BRka7B5C1fU9eZNOzg==} @@ -1144,6 +1173,7 @@ packages: '@pixi/core': 7.4.0 '@pixi/display': 7.4.0(@pixi/core@7.4.0) '@pixi/sprite': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) + dev: true /@pixi/mixin-get-child-by-name@7.4.0(@pixi/display@7.4.0): resolution: {integrity: sha512-GAWXSNnYtZyppxGVpt0lN2Iq6Z1MYuGeE/X5rYd5yO+Ra9VbUaslTRxf2y8H1TTWOPCIs8mcSTNdJTgElSfqbQ==} @@ -1151,6 +1181,7 @@ packages: '@pixi/display': 7.4.0 dependencies: '@pixi/display': 7.4.0(@pixi/core@7.4.0) + dev: true /@pixi/mixin-get-global-position@7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0): resolution: {integrity: sha512-u2EKXi7sv1zG2exk/bpjozBTOElBAsHnA0sHe0kz6sELpNBjv4g2n0Hwfl+qd69S+60zfN44ER+ihbFUWgD5VA==} @@ -1160,6 +1191,7 @@ packages: dependencies: '@pixi/core': 7.4.0 '@pixi/display': 7.4.0(@pixi/core@7.4.0) + dev: true /@pixi/particle-container@7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/sprite@7.4.0): resolution: {integrity: sha512-y3cB2EvgzfOm/pw4qBFsKOVoRzhzLy/FFj92DbD3bL5a6Z+YtKblkeWw3P5exzZJBTRn9sEk1vhzBb1HM/WEJw==} @@ -1171,6 +1203,7 @@ packages: '@pixi/core': 7.4.0 '@pixi/display': 7.4.0(@pixi/core@7.4.0) '@pixi/sprite': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) + dev: true /@pixi/prepare@7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/graphics@7.4.0)(@pixi/text@7.4.0): resolution: {integrity: sha512-qMRf0SPVYW6k0ZG19SdddwH/FErywEzkJtS7pCVrFy31RP4dF+ZunEffKNPm3Kf5b94JXd6+lIAxDy4tDVqXNQ==} @@ -1184,9 +1217,11 @@ packages: '@pixi/display': 7.4.0(@pixi/core@7.4.0) '@pixi/graphics': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/sprite@7.4.0) '@pixi/text': 7.4.0(@pixi/core@7.4.0)(@pixi/sprite@7.4.0) + dev: true /@pixi/runner@7.4.0: resolution: {integrity: sha512-TIfocv2TD4xHOiGSpeu2y3GMN09cKEpxiS/rswdCU/aacfgSyvjEmskL/dbq/PSA4FCmjVHLyjgNPvd79WPZhQ==} + dev: true /@pixi/settings@7.4.0: resolution: {integrity: sha512-ODWmSVfLnn384xFsXp+NNV6mQ+AwoeI4FtN+tMcJ+w/qQTi+eq6VLIpgqNx2Z/TJESI4HY4jxL6qz4SJEs7SMA==} @@ -1194,6 +1229,7 @@ packages: '@pixi/constants': 7.4.0 '@types/css-font-loading-module': 0.0.12 ismobilejs: 1.1.1 + dev: true /@pixi/sprite-animated@7.4.0(@pixi/core@7.4.0)(@pixi/sprite@7.4.0): resolution: {integrity: sha512-SVIO78hHqVvBg5kh13TES0oqmjBhjeQmCgXVzT1nC62Vxh/6AAd9JOKid706lXoqRgw7H7OhdunEWL6J2zN4KA==} @@ -1203,6 +1239,7 @@ packages: dependencies: '@pixi/core': 7.4.0 '@pixi/sprite': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) + dev: true /@pixi/sprite-tiling@7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/sprite@7.4.0): resolution: {integrity: sha512-q0wjrdhvqnfSRNYIJ0KHUIT0nARvlmBoKBtjEZLAnk1jQCFzrJIg4qfmsBNDSOzMVaAxAot0EbOLjld6EZmf8w==} @@ -1214,6 +1251,7 @@ packages: '@pixi/core': 7.4.0 '@pixi/display': 7.4.0(@pixi/core@7.4.0) '@pixi/sprite': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) + dev: true /@pixi/sprite@7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0): resolution: {integrity: sha512-+yQdq3aKS59s9uxiW066geWLCKYTRjtbdgE2qtyUP4pK/bYanWVWash7K8P3qVX8NQsQKjGvNPoa2fkP6MBE1Q==} @@ -1223,6 +1261,7 @@ packages: dependencies: '@pixi/core': 7.4.0 '@pixi/display': 7.4.0(@pixi/core@7.4.0) + dev: true /@pixi/spritesheet@7.4.0(@pixi/assets@7.4.0)(@pixi/core@7.4.0): resolution: {integrity: sha512-wztt4ne71AWDY4WMyuoMUrZlYVeKkubRTqT9HcPYxDEClxZAz1ggsr03PB4RGHbNQkVC1ImrAi9fa0D0PkyPYg==} @@ -1232,6 +1271,7 @@ packages: dependencies: '@pixi/assets': 7.4.0(@pixi/core@7.4.0) '@pixi/core': 7.4.0 + dev: true /@pixi/text-bitmap@7.4.0(@pixi/assets@7.4.0)(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/mesh@7.4.0)(@pixi/text@7.4.0): resolution: {integrity: sha512-OkYixlqMW9b1EHtEbSP9mgZEqI0WLN1KP4h2EyJk0LC9lH2Ybp3v7ZGHKAetGkSCt8PXY5AfXbcWtm+TgTWbJw==} @@ -1247,6 +1287,7 @@ packages: '@pixi/display': 7.4.0(@pixi/core@7.4.0) '@pixi/mesh': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) '@pixi/text': 7.4.0(@pixi/core@7.4.0)(@pixi/sprite@7.4.0) + dev: true /@pixi/text-html@7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/sprite@7.4.0)(@pixi/text@7.4.0): resolution: {integrity: sha512-HOSKLynkL4cXQdv7zMst7+vISKp4ueCdJpV2zwQJnwVa/dHKlMULQ4+F5yxbtgAF8fYcH3iNfFLaraFlx1hL5A==} @@ -1260,6 +1301,7 @@ packages: '@pixi/display': 7.4.0(@pixi/core@7.4.0) '@pixi/sprite': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) '@pixi/text': 7.4.0(@pixi/core@7.4.0)(@pixi/sprite@7.4.0) + dev: true /@pixi/text@7.4.0(@pixi/core@7.4.0)(@pixi/sprite@7.4.0): resolution: {integrity: sha512-yVVeWYH6N+E38R+D7tvOVwDhbFxrInZ7fkOllfePu3KaKsUXbjklgtKUyPREs1LGJC8ffrpCPo1k9BVmwFA4Eg==} @@ -1269,6 +1311,7 @@ packages: dependencies: '@pixi/core': 7.4.0 '@pixi/sprite': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0) + dev: true /@pixi/ticker@7.4.0: resolution: {integrity: sha512-GaDmk27tEpPfUVgVTNQWGuOYGu6ehqmVSGxecCv4No5KHP52+LihTC4YHO06zRxfyrIOgafooDL/vQiEMqas8g==} @@ -1276,6 +1319,7 @@ packages: '@pixi/extensions': 7.4.0 '@pixi/settings': 7.4.0 '@pixi/utils': 7.4.0 + dev: true /@pixi/utils@7.4.0: resolution: {integrity: sha512-VBnxNGGg/uj7k1wmvyNZei2qpbFNN/kdQ2/mwNXJtFcFymVfijNZWRUNobpSRE/yHx40WGYzSm3ZJZrF4WxFzA==} @@ -1287,6 +1331,7 @@ packages: earcut: 2.2.4 eventemitter3: 4.0.7 url: 0.11.3 + dev: true /@pkgjs/parseargs@0.11.0: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} @@ -1396,6 +1441,7 @@ packages: /@types/css-font-loading-module@0.0.12: resolution: {integrity: sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==} + dev: true /@types/d3-array@3.2.1: resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} @@ -1404,13 +1450,13 @@ packages: /@types/d3-axis@3.0.6: resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} dependencies: - '@types/d3-selection': 2.0.0 + '@types/d3-selection': 3.0.10 dev: true /@types/d3-brush@3.0.6: resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} dependencies: - '@types/d3-selection': 2.0.0 + '@types/d3-selection': 3.0.10 dev: true /@types/d3-chord@3.0.6: @@ -1436,14 +1482,14 @@ packages: resolution: {integrity: sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==} dev: true - /@types/d3-drag@2.0.0: - resolution: {integrity: sha512-VaUJPjbMnDn02tcRqsHLRAX5VjcRIzCjBfeXTLGe6QjMn5JccB5Cz4ztMRXMJfkbC45ovgJFWuj6DHvWMX1thA==} + /@types/d3-drag@3.0.7: + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} dependencies: - '@types/d3-selection': 2.0.0 + '@types/d3-selection': 3.0.10 dev: true - /@types/d3-dsv@3.0.1: - resolution: {integrity: sha512-76pBHCMTvPLt44wFOieouXcGXWOF0AJCceUvaFkxSZEu4VDUdv93JfpMa6VGNFs01FHfuP4a5Ou68eRG1KBfTw==} + /@types/d3-dsv@3.0.7: + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} dev: true /@types/d3-ease@3.0.2: @@ -1453,7 +1499,7 @@ packages: /@types/d3-fetch@3.0.7: resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} dependencies: - '@types/d3-dsv': 3.0.1 + '@types/d3-dsv': 3.0.7 dev: true /@types/d3-force@3.0.9: @@ -1506,8 +1552,8 @@ packages: '@types/d3-time': 3.0.3 dev: true - /@types/d3-selection@2.0.0: - resolution: {integrity: sha512-EF0lWZ4tg7oDFg4YQFlbOU3936e3a9UmoQ2IXlBy1+cv2c2Pv7knhKUzGlH5Hq2sF/KeDTH1amiRPey2rrLMQA==} + /@types/d3-selection@3.0.10: + resolution: {integrity: sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg==} dev: true /@types/d3-shape@3.1.6: @@ -1531,14 +1577,14 @@ packages: /@types/d3-transition@3.0.8: resolution: {integrity: sha512-ew63aJfQ/ms7QQ4X7pk5NxQ9fZH/z+i24ZfJ6tJSfqxJMrYLiK01EAs2/Rtw/JreGUsS3pLPNV644qXFGnoZNQ==} dependencies: - '@types/d3-selection': 2.0.0 + '@types/d3-selection': 3.0.10 dev: true /@types/d3-zoom@3.0.8: resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} dependencies: '@types/d3-interpolate': 3.0.4 - '@types/d3-selection': 2.0.0 + '@types/d3-selection': 3.0.10 dev: true /@types/d3@7.4.3: @@ -1552,8 +1598,8 @@ packages: '@types/d3-contour': 3.0.6 '@types/d3-delaunay': 6.0.4 '@types/d3-dispatch': 3.0.6 - '@types/d3-drag': 2.0.0 - '@types/d3-dsv': 3.0.1 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 '@types/d3-ease': 3.0.2 '@types/d3-fetch': 3.0.7 '@types/d3-force': 3.0.9 @@ -1567,7 +1613,7 @@ packages: '@types/d3-random': 3.0.3 '@types/d3-scale': 4.0.8 '@types/d3-scale-chromatic': 3.0.3 - '@types/d3-selection': 2.0.0 + '@types/d3-selection': 3.0.10 '@types/d3-shape': 3.1.6 '@types/d3-time': 3.0.3 '@types/d3-time-format': 4.0.3 @@ -1578,6 +1624,7 @@ packages: /@types/earcut@2.1.4: resolution: {integrity: sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==} + dev: true /@types/geojson@7946.0.13: resolution: {integrity: sha512-bmrNrgKMOhM3WsafmbGmC+6dsF2Z308vLFsQ3a/bT8X8Sv5clVYpPars/UPq+sAaJP+5OoLAYgwbkS5QEJdLUQ==} @@ -1994,6 +2041,7 @@ packages: fast-json-stable-stringify: 2.1.0 json-schema-traverse: 0.4.1 uri-js: 4.4.1 + dev: true /allotment@1.19.3(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-StPCZLGPNG9KhXeNCrqTkIK09s7D6+8n0w754SRY8PUvDXFLLovrFFo4ubd82fytRWS5bFntsWA5SX4sODxuiw==} @@ -2214,17 +2262,6 @@ packages: engines: {node: '>=12'} dev: false - /bit-twiddle@1.0.2: - resolution: {integrity: sha512-B9UhK0DKFZhoTFcfvAzhqsjStvGJp9vYWf3+6SNTtdSQnvIgfkHbgHrg/e4+TH71N2GDu8tpmCVoyfrL1d7ntA==} - dev: false - - /box-intersect@1.0.2: - resolution: {integrity: sha512-yJeMwlmFPG1gIa7Rs/cGXeI6iOj6Qz5MG5PE61xLKpElUGzmJ4abm+qsLpzxKJFpsSDq742BQEocr8dI2t8Nxw==} - dependencies: - bit-twiddle: 1.0.2 - typedarray-pool: 1.2.0 - dev: false - /brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} dependencies: @@ -2307,6 +2344,7 @@ packages: function-bind: 1.1.2 get-intrinsic: 1.2.2 set-function-length: 1.1.1 + dev: true /callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} @@ -2427,11 +2465,6 @@ packages: dev: true optional: true - /clsx@1.2.1: - resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} - engines: {node: '>=6'} - dev: false - /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: @@ -2460,15 +2493,16 @@ packages: delayed-stream: 1.0.0 dev: true - /commander@2.20.3: - resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} - dev: false - /commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} dev: true + /commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + dev: false + /commander@9.5.0: resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} engines: {node: ^12.20.0 || >=14} @@ -2726,36 +2760,22 @@ packages: resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} dev: true - /d3-array@2.12.1: - resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + /d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} dependencies: internmap: 1.0.1 dev: false - /d3-axis@2.1.0: - resolution: {integrity: sha512-z/G2TQMyuf0X3qP+Mh+2PimoJD41VOCjViJzT0BHeL/+JQAofkiWZbWxlwFGb1N8EN+Cl/CW+MUKbVzr1689Cw==} - dev: false - - /d3-brush@2.1.0: - resolution: {integrity: sha512-cHLLAFatBATyIKqZOkk/mDHUbzne2B3ZwxkzMHvFTCZCmLaXDpZRihQSn8UNXTkGD/3lb/W2sQz0etAftmHMJQ==} + /d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} dependencies: - d3-dispatch: 2.0.0 - d3-drag: 2.0.0 - d3-interpolate: 2.0.1 - d3-selection: 2.0.0 - d3-transition: 2.0.0(d3-selection@2.0.0) - dev: false - - /d3-collection@1.0.7: - resolution: {integrity: sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==} - dev: false - - /d3-color@1.4.1: - resolution: {integrity: sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==} - dev: false - - /d3-color@2.0.0: - resolution: {integrity: sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==} + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) dev: false /d3-color@3.1.0: @@ -2763,24 +2783,11 @@ packages: engines: {node: '>=12'} dev: false - /d3-dispatch@1.0.6: - resolution: {integrity: sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==} - dev: false - - /d3-dispatch@2.0.0: - resolution: {integrity: sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==} - /d3-dispatch@3.0.1: resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} engines: {node: '>=12'} dev: false - /d3-drag@2.0.0: - resolution: {integrity: sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==} - dependencies: - d3-dispatch: 2.0.0 - d3-selection: 2.0.0 - /d3-drag@3.0.0: resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} engines: {node: '>=12'} @@ -2789,134 +2796,67 @@ packages: d3-selection: 3.0.0 dev: false - /d3-dsv@1.2.0: - resolution: {integrity: sha512-9yVlqvZcSOMhCYzniHE7EVUws7Fa1zgw+/EAV2BxJoG3ME19V6BQFBwI855XQDsxyOuG7NibqRMTtiF/Qup46g==} - hasBin: true - dependencies: - commander: 2.20.3 - iconv-lite: 0.4.24 - rw: 1.3.3 - dev: false - - /d3-dsv@2.0.0: - resolution: {integrity: sha512-E+Pn8UJYx9mViuIUkoc93gJGGYut6mSDKy2+XaPwccwkRGlR+LO97L2VCCRjQivTwLHkSnAJG7yo00BWY6QM+w==} + /d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} hasBin: true dependencies: - commander: 2.20.3 - iconv-lite: 0.4.24 + commander: 7.2.0 + iconv-lite: 0.6.3 rw: 1.3.3 dev: false - /d3-ease@2.0.0: - resolution: {integrity: sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==} - dev: false - /d3-ease@3.0.1: resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} engines: {node: '>=12'} dev: false - /d3-format@1.4.5: - resolution: {integrity: sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==} - dev: false - - /d3-format@2.0.0: - resolution: {integrity: sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA==} - dev: false - /d3-format@3.1.0: resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} engines: {node: '>=12'} dev: false - /d3-geo@2.0.2: - resolution: {integrity: sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==} - dependencies: - d3-array: 2.12.1 - dev: false - - /d3-interpolate@1.4.0: - resolution: {integrity: sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==} - dependencies: - d3-color: 1.4.1 - dev: false - - /d3-interpolate@2.0.1: - resolution: {integrity: sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==} - dependencies: - d3-color: 2.0.0 - dev: false - /d3-interpolate@3.0.1: resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} engines: {node: '>=12'} dependencies: - d3-color: 2.0.0 - dev: false - - /d3-path@2.0.0: - resolution: {integrity: sha512-ZwZQxKhBnv9yHaiWd6ZU4x5BtCQ7pXszEV9CU6kRgwIQVQGLMv1oiL4M+MK/n79sYzsj+gcgpPQSctJUsLN7fA==} - dev: false - - /d3-queue@3.0.7: - resolution: {integrity: sha512-2rs+6pNFKkrJhqe1rg5znw7dKJ7KZr62j9aLZfhondkrnz6U7VRmJj1UGcbD8MRc46c7H8m4SWhab8EalBQrkw==} - dev: false - - /d3-request@1.0.6: - resolution: {integrity: sha512-FJj8ySY6GYuAJHZMaCQ83xEYE4KbkPkmxZ3Hu6zA1xxG2GD+z6P+Lyp+zjdsHf0xEbp2xcluDI50rCS855EQ6w==} - dependencies: - d3-collection: 1.0.7 - d3-dispatch: 1.0.6 - d3-dsv: 1.2.0 - xmlhttprequest: 1.8.0 + d3-color: 3.1.0 dev: false - /d3-scale-chromatic@2.0.0: - resolution: {integrity: sha512-LLqy7dJSL8yDy7NRmf6xSlsFZ6zYvJ4BcWFE4zBrOPnQERv9zj24ohnXKRbyi9YHnYV+HN1oEO3iFK971/gkzA==} - dependencies: - d3-color: 2.0.0 - d3-interpolate: 2.0.1 + /d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} dev: false - /d3-scale@3.2.1: - resolution: {integrity: sha512-huz5byJO/6MPpz6Q8d4lg7GgSpTjIZW/l+1MQkzKfu2u8P6hjaXaStOpmyrD6ymKoW87d2QVFCKvSjLwjzx/rA==} + /d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} dependencies: - d3-array: 2.12.1 - d3-format: 1.4.5 - d3-interpolate: 1.4.0 - d3-time: 1.1.0 - d3-time-format: 2.3.0 + d3-color: 3.1.0 + d3-interpolate: 3.0.1 dev: false /d3-scale@4.0.2: resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} engines: {node: '>=12'} dependencies: - d3-array: 2.12.1 + d3-array: 3.2.4 d3-format: 3.1.0 d3-interpolate: 3.0.1 d3-time: 3.1.0 d3-time-format: 4.1.0 dev: false - /d3-selection@2.0.0: - resolution: {integrity: sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==} - /d3-selection@3.0.0: resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} engines: {node: '>=12'} dev: false - /d3-shape@2.1.0: - resolution: {integrity: sha512-PnjUqfM2PpskbSLTJvAzp2Wv4CZsnAgTfcVRTwW03QR3MkXF8Uo7B1y/lWkAsmbKwuecto++4NlsYcvYpXpTHA==} - dependencies: - d3-path: 2.0.0 - dev: false - - /d3-time-format@2.3.0: - resolution: {integrity: sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==} + /d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} dependencies: - d3-time: 1.1.0 + d3-path: 3.1.0 dev: false /d3-time-format@4.1.0: @@ -2926,19 +2866,11 @@ packages: d3-time: 3.1.0 dev: false - /d3-time@1.1.0: - resolution: {integrity: sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==} - dev: false - /d3-time@3.1.0: resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} engines: {node: '>=12'} dependencies: - d3-array: 2.12.1 - dev: false - - /d3-timer@2.0.0: - resolution: {integrity: sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==} + d3-array: 3.2.4 dev: false /d3-timer@3.0.1: @@ -2946,33 +2878,6 @@ packages: engines: {node: '>=12'} dev: false - /d3-transition@2.0.0(d3-selection@2.0.0): - resolution: {integrity: sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==} - peerDependencies: - d3-selection: '2' - dependencies: - d3-color: 2.0.0 - d3-dispatch: 2.0.0 - d3-ease: 2.0.0 - d3-interpolate: 2.0.1 - d3-selection: 2.0.0 - d3-timer: 2.0.0 - dev: false - - /d3-transition@3.0.1(d3-selection@2.0.0): - resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} - engines: {node: '>=12'} - peerDependencies: - d3-selection: 2 - 3 - dependencies: - d3-color: 3.1.0 - d3-dispatch: 3.0.1 - d3-ease: 3.0.1 - d3-interpolate: 3.0.1 - d3-selection: 2.0.0 - d3-timer: 3.0.1 - dev: false - /d3-transition@3.0.1(d3-selection@3.0.0): resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} engines: {node: '>=12'} @@ -3078,6 +2983,7 @@ packages: get-intrinsic: 1.2.2 gopd: 1.0.1 has-property-descriptors: 1.0.1 + dev: true /define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} @@ -3124,10 +3030,6 @@ packages: esutils: 2.0.3 dev: true - /dom-scroll-into-view@1.2.1: - resolution: {integrity: sha512-LwNVg3GJOprWDO+QhLL1Z9MMgWe/KAFLxVWKzjRTxNSPn8/LLDIfmuG71YHznXCqaqTjvHJDYO1MEAgX6XCNbQ==} - dev: false - /dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} dependencies: @@ -3176,12 +3078,9 @@ packages: is-obj: 2.0.0 dev: true - /dup@1.0.0: - resolution: {integrity: sha512-Bz5jxMMC0wgp23Zm15ip1x8IhYRqJvF3nFC0UInJUDkN1z4uNPk9jTnfCUJXbOGiQ1JbXLQsiV41Fb+HXcj5BA==} - dev: false - /earcut@2.2.4: resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} + dev: true /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -3587,6 +3486,7 @@ packages: /eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + dev: true /eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} @@ -3603,6 +3503,7 @@ packages: /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + dev: true /fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} @@ -3621,6 +3522,7 @@ packages: /fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + dev: true /fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} @@ -3756,6 +3658,7 @@ packages: /function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + dev: true /function.prototype.name@1.1.6: resolution: {integrity: sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==} @@ -3775,10 +3678,6 @@ packages: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} dev: true - /genbank-parser@1.2.4: - resolution: {integrity: sha512-r3pTgKHZx/rol90v2cezrNhfMhq3yHWCnBYyETNIJkvnJk+cwx/D/ZVgAy1SX8zwtnfvYQmFbqlpbh2f4t0h2w==} - dev: false - /generic-filehandle@3.1.1: resolution: {integrity: sha512-8fLkHgbnKlOWhJgPmvGm+0HclUkyCPM0WGZQOAWdebNsWbHZJi7pW/RAPp26fhqlVpgT5tHqVKWunf/r+HRoEw==} engines: {node: '>=12'} @@ -3807,6 +3706,7 @@ packages: has-proto: 1.0.1 has-symbols: 1.0.3 hasown: 2.0.0 + dev: true /get-pkg-repo@4.2.1: resolution: {integrity: sha512-2+QbHjFRfGB74v/pYWjd5OhU3TDIC2Gv/YKUTk/tCvAz0pkn/Mz6P3uByuBimLOcPvN2jYdScl3xGFSrx0jEcA==} @@ -3945,6 +3845,7 @@ packages: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} dependencies: get-intrinsic: 1.2.2 + dev: true /graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -3994,14 +3895,17 @@ packages: resolution: {integrity: sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==} dependencies: get-intrinsic: 1.2.2 + dev: true /has-proto@1.0.1: resolution: {integrity: sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==} engines: {node: '>= 0.4'} + dev: true /has-symbols@1.0.3: resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} engines: {node: '>= 0.4'} + dev: true /has-tostringtag@1.0.0: resolution: {integrity: sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==} @@ -4020,68 +3924,7 @@ packages: engines: {node: '>= 0.4'} dependencies: function-bind: 1.1.2 - - /higlass-register@0.3.0: - resolution: {integrity: sha512-D4I+ATxFuTn+Q6p8Y9rUa+X3b3cqMqhu9Tya6uHtvgV5cs39Mk7i6Z+7PMA8YuwQd5gLMSxCm2lCyWmxRVBQsQ==} - dev: false - - /higlass-text@0.1.6: - resolution: {integrity: sha512-sjAROJfE38bSP72XncPTBV4AeYRs50N85OqRXooEwXF5zKVssBv1EKrvls0ralnSQ1aZX74cB/hTskbI91iRng==} - dependencies: - d3-color: 3.1.0 - d3-scale: 4.0.2 - higlass-register: 0.3.0 - slugid: 3.2.0 - dev: false - - /higlass@1.13.4(pixi.js@7.4.0)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-mIr+Yi5aR0zBAB9iqlqTz/K3ZusrGz8f20g5DHaaRZ/FzMOcFsdL+9ESIPndPZrN2Y/GQ4CHilNjPTTSncIOTg==} - engines: {node: '>=0.12.0'} - peerDependencies: - pixi.js: ^5.0.3 || ^6.5.2 - react: ^16.6.3 || ^17.0.0 || ^18.0.0 - react-dom: ^16.6.3 || ^17.0.0 || ^18.0.0 - dependencies: - ajv: 6.12.6 - box-intersect: 1.0.2 - buffer: 6.0.3 - clsx: 1.2.1 - css-element-queries: 1.2.3 - d3-array: 2.12.1 - d3-axis: 2.1.0 - d3-brush: 2.1.0 - d3-color: 2.0.0 - d3-drag: 2.0.0 - d3-dsv: 2.0.0 - d3-format: 2.0.0 - d3-geo: 2.0.2 - d3-queue: 3.0.7 - d3-request: 1.0.6 - d3-scale: 4.0.2 - d3-selection: 2.0.0 - d3-transition: 3.0.1(d3-selection@2.0.0) - d3-zoom: 3.0.0 - dom-scroll-into-view: 1.2.1 - genbank-parser: 1.2.4 - ndarray: 1.0.19 - pako: 1.0.11 - pixi.js: 7.4.0 - prismjs: 1.29.0 - prop-types: 15.8.1 - pub-sub-es: 2.1.1 - react: 18.2.0 - react-checkbox-tree: 1.8.0(react@18.2.0) - react-color: 2.19.3(react@18.2.0) - react-dom: 18.2.0(react@18.2.0) - react-grid-layout: 0.16.6(react-dom@18.2.0)(react@18.2.0) - react-simple-code-editor: 0.9.15(react-dom@18.2.0)(react@18.2.0) - react-sortable-hoc: 1.11.0(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0) - reactcss: 1.2.3(react@18.2.0) - robust-point-in-polygon: 1.0.3 - slugid: 3.2.0 - url-parse: 1.5.10 - vkbeautify: 0.99.3 - dev: false + dev: true /history@4.10.1: resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==} @@ -4173,19 +4016,11 @@ packages: - supports-color dev: true - /iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - dependencies: - safer-buffer: 2.1.2 - dev: false - /iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} dependencies: safer-buffer: 2.1.2 - dev: true /identity-function@1.0.0: resolution: {integrity: sha512-kNrgUK0qI+9qLTBidsH85HjDLpZfrrS0ElquKKe/fJFdB3D7VeKdXXEvOPDUHSHOzdZKCAAaQIWWyp0l2yq6pw==} @@ -4245,12 +4080,6 @@ packages: resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} dev: false - /invariant@2.2.4: - resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - dependencies: - loose-envify: 1.4.0 - dev: false - /iota-array@1.0.0: resolution: {integrity: sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==} dev: false @@ -4500,6 +4329,7 @@ packages: /ismobilejs@1.1.1: resolution: {integrity: sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==} + dev: true /istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} @@ -4647,6 +4477,7 @@ packages: /json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + dev: true /json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -4830,6 +4661,7 @@ packages: /lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + dev: true /long@4.0.0: resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} @@ -4910,10 +4742,6 @@ packages: repeat-string: 1.6.1 dev: true - /material-colors@1.2.6: - resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==} - dev: false - /mdast-add-list-metadata@1.0.1: resolution: {integrity: sha512-fB/VP4MJ0LaRsog7hGPxgOrSL3gE/2uEdZyDuSEnKCv/8IkYHiDkIQSbChiJoHyxZZXZ9bzckyRk+vNxFzh8rA==} dependencies: @@ -5172,6 +5000,7 @@ packages: resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + dev: true /natural-compare-lite@1.4.0: resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} @@ -5259,9 +5088,11 @@ packages: /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + dev: true /object-inspect@1.13.1: resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==} + dev: true /object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} @@ -5600,6 +5431,7 @@ packages: '@pixi/text': 7.4.0(@pixi/core@7.4.0)(@pixi/sprite@7.4.0) '@pixi/text-bitmap': 7.4.0(@pixi/assets@7.4.0)(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/mesh@7.4.0)(@pixi/text@7.4.0) '@pixi/text-html': 7.4.0(@pixi/core@7.4.0)(@pixi/display@7.4.0)(@pixi/sprite@7.4.0)(@pixi/text@7.4.0) + dev: true /pkg-types@1.0.3: resolution: {integrity: sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==} @@ -5678,11 +5510,6 @@ packages: parse-ms: 3.0.0 dev: true - /prismjs@1.29.0: - resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==} - engines: {node: '>=6'} - dev: false - /process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} dev: true @@ -5693,6 +5520,7 @@ packages: loose-envify: 1.4.0 object-assign: 4.1.1 react-is: 16.13.1 + dev: true /psl@1.9.0: resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} @@ -5708,10 +5536,12 @@ packages: /punycode@1.4.1: resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} + dev: true /punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + dev: true /q@1.5.1: resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==} @@ -5723,9 +5553,11 @@ packages: engines: {node: '>=0.6'} dependencies: side-channel: 1.0.4 + dev: true /querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + dev: true /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -5767,33 +5599,6 @@ packages: quickselect: 2.0.0 dev: false - /react-checkbox-tree@1.8.0(react@18.2.0): - resolution: {integrity: sha512-ufC4aorihOvjLpvY1beab2hjVLGZbDTFRzw62foG0+th+KX7e/sdmWu/nD1ZS/U5Yr0rWGwedGH5GOtR0IkUXw==} - peerDependencies: - react: ^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 - dependencies: - classnames: 2.3.2 - lodash: 4.17.21 - nanoid: 3.3.7 - prop-types: 15.8.1 - react: 18.2.0 - dev: false - - /react-color@2.19.3(react@18.2.0): - resolution: {integrity: sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==} - peerDependencies: - react: '*' - dependencies: - '@icons/material': 0.2.4(react@18.2.0) - lodash: 4.17.21 - lodash-es: 4.17.21 - material-colors: 1.2.6 - prop-types: 15.8.1 - react: 18.2.0 - reactcss: 1.2.3(react@18.2.0) - tinycolor2: 1.6.0 - dev: false - /react-dom@18.2.0(react@18.2.0): resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: @@ -5803,45 +5608,9 @@ packages: react: 18.2.0 scheduler: 0.23.0 - /react-draggable@3.3.2(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-oaz8a6enjbPtx5qb0oDWxtDNuybOylvto1QLydsXgKmwT7e3GXC2eMVDwEMIUYJIFqVG72XpOv673UuuAq6LhA==} - peerDependencies: - react: '>= 16.3.0' - react-dom: '>= 16.3.0' - dependencies: - classnames: 2.3.2 - prop-types: 15.8.1 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - - /react-draggable@4.4.6(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==} - peerDependencies: - react: '>= 16.3.0' - react-dom: '>= 16.3.0' - dependencies: - clsx: 1.2.1 - prop-types: 15.8.1 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - - /react-grid-layout@0.16.6(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-h2EsYgsqcESLJeevQSJsEKp8hhh+phOlXDJoMhlV2e7T3VWQL+S6iCF3iD/LK19r4oyRyOMDEir0KV+eLXrAyw==} - dependencies: - classnames: 2.3.2 - lodash.isequal: 4.5.0 - prop-types: 15.8.1 - react-draggable: 3.3.2(react-dom@18.2.0)(react@18.2.0) - react-resizable: 1.11.1(react-dom@18.2.0)(react@18.2.0) - transitivePeerDependencies: - - react - - react-dom - dev: false - /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + dev: true /react-is@18.2.0: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} @@ -5886,18 +5655,6 @@ packages: engines: {node: '>=0.10.0'} dev: true - /react-resizable@1.11.1(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-S70gbLaAYqjuAd49utRHibtHLrHXInh7GuOR+6OO6RO6uleQfuBnWmZjRABfqNEx3C3Z6VPLg0/0uOYFrkfu9Q==} - peerDependencies: - react: 0.14.x || 15.x || 16.x || 17.x - react-dom: 0.14.x || 15.x || 16.x || 17.x - dependencies: - prop-types: 15.8.1 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - react-draggable: 4.4.6(react-dom@18.2.0)(react@18.2.0) - dev: false - /react-resize-detector@4.2.3(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-4AeS6lxdz2KOgDZaOVt1duoDHrbYwSrUX32KeM9j6t9ISyRphoJbTRCMS1aPFxZHFqcCGLT1gMl3lEcSWZNW0A==} peerDependencies: @@ -5945,45 +5702,12 @@ packages: tiny-warning: 1.0.3 dev: true - /react-simple-code-editor@0.9.15(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-M8iKgjBTBZK92tZYgOEfMuR7c3zZ0q0v3QYllSxIPx3SU+w003VofH50txXQSBTu92pSOm2tidON1HbQ1l8BDA==} - peerDependencies: - react: ^16.0.0 - react-dom: ^16.0.0 - dependencies: - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - - /react-sortable-hoc@1.11.0(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-v1CDCvdfoR3zLGNp6qsBa4J1BWMEVH25+UKxF/RvQRh+mrB+emqtVHMgZ+WreUiKJoEaiwYoScaueIKhMVBHUg==} - peerDependencies: - prop-types: ^15.5.7 - react: ^0.14.0 || ^15.0.0 || ^16.0.0 - react-dom: ^0.14.0 || ^15.0.0 || ^16.0.0 - dependencies: - '@babel/runtime': 7.23.5 - invariant: 2.2.4 - prop-types: 15.8.1 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - /react@18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'} dependencies: loose-envify: 1.4.0 - /reactcss@1.2.3(react@18.2.0): - resolution: {integrity: sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==} - peerDependencies: - react: '*' - dependencies: - lodash: 4.17.21 - react: 18.2.0 - dev: false - /read-package-json-fast@3.0.2: resolution: {integrity: sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -6120,6 +5844,7 @@ packages: /requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + dev: true /resize-observer-polyfill@1.5.1: resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} @@ -6175,36 +5900,6 @@ packages: glob: 7.2.3 dev: true - /robust-orientation@1.2.1: - resolution: {integrity: sha512-FuTptgKwY6iNuU15nrIJDLjXzCChWB+T4AvksRtwPS/WZ3HuP1CElCm1t+OBfgQKfWbtZIawip+61k7+buRKAg==} - dependencies: - robust-scale: 1.0.2 - robust-subtract: 1.0.0 - robust-sum: 1.0.0 - two-product: 1.0.2 - dev: false - - /robust-point-in-polygon@1.0.3: - resolution: {integrity: sha512-pPzz7AevOOcPYnFv4Vs5L0C7BKOq6C/TfAw5EUE58CylbjGiPyMjAnPLzzSuPZ2zftUGwWbmLWPOjPOz61tAcA==} - dependencies: - robust-orientation: 1.2.1 - dev: false - - /robust-scale@1.0.2: - resolution: {integrity: sha512-jBR91a/vomMAzazwpsPTPeuTPPmWBacwA+WYGNKcRGSh6xweuQ2ZbjRZ4v792/bZOhRKXRiQH0F48AvuajY0tQ==} - dependencies: - two-product: 1.0.2 - two-sum: 1.0.0 - dev: false - - /robust-subtract@1.0.0: - resolution: {integrity: sha512-xhKUno+Rl+trmxAIVwjQMiVdpF5llxytozXJOdoT4eTIqmqsndQqFb1A0oiW3sZGlhMRhOi6pAD4MF1YYW6o/A==} - dev: false - - /robust-sum@1.0.0: - resolution: {integrity: sha512-AvLExwpaqUqD1uwLU6MwzzfRdaI6VEZsyvQ3IAQ0ZJ08v1H+DTyqskrf2ZJyh0BDduFVLN7H04Zmc+qTiahhAw==} - dev: false - /rollup@3.29.4: resolution: {integrity: sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==} engines: {node: '>=14.18.0', npm: '>=8.0.0'} @@ -6300,6 +5995,7 @@ packages: get-intrinsic: 1.2.2 gopd: 1.0.1 has-property-descriptors: 1.0.1 + dev: true /set-function-name@2.0.1: resolution: {integrity: sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==} @@ -6348,6 +6044,7 @@ packages: call-bind: 1.0.5 get-intrinsic: 1.2.2 object-inspect: 1.13.1 + dev: true /siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -6691,10 +6388,6 @@ packages: resolution: {integrity: sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg==} dev: true - /tinycolor2@1.6.0: - resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} - dev: false - /tinypool@0.7.0: resolution: {integrity: sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww==} engines: {node: '>=14.0.0'} @@ -6794,14 +6487,6 @@ packages: typescript: 5.0.2 dev: true - /two-product@1.0.2: - resolution: {integrity: sha512-vOyrqmeYvzjToVM08iU52OFocWT6eB/I5LUWYnxeAPGXAhAxXYU/Yr/R2uY5/5n4bvJQL9AQulIuxpIsMoT8XQ==} - dev: false - - /two-sum@1.0.0: - resolution: {integrity: sha512-phP48e8AawgsNUjEY2WvoIWqdie8PoiDZGxTDv70LDr01uX5wLEQbOgSP7Z/B6+SW5oLtbe8qaYX2fKJs3CGTw==} - dev: false - /type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -6872,13 +6557,6 @@ packages: is-typed-array: 1.1.12 dev: true - /typedarray-pool@1.2.0: - resolution: {integrity: sha512-YTSQbzX43yvtpfRtIDAYygoYtgT+Rpjuxy9iOpczrjpXLgGoyG7aS5USJXV2d3nn8uHTeb9rXDvzS27zUg5KYQ==} - dependencies: - bit-twiddle: 1.0.2 - dup: 1.0.0 - dev: false - /typescript@4.6.4: resolution: {integrity: sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==} engines: {node: '>=4.2.0'} @@ -6979,18 +6657,21 @@ packages: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} dependencies: punycode: 2.3.1 + dev: true /url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} dependencies: querystringify: 2.2.0 requires-port: 1.0.0 + dev: true /url@0.11.3: resolution: {integrity: sha512-6hxOLGfZASQK/cijlZnZJTq8OXAkt/3YGfQX45vvMYXpZoo8NdWZcY73K108Jf759lS1Bv/8wXnHDTSz17dSRw==} dependencies: punycode: 1.4.1 qs: 6.11.2 + dev: true /use-resize-observer@9.1.0(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow==} @@ -7234,10 +6915,6 @@ packages: - terser dev: true - /vkbeautify@0.99.3: - resolution: {integrity: sha512-2ozZEFfmVvQcHWoHLNuiKlUfDKlhh4KGsy54U0UrlLMR1SO+XKAIDqBxtBwHgNrekurlJwE8A9K6L49T78ZQ9Q==} - dev: false - /vlq@0.2.3: resolution: {integrity: sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==} dev: true @@ -7428,11 +7105,6 @@ packages: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} dev: true - /xmlhttprequest@1.8.0: - resolution: {integrity: sha512-58Im/U0mlVBLM38NdZjHyhuMtCqa61469k2YP/AaPbvCoV9aQGUpbJBj1QRm2ytRiVQBD/fsw7L2bJGDVQswBA==} - engines: {node: '>=0.4.0'} - dev: false - /xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} From c0704885b9751fdeed07a208ddcfb8d74f0573a7 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 15:07:51 -0400 Subject: [PATCH 008/139] feat: vendored higlass imports --- src/higlass-vendored/higlass-vendored.js | 7508 ++++++++++++++++++++++ 1 file changed, 7508 insertions(+) create mode 100644 src/higlass-vendored/higlass-vendored.js diff --git a/src/higlass-vendored/higlass-vendored.js b/src/higlass-vendored/higlass-vendored.js new file mode 100644 index 000000000..23eee69f4 --- /dev/null +++ b/src/higlass-vendored/higlass-vendored.js @@ -0,0 +1,7508 @@ +import { scaleLinear, scaleLog, scaleQuantile } from 'd3-scale'; +import { precisionPrefix, formatPrefix, format } from 'd3-format'; +import slugid from 'slugid'; +import { color, rgb } from 'd3-color'; +import * as PIXI from 'pixi.js'; +import { mean, deviation, variance, sum, range, median, ticks, bisector } from 'd3-array'; +import ndarray from 'ndarray'; +import { select } from 'd3-selection'; +import { brushX, brushY } from 'd3-brush'; +import { globalPubSub } from 'pub-sub-es'; + +/** + * Check if a 2D or 1D point is within a rectangle or range + * @param {number} x - The point's X coordinate. + * @param {number} y - The point's Y coordinate. + * @param {number} minX - The rectangle's start X coordinate. + * @param {number} maxX - The rectangle's start X coordinate. + * @param {number} minY - The rectangle's start X coordinate. + * @param {number} maxY - The rectangle's start X coordinate. + * @return {boolean} If `true` the [x,y] point is in the rectangle. + */ +const isWithin = (x, y, minX, maxX, minY, maxY, is1d = false) => + is1d ? (x >= minX && x <= maxX) || (y >= minY && y <= maxY) : x >= minX && x <= maxX && y >= minY && y <= maxY; + +const fakePubSub = { + __fake__: true, + publish: () => {}, + subscribe: () => ({ + event: '', + handler: () => {} + }), + unsubscribe: () => {}, + clear: () => {} +}; + +/** + * @typedef TrackContext + * @property {string} id - The track ID. + * @property {import('pub-sub-es').PubSub & { __fake__?: boolean }} [pubSub] - The pub-sub channel. + * @property {() => import('./types').Theme} [getTheme] - A function that returns the current theme. + */ + +/** + * @template T + * @typedef {T & TrackContext} ExtendedTrackContext + */ + +/** @template Options */ +class Track { + /** + * @param {TrackContext} context + * @param {Options} options + */ + constructor(context, options) { + this.context = context; + + const { id, pubSub, getTheme } = context; + /** @type {import('pub-sub-es').PubSub} */ + this.pubSub = pubSub ?? fakePubSub; + + /** @type {string} */ + this.id = id; + /** @type {import('./types').Scale} */ + this._xScale = scaleLinear(); + /** @type {import('./types').Scale} */ + this._yScale = scaleLinear(); + + // reference scales used for tracks that can translate and scale + // their graphics + // They will draw their graphics on the reference scales and then translate + // and pan them as needed + /** @type {import('./types').Scale} */ + this._refXScale = scaleLinear(); + /** @type {import('./types').Scale} */ + this._refYScale = scaleLinear(); + + /** @type {[number, number]} */ + this.position = [0, 0]; + /** @type {[number, number]} */ + this.dimensions = [1, 1]; + /** @type {Options} */ + this.options = options; + /** @type {Array} */ + this.pubSubs = []; + + /** @type {() => (import('./types').Theme | undefined)} */ + this.getTheme = getTheme ?? (() => undefined); + + this.pubSubs.push(this.pubSub.subscribe('app.mouseMove', this.defaultMouseMoveHandler.bind(this))); + + this.isLeftModified = false; + } + + /** + * Check if a 2d location (x, y) is within the bounds of this track. + * + * @param {number} x - X position to be tested. + * @param {number} y - Y position to be tested. + * @return {boolean} If `true` location is within the track. + */ + isWithin(x, y) { + let xx = x; + let yy = y; + let left = this.position[0]; + let top = this.position[1]; + + if (this.isLeftModified) { + xx = y; + yy = x; + left = this.position[1]; + top = this.position[0]; + } + + return isWithin(xx, yy, left, this.dimensions[0] + left, top, this.dimensions[1] + top); + } + + /** + * Get a property from the track. + * @template {keyof this} T + * @param {T} prop - The property to get. + * @return {() => this[T]} + */ + getProp(prop) { + return () => this[prop]; + } + + getData() {} + + /** + * Capture click events. x and y are relative to the track position + * @template T + * @param {number} x - X position of the click event. + * @param {number} y - Y position of the click event. + * @param {T} evt - The event. + * @return {{ type: 'generic', event: T, payload: null }} + */ + click(x, y, evt) { + return { + type: 'generic', + event: evt, + payload: null + }; + } + + /** There was a click event outside the track * */ + clickOutside() {} + + /** @returns {[number, number]} */ + getDimensions() { + return this.dimensions; + } + + /** @param {[number, number]} newDimensions */ + setDimensions(newDimensions) { + this.dimensions = newDimensions; + + this._xScale.range([0, this.dimensions[0]]); + this._yScale.range([0, this.dimensions[1]]); + } + + /** + * @overload + * @return {import('./types').Scale} + */ + /** + * @overload + * @param {import('./types').Scale} scale + * @return {this} + */ + /** + * Either get or set the reference xScale + * + * @param {import('./types').Scale=} scale + * @return {import('./types').Scale | this} + */ + refXScale(scale) { + if (!scale) return this._refXScale; + this._refXScale = scale; + return this; + } + + /** + * @overload + * @return {import('./types').Scale} + */ + /** + * @overload + * @param {import('./types').Scale} scale + * @return {this} + */ + /** + * Either get or set the reference yScale + * + * @param {import('./types').Scale=} scale + * @return {import('./types').Scale | this} + */ + refYScale(scale) { + if (!scale) return this._refYScale; + this._refYScale = scale; + return this; + } + + /** + * @overload + * @return {import('./types').Scale} + */ + /** + * @overload + * @param {import('./types').Scale} scale + * @return {this} + */ + /** + * Either get or set the xScale + * + * @param {import('./types').Scale=} scale + * @return {import('./types').Scale | this} + */ + xScale(scale) { + if (!scale) return this._xScale; + this._xScale = scale; + return this; + } + + /** + * @overload + * @return {import('./types').Scale} + */ + /** + * @overload + * @param {import('./types').Scale} scale + * @return {this} + */ + /** + * Either get or set the yScale + * + * @param {import('./types').Scale=} scale + * @return {import('./types').Scale | this} + */ + yScale(scale) { + if (!scale) return this._yScale; + this._yScale = scale; + return this; + } + + /** + * @param {import('./types').Scale} newXScale + * @param {import('./types').Scale} newYScale + * @returns {void} + */ + zoomed(newXScale, newYScale) { + this.xScale(newXScale); + this.yScale(newYScale); + } + + /** + * @param {import('./types').Scale} refXScale + * @param {import('./types').Scale} refYScale + * @returns {void} + */ + refScalesChanged(refXScale, refYScale) { + this._refXScale = refXScale; + this._refYScale = refYScale; + } + + /** @returns {void} */ + draw() {} + + /** @returns {[number, number]} */ + getPosition() { + return this.position; + } + + /** + * @param {[number, number]} newPosition + * @returns {void} + */ + setPosition(newPosition) { + this.position = newPosition; + } + + /** + * A blank handler for MouseMove / Zoom events. Should be overriden + * by individual tracks to provide + * + * @param {{}} evt + * @returns {void} + */ + defaultMouseMoveHandler(evt) {} + + /** @returns {void} */ + remove() { + // Clear all pubSub subscriptions + this.pubSubs.forEach(subscription => this.pubSub.unsubscribe(subscription)); + this.pubSubs = []; + } + + /** + * @param {Options} options + * @returns {void} + */ + rerender(options) {} + + /** + * This function is for seeing whether this track should respond + * to events at this mouse position. The difference to `isWithin()` is that it + * can be overwritten if a track is inactive for example. + * + * @param {number} x - X position to be tested. + * @param {number} y - Y position to be tested. + * @returns {boolean} + */ + respondsToPosition(x, y) { + return this.isWithin(x, y); + } + + /** + * @param {number} trackY + * @param {number} kMultiplier + * @returns {void} + */ + zoomedY(trackY, kMultiplier) {} + + /** + * @param {number} dY + * @returns {void} + */ + movedY(dY) {} +} + +// @ts-nocheck + +const GLOBALS = { + PIXI +}; + +/** + * Convert a regular color value (e.g. 'red', '#FF0000', 'rgb(255,0,0)') to a + * hex value which is legible by PIXI + * + * @param {string} colorValue - Color value to convert + * @return {number} Hex value + */ +const colorToHex = colorValue => { + /** @type {import('d3-color').RGBColor} */ + // @ts-expect-error - FIXME: `color` can return many different types + // depending on the string input. We should probably use a different + // the more strict `rgb` function instead? + const c = color(colorValue); + const hex = GLOBALS.PIXI.utils.rgb2hex([c.r / 255.0, c.g / 255.0, c.b / 255.0]); + + return hex; +}; + +/** + * @param {import('../types').TrackConfig} trackConfig + * @return {trackConfig is import('../types').CombinedTrackConfig} + */ + +/** + * @param {unknown} obj + * @returns {obj is {}} + */ +function isObject(obj) { + return obj !== null && typeof obj === 'object'; +} + +/** + * @param {import('../types').TilesetInfo | undefined} info + * @returns {info is import('../types').LegacyTilesetInfo} + */ +function isLegacyTilesetInfo(info) { + return isObject(info) && 'max_width' in info; +} + +/** + * @param {import('../types').TilesetInfo | undefined} info + * @returns {info is import('../types').ResolutionsTilesetInfo} + */ +function isResolutionsTilesetInfo(info) { + return isObject(info) && 'resolutions' in info; +} + +/** + * Format a resolution relative to the highest possible resolution. + * + * The highest possible resolution determines the granularity of the + * formatting (e.g. 20K vs 20000) + * @param {number} resolution The resolution to format (e.g. 30000) + * @param {number} maxResolutionSize The maximum possible resolution (e.g. 1000) + * + * @returns {string} A formatted resolution string (e.g. "30K") + */ +function formatResolutionText(resolution, maxResolutionSize) { + const pp = precisionPrefix(maxResolutionSize, resolution); + const f = formatPrefix(`.${pp}`, resolution); + const formattedResolution = f(resolution); + + return formattedResolution; +} + +/** + * Get a text description of a resolution based on a zoom level + * and a list of resolutions + * + * @param {Array} resolutions: A list of resolutions (e.g. [1000,2000,3000]) + * @param {number} zoomLevel: The current zoom level (e.g. 4) + * + * @returns {string} A formatted string representation of the zoom level (e.g. "30K") + */ +function getResolutionBasedResolutionText(resolutions, zoomLevel) { + const sortedResolutions = resolutions.map(x => +x).sort((a, b) => b - a); + const resolution = sortedResolutions[zoomLevel]; + const maxResolutionSize = sortedResolutions[sortedResolutions.length - 1]; + + return formatResolutionText(resolution, maxResolutionSize); +} + +/** + * Get a text description of the resolution based on the zoom level + * max width of the dataset, the bins per dimension and the maximum zoom. + * + * @param {number} zoomLevel - The current zoomLevel (e.g. 0) + * @param {number} maxWidth - The max width (e.g. 2 ** maxZoom * highestResolution * binsPerDimension) + * @param {number} binsPerDimension - The number of bins per tile dimension (e.g. 256) + * @param {number} maxZoom - The maximum zoom level for this tileset + * + * @returns {string} A formatted string representation of the zoom level (e.g. "30K") + */ +function getWidthBasedResolutionText(zoomLevel, maxWidth, binsPerDimension, maxZoom) { + const resolution = maxWidth / (2 ** zoomLevel * binsPerDimension); + + // we can't display a NaN resolution + if (!Number.isNaN(resolution)) { + // what is the maximum possible resolution? + // this will determine how we format the lower resolutions + const maxResolutionSize = maxWidth / (2 ** maxZoom * binsPerDimension); + + const pp = precisionPrefix(maxResolutionSize, resolution); + const f = formatPrefix(`.${pp}`, resolution); + const formattedResolution = f(resolution); + + return formattedResolution; + } + console.warn('NaN resolution, screen is probably too small.'); + + return ''; +} + +/** + * @typedef PixiTrackOptions + * @property {string} labelPosition - If the label is to be drawn, where should it be drawn? + * @property {string} labelText - What should be drawn in the label. + * If either labelPosition or labelText are false, no label will be drawn. + * @property {number=} trackBorderWidth + * @property {string=} trackBorderColor + * @property {string=} backgroundColor + * @property {string=} labelColor + * @property {string=} lineStrokeColor + * @property {string=} barFillColor + * @property {string=} name + * @property {number=} labelTextOpacity + * @property {string=} labelBackgroundColor + * @property {number=} labelLeftMargin + * @property {number=} labelRightMargin + * @property {number=} labelTopMargin + * @property {number=} labelBottomMargin + * @property {number=} labelBackgroundOpacity + * @property {boolean=} labelShowAssembly + * @property {boolean=} labelShowResolution + * @property {string=} dataTransform + */ + +/** @extends {Track} */ +class PixiTrack extends Track { + /** + * @param {import('./Track').ExtendedTrackContext<{ scene: import('pixi.js').Container}>} context - Includes the PIXI.js scene to draw to. + * @param {PixiTrackOptions} options - The options for this track. + */ + constructor(context, options) { + super(context, options); + const { scene } = context; + + // the PIXI drawing areas + // pMain will have transforms applied to it as users scroll to and fro + /** @type {import('pixi.js').Container} */ + this.scene = scene; + + // this option is used to temporarily prevent drawing so that + // updates can be batched (e.g. zoomed and options changed) + /** @type {boolean} */ + this.delayDrawing = false; + + /** @type {import('pixi.js').Graphics} */ + this.pBase = new GLOBALS.PIXI.Graphics(); + /** @type {import('pixi.js').Graphics} */ + this.pMasked = new GLOBALS.PIXI.Graphics(); + /** @type {import('pixi.js').Graphics} */ + this.pMask = new GLOBALS.PIXI.Graphics(); + /** @type {import('pixi.js').Graphics} */ + this.pMain = new GLOBALS.PIXI.Graphics(); + + // for drawing the track label (often its name) + /** @type {import('pixi.js').Graphics} */ + this.pBorder = new GLOBALS.PIXI.Graphics(); + /** @type {import('pixi.js').Graphics} */ + this.pBackground = new GLOBALS.PIXI.Graphics(); + /** @type {import('pixi.js').Graphics} */ + this.pForeground = new GLOBALS.PIXI.Graphics(); + /** @type {import('pixi.js').Graphics} */ + this.pLabel = new GLOBALS.PIXI.Graphics(); + /** @type {import('pixi.js').Graphics} */ + this.pMobile = new GLOBALS.PIXI.Graphics(); + /** @type {import('pixi.js').Graphics} */ + this.pAxis = new GLOBALS.PIXI.Graphics(); + + // for drawing information on mouseover events + /** @type {import('pixi.js').Graphics} */ + this.pMouseOver = new GLOBALS.PIXI.Graphics(); + + this.scene.addChild(this.pBase); + + this.pBase.addChild(this.pMasked); + + this.pMasked.addChild(this.pBackground); + this.pMasked.addChild(this.pMain); + this.pMasked.addChild(this.pMask); + this.pMasked.addChild(this.pMobile); + this.pMasked.addChild(this.pBorder); + this.pMasked.addChild(this.pLabel); + this.pMasked.addChild(this.pForeground); + this.pMasked.addChild(this.pMouseOver); + this.pBase.addChild(this.pAxis); + + this.pMasked.mask = this.pMask; + + /** @type {string} */ + this.prevOptions = ''; + + // pMobile will be a graphics object that is moved around + // tracks that wish to use it will replace this.pMain with it + + /** @type {PixiTrackOptions} */ + this.options = Object.assign(this.options, options); + + /** @type {string} */ + const labelTextText = this.getName(); + /** @type {string} */ + this.labelTextFontFamily = 'Arial'; + /** @type {number} */ + this.labelTextFontSize = 12; + /** + * Used to avoid label/colormap clashes + * @type {number} + */ + this.labelXOffset = 0; + + /** @type {import('pixi.js').Text} */ + this.labelText = new GLOBALS.PIXI.Text(labelTextText, { + fontSize: `${this.labelTextFontSize}px`, + fontFamily: this.labelTextFontFamily, + fill: 'black' + }); + this.pLabel.addChild(this.labelText); + + /** @type {import('pixi.js').Text} */ + this.errorText = new GLOBALS.PIXI.Text('', { + fontSize: '12px', + fontFamily: 'Arial', + fill: 'red' + }); + this.errorText.anchor.x = 0.5; + this.errorText.anchor.y = 0.5; + this.pLabel.addChild(this.errorText); + /** @type {string} */ + this.errorTextText = ''; + /** @type {boolean} */ + this.flipText = false; + /** @type {import('./types').TilesetInfo | undefined} */ + this.tilesetInfo = undefined; + } + + setLabelText() { + // will be drawn in draw() anyway + } + + /** @param {[number, number]} newPosition */ + setPosition(newPosition) { + this.position = newPosition; + + this.drawBorder(); + this.drawLabel(); + this.drawBackground(); + this.setMask(this.position, this.dimensions); + this.setForeground(); + } + + /** @param {[number, number]} newDimensions */ + setDimensions(newDimensions) { + super.setDimensions(newDimensions); + + this.drawBorder(); + this.drawLabel(); + this.drawBackground(); + this.setMask(this.position, this.dimensions); + this.setForeground(); + } + + /** + * @param {[number, number]} position + * @param {[number, number]} dimensions + */ + setMask(position, dimensions) { + this.pMask.clear(); + this.pMask.beginFill(); + + this.pMask.drawRect(position[0], position[1], dimensions[0], dimensions[1]); + this.pMask.endFill(); + } + + setForeground() { + this.pForeground.position.y = this.position[1]; + this.pForeground.position.x = this.position[0]; + } + + /** + * We're going to destroy this object, so we need to detach its + * graphics from the scene + */ + remove() { + // the entire PIXI stage was probably removed + this.pBase.clear(); + this.scene.removeChild(this.pBase); + } + + /** + * Draw a border around each track. + */ + drawBorder() { + const graphics = this.pBorder; + + graphics.clear(); + + // don't display the track label + if (!this.options || !this.options.trackBorderWidth) return; + + const stroke = colorToHex(this.options.trackBorderColor ? this.options.trackBorderColor : 'white'); + + graphics.lineStyle(this.options.trackBorderWidth, stroke); + + graphics.drawRect(this.position[0], this.position[1], this.dimensions[0], this.dimensions[1]); + } + + drawError() { + this.errorText.x = this.position[0] + this.dimensions[0] / 2; + this.errorText.y = this.position[1] + this.dimensions[1] / 2; + + this.errorText.text = this.errorTextText; + + if (this.errorTextText && this.errorTextText.length) { + // draw a red border around the track to bring attention to its + // error + const graphics = this.pBorder; + graphics.clear(); + graphics.lineStyle(1, colorToHex('red')); + + graphics.drawRect(this.position[0], this.position[1], this.dimensions[0], this.dimensions[1]); + } + } + + drawBackground() { + const graphics = this.pBackground; + + graphics.clear(); + + if (!this.options || !this.options.backgroundColor) { + return; + } + + let opacity = 1; + let color = this.options.backgroundColor; + + if (this.options.backgroundColor === 'transparent') { + opacity = 0; + color = 'white'; + } + + const hexColor = colorToHex(color); + graphics.beginFill(hexColor, opacity); + + graphics.drawRect(this.position[0], this.position[1], this.dimensions[0], this.dimensions[1]); + } + + /** + * Determine the label color based on the number of options. + * + * @return {string} The color to use for the label. + */ + getLabelColor() { + if (this.options.labelColor && this.options.labelColor !== '[glyph-color]') { + return this.options.labelColor; + } + + return this.options.lineStrokeColor || this.options.barFillColor || 'black'; + } + + getName() { + return this.options.name ? this.options.name : (this.tilesetInfo && this.tilesetInfo.name) || ''; + } + + drawLabel() { + if (!this.labelText) return; + + const graphics = this.pLabel; + + graphics.clear(); + + // TODO(Trevor): I don't think this can ever be true. Options are always defined, + // and options.labelPosition can't be defined if this.options is undefined. + if (!this.options || !this.options.labelPosition || this.options.labelPosition === 'hidden') { + // don't display the track label + this.labelText.alpha = 0; + return; + } + + const { labelBackgroundColor = 'white', labelBackgroundOpacity = 0.5 } = this.options; + graphics.beginFill(colorToHex(labelBackgroundColor), +labelBackgroundOpacity); + + const fontColor = colorToHex(this.getLabelColor()); + const labelBackgroundMargin = 2; + + // we can't draw a label if there's no space + if (this.dimensions[0] < 0) { + return; + } + + let labelTextText = + this.options.labelShowAssembly && this.tilesetInfo && this.tilesetInfo.coordSystem + ? `${this.tilesetInfo.coordSystem} | ` + : ''; + + labelTextText += this.getName(); + + if ( + this.options.labelShowResolution && + isLegacyTilesetInfo(this.tilesetInfo) && + this.tilesetInfo.bins_per_dimension + ) { + const formattedResolution = getWidthBasedResolutionText( + this.calculateZoomLevel(), + this.tilesetInfo.max_width, + this.tilesetInfo.bins_per_dimension, + this.tilesetInfo.max_zoom + ); + + labelTextText += `\n[Current data resolution: ${formattedResolution}]`; + } else if (this.options.labelShowResolution && isResolutionsTilesetInfo(this.tilesetInfo)) { + const formattedResolution = getResolutionBasedResolutionText( + this.tilesetInfo.resolutions, + this.calculateZoomLevel() + ); + + labelTextText += `\n[Current data resolution: ${formattedResolution}]`; + } + + if (this.options && this.options.dataTransform) { + let chosenTransform = null; + + if (this.tilesetInfo && this.tilesetInfo.transforms) { + for (const transform of this.tilesetInfo.transforms) { + if (transform.value === this.options.dataTransform) { + chosenTransform = transform; + } + } + } + + if (chosenTransform) { + labelTextText += `\n[Transform: ${chosenTransform.name}]`; + } else if (this.options.dataTransform === 'None') { + labelTextText += '\n[Transform: None ]'; + } else { + labelTextText += '\n[Transform: Default ]'; + } + } + + this.labelText.text = labelTextText; + this.labelText.style = { + fontSize: `${this.labelTextFontSize}px`, + fontFamily: this.labelTextFontFamily, + fill: fontColor + }; + this.labelText.alpha = typeof this.options.labelTextOpacity !== 'undefined' ? this.options.labelTextOpacity : 1; + + this.labelText.visible = true; + + if (this.flipText) { + this.labelText.scale.x = -1; + } + + const { labelLeftMargin = 0, labelRightMargin = 0, labelTopMargin = 0, labelBottomMargin = 0 } = this.options; + + if (this.options.labelPosition === 'topLeft') { + this.labelText.x = this.position[0] + labelLeftMargin + this.labelXOffset; + this.labelText.y = this.position[1] + labelTopMargin; + + this.labelText.anchor.x = 0.5; + this.labelText.anchor.y = 0; + + this.labelText.x += this.labelText.width / 2; + + graphics.drawRect( + this.position[0] + labelLeftMargin + this.labelXOffset, + this.position[1] + labelTopMargin, + this.labelText.width + labelBackgroundMargin, + this.labelText.height + labelBackgroundMargin + ); + } else if ( + (this.options.labelPosition === 'bottomLeft' && !this.flipText) || + (this.options.labelPosition === 'topRight' && this.flipText) + ) { + this.labelText.x = this.position[0] + (labelLeftMargin || labelTopMargin); + this.labelText.y = this.position[1] + this.dimensions[1] - (labelBottomMargin || labelRightMargin); + this.labelText.anchor.x = 0.5; + this.labelText.anchor.y = 1; + + this.labelText.x += this.labelText.width / 2 + this.labelXOffset; + graphics.drawRect( + this.position[0] + (labelLeftMargin || labelTopMargin) + this.labelXOffset, + this.position[1] + + this.dimensions[1] - + this.labelText.height - + labelBackgroundMargin - + (labelBottomMargin || labelRightMargin), + this.labelText.width + labelBackgroundMargin, + this.labelText.height + labelBackgroundMargin + ); + } else if ( + (this.options.labelPosition === 'topRight' && !this.flipText) || + (this.options.labelPosition === 'bottomLeft' && this.flipText) + ) { + this.labelText.x = this.position[0] + this.dimensions[0] - (labelRightMargin || labelBottomMargin); + this.labelText.y = this.position[1] + (labelTopMargin || labelLeftMargin); + this.labelText.anchor.x = 0.5; + this.labelText.anchor.y = 0; + + this.labelText.x -= this.labelText.width / 2 + this.labelXOffset; + + graphics.drawRect( + this.position[0] + + this.dimensions[0] - + this.labelText.width - + labelBackgroundMargin - + (labelRightMargin || labelBottomMargin) - + this.labelXOffset, + this.position[1] + (labelTopMargin || labelLeftMargin), + this.labelText.width + labelBackgroundMargin, + this.labelText.height + labelBackgroundMargin + ); + } else if (this.options.labelPosition === 'bottomRight') { + this.labelText.x = this.position[0] + this.dimensions[0] - labelRightMargin; + this.labelText.y = this.position[1] + this.dimensions[1] - labelBottomMargin; + this.labelText.anchor.x = 0.5; + this.labelText.anchor.y = 1; + + // we set the anchor to 0.5 so that we can flip the text if the track + // is rotated but that means we have to adjust its position + this.labelText.x -= this.labelText.width / 2 + this.labelXOffset; + + graphics.drawRect( + this.position[0] + + this.dimensions[0] - + this.labelText.width - + labelBackgroundMargin - + labelRightMargin - + this.labelXOffset, + this.position[1] + + this.dimensions[1] - + this.labelText.height - + labelBackgroundMargin - + labelBottomMargin, + this.labelText.width + labelBackgroundMargin, + this.labelText.height + labelBackgroundMargin + ); + } else if ( + (this.options.labelPosition === 'outerLeft' && !this.flipText) || + (this.options.labelPosition === 'outerTop' && this.flipText) + ) { + this.labelText.x = this.position[0]; + this.labelText.y = this.position[1] + this.dimensions[1] / 2; + + this.labelText.anchor.x = 0.5; + this.labelText.anchor.y = 0.5; + + this.labelText.x -= this.labelText.width / 2 + 3; + } else if ( + (this.options.labelPosition === 'outerTop' && !this.flipText) || + (this.options.labelPosition === 'outerLeft' && this.flipText) + ) { + this.labelText.x = this.position[0] + this.dimensions[0] / 2; + this.labelText.y = this.position[1]; + + this.labelText.anchor.x = 0.5; + this.labelText.anchor.y = 0.5; + + this.labelText.y -= this.labelText.height / 2 + 3; + } else if ( + (this.options.labelPosition === 'outerBottom' && !this.flipText) || + (this.options.labelPosition === 'outerRight' && this.flipText) + ) { + this.labelText.x = this.position[0] + this.dimensions[0] / 2; + this.labelText.y = this.position[1] + this.dimensions[1]; + + this.labelText.anchor.x = 0.5; + this.labelText.anchor.y = 0.5; + + this.labelText.y += this.labelText.height / 2 + 3; + } else if ( + (this.options.labelPosition === 'outerRight' && !this.flipText) || + (this.options.labelPosition === 'outerBottom' && this.flipText) + ) { + this.labelText.x = this.position[0] + this.dimensions[0]; + this.labelText.y = this.position[1] + this.dimensions[1] / 2; + + this.labelText.anchor.x = 0.5; + this.labelText.anchor.y = 0.5; + + this.labelText.x += this.labelText.width / 2 + 3; + } else { + this.labelText.visible = false; + } + + if ( + this.options.labelPosition === 'outerLeft' || + this.options.labelPosition === 'outerRight' || + this.options.labelPosition === 'outerTop' || + this.options.labelPosition === 'outerBottom' + ) { + this.pLabel.setParent(this.pBase); + } else { + this.pLabel.setParent(this.pMasked); + } + } + + /** @param {PixiTrackOptions} options */ + rerender(options) { + this.options = options; + + this.draw(); + this.drawBackground(); + this.drawLabel(); + this.drawError(); + this.drawBorder(); + } + + /** + * Draw all the data associated with this track + */ + draw() { + // this rectangle is cleared by functions that override this draw method + // this.drawBorder(); + // this.drawLabel(); + this.drawError(); + } + + /** + * Export an SVG representation of this track + * + * @returns {[HTMLElement, HTMLElement]} The two returned DOM nodes are both SVG + * elements [base, track]. Base is a parent which contains track as a + * child. Track is clipped with a clipping rectangle contained in base. + * + */ + exportSVG() { + const gBase = document.createElement('g'); + const rectBackground = document.createElement('rect'); + + rectBackground.setAttribute('x', `${this.position[0]}`); + rectBackground.setAttribute('y', `${this.position[1]}`); + rectBackground.setAttribute('width', `${this.dimensions[0]}`); + rectBackground.setAttribute('height', `${this.dimensions[1]}`); + + if (this.options && this.options.backgroundColor) { + rectBackground.setAttribute('fill', this.options.backgroundColor); + } else { + rectBackground.setAttribute('fill-opacity', '0'); + } + + const gClipped = document.createElement('g'); + gClipped.setAttribute('class', 'g-clipped'); + gBase.appendChild(gClipped); + gClipped.appendChild(rectBackground); + + const gTrack = document.createElement('g'); + gClipped.setAttribute('class', 'g-track'); + gClipped.appendChild(gTrack); + + const gLabels = document.createElement('g'); + gClipped.setAttribute('class', 'g-labels'); + gClipped.appendChild(gLabels); // labels should always appear on top of the track + + // define the clipping area as a polygon defined by the track's + // dimensions on the canvas + const clipPath = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath'); + gBase.appendChild(clipPath); + + const clipPolygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); + clipPath.appendChild(clipPolygon); + + clipPolygon.setAttribute( + 'points', + `${this.position[0]},${this.position[1]} ` + + `${this.position[0] + this.dimensions[0]},${this.position[1]} ` + + `${this.position[0] + this.dimensions[0]},${this.position[1] + this.dimensions[1]} ` + + `${this.position[0]},${this.position[1] + this.dimensions[1]} ` + ); + + // the clipping area needs to be a clipPath element + const clipPathId = slugid.nice(); + clipPath.setAttribute('id', clipPathId); + + gClipped.setAttribute('style', `clip-path:url(#${clipPathId});`); + + const lineParts = this.labelText.text.split('\n'); + let ddy = 0; + + // SVG text alignment is wonky, just adjust the dy values of the tspans + // instead + + const paddingBottom = 3; + const labelTextHeight = (this.labelTextFontSize + 2) * lineParts.length + paddingBottom; + + if (this.labelText.anchor.y === 0.5) { + ddy = labelTextHeight / 2; + } else if (this.labelText.anchor.y === 1) { + ddy = -labelTextHeight; + } + + for (let i = 0; i < lineParts.length; i++) { + const text = document.createElement('text'); + + text.setAttribute('font-family', this.labelTextFontFamily); + text.setAttribute('font-size', `${this.labelTextFontSize}px`); + + // break up newlines into separate tspan elements because SVG text + // doesn't support line breaks: + // http://stackoverflow.com/a/16701952/899470 + + text.innerText = lineParts[i]; + if (this.options.labelPosition === 'topLeft' || this.options.labelPosition === 'topRight') { + const dy = ddy + (i + 1) * (this.labelTextFontSize + 2); + text.setAttribute('dy', String(dy)); + } else if (this.options.labelPosition === 'bottomLeft' || this.options.labelPosition === 'bottomRight') { + text.setAttribute('dy', String(ddy + i * (this.labelTextFontSize + 2))); + } + + text.setAttribute('fill', this.options.labelColor ?? ''); + + if (this.labelText.anchor.x === 0.5) { + text.setAttribute('text-anchor', 'middle'); + } else if (this.labelText.anchor.x === 1) { + text.setAttribute('text-anchor', 'end'); + } + + gLabels.appendChild(text); + } + + gLabels.setAttribute( + 'transform', + `translate(${this.labelText.x},${this.labelText.y})scale(${this.labelText.scale.x},1)` + ); + + // return the whole SVG and where the specific track should draw its + // contents + return [gBase, gTrack]; + } + + /** + * @returns {number} + */ + calculateZoomLevel() { + throw new Error('Must be implemented by subclass'); + } +} + +/** + * Trim trailing slash of an URL. + * @param {string} url - URL to be trimmed. + * @return {string} Trimmed URL. + */ +const trimTrailingSlash = url => (url || '').replace(/\/$/, ''); + +/** + * Return an array of values that are present in this dictionary + * + * @template {object} T + * @param {T} dictionary + * @returns {Array} + */ +function dictValues(dictionary) { + /** @type {Array} */ + const values = []; + + for (const key in dictionary) { + if (dictionary.hasOwnProperty(key)) { + values.push(dictionary[key]); + } + } + + return values; +} + +const epsilon$1 = 0.0000001; + +/** + * Calculate the minimum non-zero value in the data + * @param {ArrayLike} data - An array of values + * @returns {number} The minimum non-zero value in the array + */ +function minNonZero(data) { + /** + * Calculate the minimum non-zero value in the data + * + * Parameters + * ---------- + * data: Float32Array + * An array of values + * + * Returns + * ------- + * minNonZero: float + * The minimum non-zero value in the array + */ + let minNonZeroNum = Number.MAX_SAFE_INTEGER; + + for (let i = 0; i < data.length; i++) { + const x = data[i]; + + if (x < epsilon$1 && x > -epsilon$1) { + continue; + } + + if (x < minNonZeroNum) { + minNonZeroNum = x; + } + } + + return minNonZeroNum; +} + +const epsilon = 0.0000001; + +/** + * Calculate the maximum non-zero value in the data + * @param {ArrayLike} data - An array of values + * @returns {number} The maximum non-zero value in the array + */ +function maxNonZero(data) { + /** + * Calculate the minimum non-zero value in the data + * + * Parameters + * ---------- + * data: Float32Array + * An array of values + * + * Returns + * ------- + * minNonZero: float + * The minimum non-zero value in the array + */ + let maxNonZeroNum = Number.MIN_SAFE_INTEGER; + + for (let i = 0; i < data.length; i++) { + const x = data[i]; + + if (x < epsilon && x > -epsilon) { + continue; + } + + if (x > maxNonZeroNum) { + maxNonZeroNum = x; + } + } + + return maxNonZeroNum; +} + +// @ts-nocheck +// Number of subset (in one direction) that is used to precompute extrema +// in the case of continuous scaling +const NUM_PRECOMP_SUBSETS_PER_1D_TTILE = 8; + +// Number of subset (in one direction) that is used to precompute extrema +// in the case of continuous scaling +const NUM_PRECOMP_SUBSETS_PER_2D_TTILE = 8; + +/** + * @template {ArrayLike} [T=ArrayLike] + */ +class DenseDataExtrema1D { + /** + * This module efficiently computes extrema of arbitrary subsets of a given data array. + * The array is subdivided into 'numSubsets' subsets where extrema are precomputed. + * These values are used to compute extrema given arbitrary start and end indices via + * the getMinNonZeroInSubset and getMaxNonZeroInSubset methods. + * @param {T} data + */ + constructor(data) { + /** @type {number} */ + this.epsilon = 1e-6; + /** @type {T} */ + this.data = data; + + /** @type {number} */ + this.tileSize = this.data.length; // might not be a power of 2 + /** @type {number} */ + this.paddedTileSize = 2 ** Math.ceil(Math.log2(this.tileSize)); + + // This controls how many subsets are created and precomputed. + // Setting numSubsets to 1, is equivalent to no precomputation in + // most cases + /** @type {number} */ + this.numSubsets = Math.min(NUM_PRECOMP_SUBSETS_PER_1D_TTILE, this.paddedTileSize); + /** @type {number} */ + this.subsetSize = this.paddedTileSize / this.numSubsets; + + this.subsetMinimums = this.computeSubsetNonZeroMinimums(); + this.subsetMaximums = this.computeSubsetNonZeroMaximums(); + this.minNonZeroInTile = this.getMinNonZeroInTile(); + this.maxNonZeroInTile = this.getMaxNonZeroInTile(); + } + + /** + * Computes the non-zero minimum in a subset using precomputed values, + * if possible. data[end] is not considered. + * + * @param {[start: number, end: number]} indexBounds + * @return {number} non-zero minium of the subset + */ + getMinNonZeroInSubset(indexBounds) { + const start = indexBounds[0]; + const end = indexBounds[1]; + let curMin = Number.MAX_SAFE_INTEGER; + + if (start === 0 && end === this.tileSize) { + return this.minNonZeroInTile; + } + + const firstSubsetIndex = Math.ceil(start / this.subsetSize); + const lastSubsetIndex = Math.floor((end - 1) / this.subsetSize); + + if (firstSubsetIndex >= lastSubsetIndex) { + // No precomputation was found. + return this.minNonZero(this.data, start, end); + } + + // Compute from original data if the beginning is not covered by precomputations + if (start < firstSubsetIndex * this.subsetSize) { + curMin = Math.min(curMin, this.minNonZero(this.data, start, firstSubsetIndex * this.subsetSize)); + } + + // Use the precomputed values + curMin = Math.min(curMin, this.minNonZero(this.subsetMinimums, firstSubsetIndex, lastSubsetIndex)); + + // Compute from original data if the end is not covered by precomputations + if (end > lastSubsetIndex * this.subsetSize) { + curMin = Math.min(curMin, this.minNonZero(this.data, lastSubsetIndex * this.subsetSize, end)); + } + + return curMin; + } + + /** + * Computes the non-zero maximum in a subset using precomputed values, if possible + * + * @param {[start: number, end: number]} indexBounds + * @return {number} non-zero maxium of the subset + */ + getMaxNonZeroInSubset(indexBounds) { + const start = indexBounds[0]; + const end = indexBounds[1]; + let curMax = Number.MIN_SAFE_INTEGER; + + if (start === 0 && end === this.tileSize) { + return this.maxNonZeroInTile; + } + + const firstSubsetIndex = Math.ceil(start / this.subsetSize); + const lastSubsetIndex = Math.floor((end - 1) / this.subsetSize); + + if (firstSubsetIndex >= lastSubsetIndex) { + // No precomputation was found. + return this.maxNonZero(this.data, start, end); + } + + // Compute from original data if the beginning is not covered by precomputations + if (start < firstSubsetIndex * this.subsetSize) { + curMax = Math.max(curMax, this.maxNonZero(this.data, start, firstSubsetIndex * this.subsetSize)); + } + + // Use the precomputed values + curMax = Math.max(curMax, this.maxNonZero(this.subsetMaximums, firstSubsetIndex, lastSubsetIndex)); + + // Compute from original data if the end is not covered by precomputations + if (end > lastSubsetIndex * this.subsetSize) { + curMax = Math.max(curMax, this.maxNonZero(this.data, lastSubsetIndex * this.subsetSize, end)); + } + + return curMax; + } + + /** + * Precomputes non-zero minimums of subsets of the given data vector + * + * @returns {Array} - Minimums of the regularly subdivided data vector + */ + computeSubsetNonZeroMinimums() { + /** @type {Array} */ + const minimums = []; + + for (let i = 0; i < this.numSubsets; i++) { + let curMin = Number.MAX_SAFE_INTEGER; + + for (let j = 0; j < this.subsetSize; j++) { + const x = this.data[i * this.subsetSize + j]; + // if the tilesize is not a power of 2 we might access + // a value that is not there + if (x === undefined) { + continue; + } + + if (x < this.epsilon && x > -this.epsilon) { + continue; + } + if (x < curMin) { + curMin = x; + } + } + minimums.push(curMin); + } + return minimums; + } + + /** + * Precomputes non-zero maximums of subsets of the given data vector + * @return {Array} Maximums of the regularly subdivided data vector + */ + computeSubsetNonZeroMaximums() { + /** @type {Array} */ + const maximums = []; + + for (let i = 0; i < this.numSubsets; i++) { + let curMax = Number.MIN_SAFE_INTEGER; + + for (let j = 0; j < this.subsetSize; j++) { + const x = this.data[i * this.subsetSize + j]; + // if the tilesize is not a power of 2 we might access + // a value that is not there + if (x === undefined) { + continue; + } + + if (x < this.epsilon && x > -this.epsilon) { + continue; + } + if (x > curMax) { + curMax = x; + } + } + maximums.push(curMax); + } + return maximums; + } + + /** + * Computes the non-zero minimum in the entire data array using precomputed values + * + * @return {number} Non-zeros maximum of the data + */ + getMinNonZeroInTile() { + return Math.min(...this.subsetMinimums); + } + + /** + * Computes the non-zero maximum in the entire data array using precomputed values + * + * @return {number} Non-zeros maximum of the data + */ + getMaxNonZeroInTile() { + return Math.max(...this.subsetMaximums); + } + + /** + * Calculate the minimum non-zero value in the data from start + * to end. No precomputations are used to compute the min. + * + * @param {ArrayLike} data + * @param {number} start + * @param {number} end + * @return {number} non-zero min in subset + */ + minNonZero(data, start, end) { + let minNonZeroNum = Number.MAX_SAFE_INTEGER; + + for (let i = start; i < end; i++) { + const x = data[i]; + + if (x < this.epsilon && x > -this.epsilon) { + continue; + } + + if (x < minNonZeroNum) { + minNonZeroNum = x; + } + } + + return minNonZeroNum; + } + + /** + * Calculate the maximum non-zero value in the data from start + * to end. No precomputations are used to compute the max. + * + * @param {ArrayLike} data + * @param {number} start + * @param {number} end + * @return {number} non-zero max in subset + */ + maxNonZero(data, start, end) { + let maxNonZeroNum = Number.MIN_SAFE_INTEGER; + + for (let i = start; i < end; i++) { + const x = data[i]; + + if (x < this.epsilon && x > -this.epsilon) { + continue; + } + + if (x > maxNonZeroNum) { + maxNonZeroNum = x; + } + } + + return maxNonZeroNum; + } +} + +/** + * @typedef View2D + * @property {(i: number, j: number) => number} get + * @property {(i: number, j: number, v: number) => void} set + */ + +class DenseDataExtrema2D { + /** + * This module efficiently computes extrema of subsets of a given data matrix. + * The matrix is subdivided into 'numSubsets' subsets where extrema are precomputed. + * These values are used to efficiently approximate extrema given arbitrary subsets. + * Larger values of 'numSubsets' lead to more accurate approximations (more expensive). + * + * @param {ArrayLike} data array of quadratic length + */ + constructor(data) { + /** @type {number} */ + this.epsilon = 1e-6; + /** @type {number} */ + this.tileSize = Math.sqrt(data.length); + + if (!Number.isSafeInteger(this.tileSize)) { + console.error('The DenseDataExtrema2D module only works for data of quadratic length.'); + } + + // if this.numSubsets == this.tilesize the extrema are computed exactly (expensive). + /** @type {number} */ + this.numSubsets = Math.min(NUM_PRECOMP_SUBSETS_PER_2D_TTILE, this.tileSize); + /** @type {number} */ + this.subsetSize = this.tileSize / this.numSubsets; + + // Convert data to 2d array + /** @type {View2D} */ + const dataMatrix = ndarray(Array.from(data), [this.tileSize, this.tileSize]); + + /** @type {View2D} */ + this.subsetMinimums = this.computeSubsetNonZeroMinimums(dataMatrix); + /** @type {View2D} */ + this.subsetMaximums = this.computeSubsetNonZeroMaximums(dataMatrix); + /** @type {number} */ + this.minNonZeroInTile = this.getMinNonZeroInTile(); + /** @type {number} */ + this.maxNonZeroInTile = this.getMaxNonZeroInTile(); + } + + /** + * Computes an approximation of the non-zero minimum in a subset + * + * @param {[startX: number, startY: number, endX: number, endY: number]} indexBounds + * @return {number} Non-zero minium of the subset + */ + getMinNonZeroInSubset(indexBounds) { + const startX = indexBounds[0]; + const startY = indexBounds[1]; + const endX = indexBounds[2]; + const endY = indexBounds[3]; + + // transform indices to the corresponding entries in the + // precomputed minimum matrix + const rowOffsetStart = Math.floor(startY / this.subsetSize); + const colOffsetStart = Math.floor(startX / this.subsetSize); + const height = Math.ceil((endY + 1) / this.subsetSize) - rowOffsetStart; + const width = Math.ceil((endX + 1) / this.subsetSize) - colOffsetStart; + + const min = this.getMinNonZeroInNdarraySubset( + this.subsetMinimums, + rowOffsetStart, + colOffsetStart, + width, + height + ); + + return min; + } + + /** + * Computes an approximation of the non-zero maximum in a subset + * + * @param {[startX: number, startY: number, endX: number, endY: number]} indexBounds + * @return {number} Non-zero maxium of the subset + */ + getMaxNonZeroInSubset(indexBounds) { + const startX = indexBounds[0]; + const startY = indexBounds[1]; + const endX = indexBounds[2]; + const endY = indexBounds[3]; + + // transform indices to the corresponding entries in the + // precomputed maximum matrix + const rowOffsetStart = Math.floor(startY / this.subsetSize); + const colOffsetStart = Math.floor(startX / this.subsetSize); + const height = Math.ceil((endY + 1) / this.subsetSize) - rowOffsetStart; + const width = Math.ceil((endX + 1) / this.subsetSize) - colOffsetStart; + + const max = this.getMaxNonZeroInNdarraySubset( + this.subsetMaximums, + rowOffsetStart, + colOffsetStart, + width, + height + ); + + return max; + } + + /** + * Precomputes non-zero minimums of subsets of a given matrix + * @param {View2D} dataMatrix + * @return {View2D} Matrix containing minimums of the dataMatrix after subdivision using a regular grid + */ + computeSubsetNonZeroMinimums(dataMatrix) { + const minimums = ndarray(new Array(this.numSubsets ** 2), [this.numSubsets, this.numSubsets]); + + for (let i = 0; i < this.numSubsets; i++) { + for (let j = 0; j < this.numSubsets; j++) { + const curMin = this.getMinNonZeroInNdarraySubset( + dataMatrix, + i * this.subsetSize, + j * this.subsetSize, + this.subsetSize, + this.subsetSize + ); + minimums.set(i, j, curMin); + } + } + return minimums; + } + + /** + * Precomputes non-zero maximums of subsets of a given matrix + * + * @param {View2D} dataMatrix + * @return {View2D} Matrix containing maximums of the dataMatrix after subdivision using a regular grid + */ + computeSubsetNonZeroMaximums(dataMatrix) { + const maximums = ndarray(new Array(this.numSubsets ** 2), [this.numSubsets, this.numSubsets]); + + for (let i = 0; i < this.numSubsets; i++) { + for (let j = 0; j < this.numSubsets; j++) { + const curMax = this.getMaxNonZeroInNdarraySubset( + dataMatrix, + i * this.subsetSize, + j * this.subsetSize, + this.subsetSize, + this.subsetSize + ); + maximums.set(i, j, curMax); + } + } + return maximums; + } + + /** + * Computes the non-zero minimum of a subset of a matrix (ndarray) + * @param {View2D} arr + * @param {number} rowOffset - Starting row of the subset + * @param {number} colOffset - Starting column of the subset + * @param {number} width - Width (num columns) of the subset + * @param {number} height - Height (num rows) of the subset + * @return {number} Non-zeros - minimum of the subset + */ + getMinNonZeroInNdarraySubset(arr, rowOffset, colOffset, width, height) { + let curMin = Number.MAX_SAFE_INTEGER; + + for (let k = 0; k < width; k++) { + for (let l = 0; l < height; l++) { + const x = arr.get(rowOffset + l, colOffset + k); + if (x < this.epsilon && x > -this.epsilon) { + continue; + } + if (x < curMin) { + curMin = x; + } + } + } + + return curMin; + } + + /** + * Computes the non-zero maximum of a subset of a matrix (ndarray) + * @param {View2D} arr + * @param {number} rowOffset - Starting row of the subset + * @param {number} colOffset - Starting column of the subset + * @param {number} width - Width (num columns) of the subset + * @param {number} height - Height (num rows) of the subset + * @return {number} Non-zeros maximum of the subset + */ + getMaxNonZeroInNdarraySubset(arr, rowOffset, colOffset, width, height) { + let curMax = Number.MIN_SAFE_INTEGER; + + for (let k = 0; k < width; k++) { + for (let l = 0; l < height; l++) { + const x = arr.get(rowOffset + l, colOffset + k); + if (x < this.epsilon && x > -this.epsilon) { + continue; + } + if (x > curMax) { + curMax = x; + } + } + } + + return curMax; + } + + mirrorPrecomputedExtrema() { + for (let row = 1; row < this.numSubsets; row++) { + for (let col = 0; col < row; col++) { + this.subsetMinimums.set(col, row, this.subsetMinimums.get(row, col)); + this.subsetMaximums.set(col, row, this.subsetMaximums.get(row, col)); + } + } + } + + /** + * Computes the non-zero minimum in the entire data array using precomputed values + * + * @return {number} Non-zeros minimum of the data + */ + getMinNonZeroInTile() { + return this.getMinNonZeroInNdarraySubset(this.subsetMinimums, 0, 0, this.numSubsets, this.numSubsets); + } + + /** + * Computes the non-zero maximum in the entire data array using precomputed values + * + * @return {number} Non-zeros maximum of the data + */ + getMaxNonZeroInTile() { + return this.getMaxNonZeroInNdarraySubset(this.subsetMaximums, 0, 0, this.numSubsets, this.numSubsets); + } +} + +/** @typedef {(values: number[]) => number | undefined} Aggregation */ + +/** + * Get an aggregation function from a function name. + * @param {'mean' | 'sum' | 'variance' | 'deviation'} name - The type of aggregation. + * If an unknown string is passed, the mean function will be used (and a warning will be logged). + * @returns {Aggregation} The function of interest as determined by the string, + */ +const getAggregationFunction = name => { + /** @type {Aggregation} */ + let aggFunc; + const lowerCaseName = name ? name.toLowerCase() : name; + switch (lowerCaseName) { + case 'mean': + aggFunc = mean; + break; + case 'sum': + aggFunc = sum; + break; + case 'variance': + aggFunc = variance; + break; + case 'deviation': + aggFunc = deviation; + break; + default: + aggFunc = mean; + console.warn('Encountered an unsupported selectedRowsAggregationMode option.'); + } + return aggFunc; +}; + +/** + * Compute the size associated with a potentially 2d array of selected item indices. + * For example, this can be used to compute the total height of a `horizontal-multivec` track + * where rows are selected individually or in aggregation groups. + * + * @param {Array} selectedItems The 1d or 2d array of items or groups of items. + * @param {boolean} withRelativeSize Does a group of indices count as 1 unit size + * or is its size relative to the group size? + * @returns {number} The computed size value. + * Between 0 and the total number of items in the (flattened) input array. + */ +const selectedItemsToSize = (selectedItems, withRelativeSize) => + selectedItems.reduce((/** @type {number} */ a, h) => a + (Array.isArray(h) && withRelativeSize ? h.length : 1), 0); + +/** + * This function helps to fill in pixData by calling setPixData() + * when selectedRowsOptions have been passed to workerSetPix(). + * + * @param {ArrayLike} data - The (2D) tile data array. + * @param {[numRows: number, numCols: number]} shape - Array shape (number of rows and columns). + * @param {(pixDataIndex: number, dataPoint: number) => void} setPixData - The setPixData function created by workerSetPix(). + * @param {number[] | number[][]} selectedRows - Row indices, for ordering and filtering rows. Used by the HorizontalMultivecTrack. + * @param {'mean' | 'sum' | 'variance' | 'deviation'} selectedRowsAggregationMode - The aggregation function to use ("mean", "sum", etc). + * @param {boolean} selectedRowsAggregationWithRelativeHeight - Whether the height of row groups should be relative to the size of the group. + * @param {'client' | 'server'} selectedRowsAggregationMethod - Where will the aggregation be performed? Possible values: "client", "server". + */ +function setPixDataForSelectedRows( + data, + shape, + setPixData, + selectedRows, + selectedRowsAggregationMode, + selectedRowsAggregationWithRelativeHeight, + selectedRowsAggregationMethod +) { + // We need to set the pixels in the order specified by the `selectedRows` parameter. + /** @type {((data: number[]) => number | undefined) | undefined} */ + let aggFunc; + /** @type {((columnIndex: number, rowIndices: number[]) => number) | undefined} */ + let aggFromDataFunc; + if (selectedRowsAggregationMode) { + const agg = getAggregationFunction(selectedRowsAggregationMode); + aggFunc = agg; + aggFromDataFunc = (colI, rowIs) => agg(rowIs.map(rowI => data[rowI * shape[1] + colI])) ?? 0; + } + /** @type {number} */ + let d; + /** @type {number} */ + let pixRowI; + /** @type {number} */ + let colI; + /** @type {number} */ + let selectedRowI; + /** @type {number} */ + let selectedRowGroupItemI; + /** @type {number | number[]} */ + let selectedRow; + + for (colI = 0; colI < shape[1]; colI++) { + // For this column, aggregate along the row axis. + pixRowI = 0; + for (selectedRowI = 0; selectedRowI < selectedRows.length; selectedRowI++) { + selectedRow = selectedRows[selectedRowI]; + if (aggFunc && selectedRowsAggregationMethod === 'server') { + d = data[selectedRowI * shape[1] + colI]; + } else if (Array.isArray(selectedRow)) { + if (!aggFromDataFunc) { + throw new Error("row aggregation requires 'aggFromDataFunc'"); + } + // An aggregation step must be performed for this data point. + d = aggFromDataFunc(colI, selectedRow); + } else { + d = data[selectedRow * shape[1] + colI]; + } + + if (selectedRowsAggregationWithRelativeHeight && Array.isArray(selectedRow)) { + // Set a pixel for multiple rows, proportionate to the size of the row aggregation group. + for (selectedRowGroupItemI = 0; selectedRowGroupItemI < selectedRow.length; selectedRowGroupItemI++) { + setPixData( + pixRowI * shape[1] + colI, // pixData index + d // data point + ); + pixRowI++; + } + } else { + // Set a single pixel, either representing a single row or an entire row group, if the vertical height for each group should be uniform (i.e. should not depend on group size). + setPixData( + pixRowI * shape[1] + colI, // pixData index + d // data point + ); + pixRowI++; + } + } // end row group for + } // end col for +} + +/** + * @typedef SelectedRowsOptions + * @property {number[] | number[][]} selectedRows - Row indices, for ordering and filtering rows. Used by the HorizontalMultivecTrack. + * @property {'mean' | 'sum' | 'variance' | 'deviation'} selectedRowsAggregationMode - The aggregation function to use ("mean", "sum", etc). + * @property {boolean} selectedRowsAggregationWithRelativeHeight - Whether the height of row groups should be relative to the size of the group. + * @property {'client' | 'server'} selectedRowsAggregationMethod - Where will the aggregation be performed? Possible values: "client", "server". + */ + +/** + * This function takes in tile data and other rendering parameters, + * and generates an array of pixel data that can be passed to a canvas + * (and subsequently passed to a PIXI sprite). + * + * @param {number} size - `data` parameter length. Often set to a tile's `tile.tileData.dense.length` value. + * @param {Array} data - The tile data array. + * @param {'log' | 'linear'} valueScaleType 'log' or 'linear'. + * @param {[number, number]} valueScaleDomain + * @param {number} pseudocount - The pseudocount is generally the minimum non-zero value and is + * used so that our log scaling doesn't lead to NaN values. + * @param {Array<[r: number, g: number, b: number, a: number]>} colorScale + * @param {boolean} ignoreUpperRight + * @param {boolean} ignoreLowerLeft + * @param {[numRows: number, numCols: number] | null} shape - Array `[numRows, numCols]`, used when iterating over a subset of rows, + * when one needs to know the width of each column. + * @param {[r: number, g: number, b: number, a: number] | null} zeroValueColor - The color to use for rendering zero data values, [r, g, b, a]. + * @param {Partial | null} selectedRowsOptions - Rendering options when using a `selectRows` track option. + * + * @returns {Uint8ClampedArray} A flattened array of pixel values. + */ +function workerSetPix( + size, + data, + valueScaleType, + valueScaleDomain, + pseudocount, + colorScale, + ignoreUpperRight = false, + ignoreLowerLeft = false, + shape = null, + zeroValueColor = null, + selectedRowsOptions = null +) { + /** @type {import('../types').Scale} */ + let valueScale; + + if (valueScaleType === 'log') { + valueScale = scaleLog().range([254, 0]).domain(valueScaleDomain); + } else { + if (valueScaleType !== 'linear') { + console.warn('Unknown value scale type:', valueScaleType, ' Defaulting to linear'); + } + valueScale = scaleLinear().range([254, 0]).domain(valueScaleDomain); + } + + const { + selectedRows, + selectedRowsAggregationMode = 'mean', + selectedRowsAggregationWithRelativeHeight = false, + selectedRowsAggregationMethod = 'client' + } = selectedRowsOptions ?? {}; + + let filteredSize = size; + if (shape && selectedRows) { + // If using the `selectedRows` parameter, then the size of the `pixData` array + // will likely be different than `size` (the total size of the tile data array). + // The potential for aggregation groups in `selectedRows` also must be taken into account. + filteredSize = selectedItemsToSize(selectedRows, selectedRowsAggregationWithRelativeHeight) * shape[1]; + } + + let rgb; + let rgbIdx = 0; + const tileWidth = shape ? shape[1] : Math.sqrt(size); + const pixData = new Uint8ClampedArray(filteredSize * 4); + + /** @type {(x: number) => number} */ + const dToRgbIdx = x => { + const v = valueScale(x); + if (Number.isNaN(v)) return 254; + return Math.max(0, Math.min(254, Math.floor(v))); + }; + + /** + * Set the ith element of the pixData array, using value d. + * (well not really, since i is scaled to make space for each rgb value). + * + * @param {number} i - Index of the element. + * @param {number} d - The value to be transformed and then inserted. + */ + const setPixData = (i, d) => { + // Transparent + rgbIdx = 255; + + if ( + // ignore the upper right portion of a tile because it's on the diagonal + // and its mirror will fill in that space + !(ignoreUpperRight && Math.floor(i / tileWidth) < i % tileWidth) && + !(ignoreLowerLeft && Math.floor(i / tileWidth) > i % tileWidth) && + // Ignore color if the value is invalid + !Number.isNaN(+d) + ) { + // values less than espilon are considered NaNs and made transparent (rgbIdx 255) + rgbIdx = dToRgbIdx(d + pseudocount); + } + + // let rgbIdx = qScale(d); //Math.max(0, Math.min(255, Math.floor(valueScale(ct)))) + if (rgbIdx < 0 || rgbIdx > 255) { + console.warn('out of bounds rgbIdx:', rgbIdx, ' (should be 0 <= rgbIdx <= 255)'); + } + + if (zeroValueColor && !Number.isNaN(+d) && +d === 0.0) { + rgb = zeroValueColor; + } else { + rgb = colorScale[rgbIdx]; + } + + pixData[i * 4] = rgb[0]; + pixData[i * 4 + 1] = rgb[1]; + pixData[i * 4 + 2] = rgb[2]; + pixData[i * 4 + 3] = rgb[3]; + }; + + let d; + try { + if (selectedRows && shape) { + // We need to set the pixels in the order specified by the `selectedRows` parameter. + // Call the setPixDataForSelectedRows helper function, + // which will loop over the data for us and call setPixData(). + setPixDataForSelectedRows( + data, + shape, + setPixData, + selectedRows, + selectedRowsAggregationMode, + selectedRowsAggregationWithRelativeHeight, + selectedRowsAggregationMethod + ); + } else { + // The `selectedRows` array has not been passed, so we want to use all of the tile data values, + // in their default ordering. + for (let i = 0; i < data.length; i++) { + d = data[i]; + setPixData(i, d); + } + } + } catch (err) { + console.warn('Odd datapoint'); + console.warn('d:', d); + d = d ?? 0; + console.warn('valueScale.domain():', valueScale.domain()); + console.warn('valueScale.range():', valueScale.range()); + console.warn('value:', valueScale(d + pseudocount)); + console.warn('pseudocount:', pseudocount); + console.warn('rgbIdx:', rgbIdx, 'd:', d, 'ct:', valueScale(d)); + console.error('ERROR:', err); + return pixData; + } + + return pixData; +} + +/** + * Yanked from https://github.com/numpy/numpy/blob/master/numpy/core/src/npymath/halffloat.c#L466 + * + * Does not support infinities or NaN. All requests with such + * values should be encoded as float32 + * + * @param {number} h + * @returns {number} + */ +function float32(h) { + let hExp = h & 0x7c00; + let hSig; + let fExp; + let fSig; + + const fSgn = (h & 0x8000) << 16; + switch (hExp) { + /* 0 or subnormal */ + case 0x0000: + hSig = h & 0x03ff; + /* Signed zero */ + if (hSig === 0) { + return fSgn; + } + /* Subnormal */ + hSig <<= 1; + while ((hSig & 0x0400) === 0) { + hSig <<= 1; + hExp++; + } + fExp = (127 - 15 - hExp) << 23; + fSig = (hSig & 0x03ff) << 13; + return fSgn + fExp + fSig; + + /* inf or NaN */ + case 0x7c00: + /* All-ones exponent and a copy of the significand */ + return fSgn + 0x7f800000 + ((h & 0x03ff) << 13); + + default: + /* normalized */ + /* Just need to adjust the exponent and shift */ + return fSgn + (((h & 0x7fff) + 0x1c000) << 13); + } +} + +/** + * Convert a base64 string to an array buffer + * @param {string} base64 + * @returns {ArrayBuffer} + */ +function base64ToArrayBuffer(base64) { + const binaryString = atob(base64); + const len = binaryString.length; + + const bytes = new Uint8Array(len); + + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + return bytes.buffer; +} + +/** + * Convert a uint16 array to a float32 array + * + * @param {Uint16Array} uint16array + * @returns {Float32Array} + */ +function uint16ArrayToFloat32Array(uint16array) { + const bytes = new Uint32Array(uint16array.length); + + for (let i = 0; i < uint16array.length; i++) { + bytes[i] = float32(uint16array[i]); + } + + const newBytes = new Float32Array(bytes.buffer); + + return newBytes; +} + +/** + * @typedef TileData + * @property {string} server + * @property {string} tileId + * @property {number} zoomLevel + * @property {[number] | [number, number]} tilePos + * @property {string} tilesetUid + */ + +/** + * @typedef DenseTileData + * @property {string} server + * @property {string} tileId + * @property {number} zoomLevel + * @property {[number] | [number, number]} tilePos + * @property {string} tilesetUid + * @property {Float32Array} dense + * @property {string} dtype + * @property {DenseDataExtrema1D | DenseDataExtrema2D} denseDataExtrema + * @property {number} minNonZero + * @property {number} maxNonZero + */ + +/** + * @template T + * @typedef {Omit & (TileData | DenseTileData)} CompletedTileData + */ + +/** + * @typedef TileResponse + * @property {string=} dense - a base64 encoded string + */ + +/** + * Convert a response from the tile server to data that can be used by higlass. + * + * WARNING: Mutates the data object. + * + * @template {TileResponse} T + * @param {Record} inputData + * @param {string} server + * @param {string[]} theseTileIds + * + * @returns {Record>} + * + * Trevor: This function is littered with ts-expect-error comments because + * the type of mutation happening to the input object is very tricky to type. + * The type signature of the function tries to adequately describe the mutation, + * to outside users. + */ +function tileResponseToData(inputData, server, theseTileIds) { + /** @type {Record>} */ + // @ts-expect-error - This function works by overriing all the properties of inputData + // It's not great, but I don't want to touch the implementation. + const data = inputData ?? {}; + + for (const thisId of theseTileIds) { + if (!(thisId in data)) { + // the server didn't return any data for this tile + data[thisId] = {}; + } + const key = thisId; + // let's hope the payload doesn't contain a tileId field + const keyParts = key.split('.'); + + data[key].server = server; + data[key].tileId = key; + data[key].zoomLevel = +keyParts[1]; + + // slice from position 2 to exclude tileId and zoomLevel + // filter by NaN to exclude metadata portions of the tile request + /** @type {[number] | [number, number]} */ + // @ts-expect-error - tilePos is [number] or [number, number] + const tilePos = keyParts + .slice(2, keyParts.length) + .map(x => +x) + .filter(x => !Number.isNaN(x)); + data[key].tilePos = tilePos; + data[key].tilesetUid = keyParts[0]; + + if ('dense' in data[key]) { + /** @type {string} */ + // @ts-expect-error - The input of this function requires that dense is a string + // We are overriding the property on the input object, so TS is upset. + const base64 = data[key].dense; + const arrayBuffer = base64ToArrayBuffer(base64); + let a; + + if (data[key].dtype === 'float16') { + // data is encoded as float16s + /* comment out until next empty line for 32 bit arrays */ + const uint16Array = new Uint16Array(arrayBuffer); + const newDense = uint16ArrayToFloat32Array(uint16Array); + a = newDense; + } else { + // data is encoded as float32s + a = new Float32Array(arrayBuffer); + } + + const dde = tilePos.length === 2 ? new DenseDataExtrema2D(a) : new DenseDataExtrema1D(a); + + data[key].dense = a; + data[key].denseDataExtrema = dde; + data[key].minNonZero = dde.minNonZeroInTile; + data[key].maxNonZero = dde.maxNonZeroInTile; + } + } + + // @ts-expect-error - We have completed the tile data. + return data; +} + +/** + * Fetch tiles from the server. + * + * @param {string} outUrl + * @param {string} server + * @param {string[]} theseTileIds + * @param {string} authHeader + * @param {(data: Record>) => void} done + * @param {Record} requestBody + */ +function workerGetTiles(outUrl, server, theseTileIds, authHeader, done, requestBody) { + /** @type {Record} */ + const headers = { + 'content-type': 'application/json' + }; + + if (authHeader) headers.Authorization = authHeader; + + fetch(outUrl, { + credentials: 'same-origin', + headers, + ...(requestBody && Object.keys(requestBody).length > 0 + ? { + method: 'POST', + body: JSON.stringify(requestBody) + } + : {}) + }) + .then(response => response.json()) + .then(data => { + done(tileResponseToData(data, server, theseTileIds)); + }) + .catch(err => console.warn('err:', err)); +} + +/** @param {number} ms */ +const timeout = ms => + new Promise(resolve => { + setTimeout(resolve, ms); + }); + +const TILE_FETCH_DEBOUNCE = 100; + +// Number of milliseconds zoom-related actions (e.g., tile loading) are debounced +const ZOOM_DEBOUNCE = 10; + +// @ts-nocheck + +const MAX_FETCH_TILES = 15; + +/* +const str = document.currentScript.src +const pathName = str.substring(0, str.lastIndexOf("/")); +const workerPath = `${pathName}/worker.js`; + +const setPixPool = new Pool(1); + +setPixPool.run(function(params, done) { + try { + const array = new Float32Array(params.data); + const pixData = worker.workerSetPix( + params.size, + array, + params.valueScaleType, + params.valueScaleDomain, + params.pseudocount, + params.colorScale, + ); + + done.transfer({ + pixData: pixData + }, [pixData.buffer]); + } catch (err) { + console.log('err:', err); + } +}, [workerPath]); + + +const fetchTilesPool = new Pool(10); +fetchTilesPool.run(function(params, done) { + try { + worker.workerGetTiles(params.outUrl, params.server, params.theseTileIds, + params.authHeader, done); + // done.transfer({ + // pixData: pixData + // }, [pixData.buffer]); + } catch (err) { + console.log('err:', err); + } +}, [workerPath]); +*/ + +const sessionId = import.meta.env.DEV ? 'dev' : slugid.nice(); +let authHeader = null; // eslint-disable-line import/no-mutable-exports + +const throttleAndDebounce$1 = (func, interval, finalWait) => { + let timeout; + let bundledRequest = []; + let requestMapper = {}; + let blockedCalls = 0; + + const bundleRequests = request => { + const requestId = requestMapper[request.id]; + + if (requestId && bundledRequest[requestId]) { + bundledRequest[requestId].ids = bundledRequest[requestId].ids.concat(request.ids); + } else { + requestMapper[request.id] = bundledRequest.length; + bundledRequest.push(request); + } + }; + + const reset = () => { + timeout = null; + bundledRequest = []; + requestMapper = {}; + }; + + // In a normal situation we would just call `func(...args)` but since we + // modify the first argument and always trigger `reset()` afterwards I created + // this helper function to avoid code duplication. Think of this function + // as the actual function call that is being throttled and debounced. + const callFunc = (request, ...args) => { + func( + { + sessionId, + requests: bundledRequest + }, + ...args + ); + reset(); + }; + + const debounced = (request, ...args) => { + const later = () => { + // Since we throttle and debounce we should check whether there were + // actually multiple attempts to call this function after the most recent + // throttled call. If there were no more calls we don't have to call + // the function again. + if (blockedCalls > 0) { + callFunc(request, ...args); + blockedCalls = 0; + } + }; + + clearTimeout(timeout); + timeout = setTimeout(later, finalWait); + }; + + debounced.cancel = () => { + clearTimeout(timeout); + reset(); + }; + + debounced.immediate = () => { + func({ + sessionId, + requests: bundledRequest + }); + }; + + let wait = false; + const throttled = (request, ...args) => { + bundleRequests(request); + + if (!wait) { + callFunc(request, ...args); + debounced(request, ...args); + wait = true; + blockedCalls = 0; + setTimeout(() => { + wait = false; + }, interval); + } else { + blockedCalls++; + } + }; + + return throttled; +}; + +// Fritz: is this function used anywhere? +function fetchMultiRequestTiles(req, pubSub) { + const requests = req.requests; + + const fetchPromises = []; + + const requestsByServer = {}; + const requestBodyByServer = {}; + + // We're converting the array of IDs into an object in order to filter out duplicated requests. + // In case different instances request the same data it won't be loaded twice. + for (const request of requests) { + if (!requestsByServer[request.server]) { + requestsByServer[request.server] = {}; + requestBodyByServer[request.server] = []; + } + for (const id of request.ids) { + requestsByServer[request.server][id] = true; + + if (request.options) { + const firstSepIndex = id.indexOf('.'); + const tilesetUuid = id.substring(0, firstSepIndex); + const tileId = id.substring(firstSepIndex + 1); + const tilesetObject = requestBodyByServer[request.server].find(t => t.tilesetUid === tilesetUuid); + if (tilesetObject) { + tilesetObject.tileIds.push(tileId); + } else { + requestBodyByServer[request.server].push({ + tilesetUid: tilesetUuid, + tileIds: [tileId], + options: request.options + }); + } + } + } + } + + const servers = Object.keys(requestsByServer); + + for (const server of servers) { + const ids = Object.keys(requestsByServer[server]); + // console.log('ids:', ids); + + const requestBody = requestBodyByServer[server]; + + // if we request too many tiles, then the URL can get too long and fail + // so we'll break up the requests into smaller subsets + for (let i = 0; i < ids.length; i += MAX_FETCH_TILES) { + const theseTileIds = ids.slice(i, i + Math.min(ids.length - i, MAX_FETCH_TILES)); + + const renderParams = theseTileIds.map(x => `d=${x}`).join('&'); + const outUrl = `${server}/tiles/?${renderParams}&s=${sessionId}`; + + /* eslint-disable no-loop-func */ + /* eslint-disable no-unused-vars */ + const p = new Promise((resolve, reject) => { + pubSub.publish('requestSent', outUrl); + const params = {}; + + params.outUrl = outUrl; + params.server = server; + params.theseTileIds = theseTileIds; + params.authHeader = authHeader; + + workerGetTiles( + params.outUrl, + params.server, + params.theseTileIds, + params.authHeader, + resolve, + requestBody + ); + + /* + fetchTilesPool.send(params) + .promise() + .then(ret => { + resolve(ret); + }); + */ + pubSub.publish('requestReceived', outUrl); + }); + + fetchPromises.push(p); + } + } + + Promise.all(fetchPromises).then(datas => { + const tiles = {}; + + // merge back all the tile requests + for (const data of datas) { + const tileIds = Object.keys(data); + + for (const tileId of tileIds) { + tiles[`${data[tileId].server}/${tileId}`] = data[tileId]; + } + } + + // trigger the callback for every request + for (const request of requests) { + const reqDate = {}; + const { server } = request; + + // pull together the data per request + for (const id of request.ids) { + reqDate[id] = tiles[`${server}/${id}`]; + } + + request.done(reqDate); + } + }); +} + +/** + * Retrieve a set of tiles from the server + * + * Plenty of room for optimization and caching here. + * + * @param server: A string with the server's url (e.g. "http://127.0.0.1") + * @param tileIds: The ids of the tiles to fetch (e.g. asdf-sdfs-sdfs.0.0.0) + */ +const fetchTilesDebounced = throttleAndDebounce$1(fetchMultiRequestTiles, TILE_FETCH_DEBOUNCE, TILE_FETCH_DEBOUNCE); + +/** + * Calculate the zoom level from a list of available resolutions + */ +const calculateZoomLevelFromResolutions = (resolutions, scale) => { + const sortedResolutions = resolutions.map(x => +x).sort((a, b) => b - a); + + const trackWidth = scale.range()[1] - scale.range()[0]; + + const binsDisplayed = sortedResolutions.map(r => (scale.domain()[1] - scale.domain()[0]) / r); + const binsPerPixel = binsDisplayed.map(b => b / trackWidth); + + // we're going to show the highest resolution that requires more than one + // pixel per bin + const displayableBinsPerPixel = binsPerPixel.filter(b => b < 1); + + if (displayableBinsPerPixel.length === 0) return 0; + + return binsPerPixel.indexOf(displayableBinsPerPixel[displayableBinsPerPixel.length - 1]); +}; + +const calculateResolution = (tilesetInfo, zoomLevel) => { + if (tilesetInfo.resolutions) { + const sortedResolutions = tilesetInfo.resolutions.map(x => +x).sort((a, b) => b - a); + const resolution = sortedResolutions[zoomLevel]; + + return resolution; + } + + const maxWidth = tilesetInfo.max_width; + const binsPerDimension = +tilesetInfo.bins_per_dimension; + const resolution = maxWidth / (2 ** zoomLevel * binsPerDimension); + + return resolution; +}; + +/** + * Calculate the current zoom level. + */ +const calculateZoomLevel = (scale, minX, maxX, binsPerTile) => { + const rangeWidth = scale.range()[1] - scale.range()[0]; + + const zoomScale = Math.max((maxX - minX) / (scale.domain()[1] - scale.domain()[0]), 1); + + const viewResolution = 384; + // const viewResolution = 2048; + + // fun fact: the number 384 is halfway between 256 and 512 + const addedZoom = Math.max(0, Math.ceil(Math.log(rangeWidth / viewResolution) / Math.LN2)); + let zoomLevel = Math.round(Math.log(zoomScale) / Math.LN2) + addedZoom; + + let binsPerTileCorrection = 0; + + if (binsPerTile) { + binsPerTileCorrection = Math.floor(Math.log(256) / Math.log(2) - Math.log(binsPerTile) / Math.log(2)); + } + + zoomLevel += binsPerTileCorrection; + + return zoomLevel; +}; + +/** + * Calculate the element within this tile containing the given + * position. + * + * Returns the tile position and position within the tile for + * the given element. + * + * @param {object} tilesetInfo: The information about this tileset + * @param {Number} maxDim: The maximum width of the dataset (only used for + * tilesets without resolutions) + * @param {Number} dataStartPos: The position where the data begins + * @param {int} zoomLevel: The current zoomLevel + * @param {Number} position: The position (in absolute coordinates) to caculate + * the tile and position in tile for + */ +function calculateTileAndPosInTile(tilesetInfo, maxDim, dataStartPos, zoomLevel, position) { + let tileWidth = null; + const PIXELS_PER_TILE = tilesetInfo.bins_per_dimension || 256; + + if (tilesetInfo.resolutions) { + tileWidth = tilesetInfo.resolutions[zoomLevel] * PIXELS_PER_TILE; + } else { + tileWidth = maxDim / 2 ** zoomLevel; + } + + const tilePos = Math.floor((position - dataStartPos) / tileWidth); + const posInTile = Math.floor((PIXELS_PER_TILE * (position - tilePos * tileWidth)) / tileWidth); + + return [tilePos, posInTile]; +} + +/** + * Calculate the tiles that should be visible get a data domain + * and a tileset info + * + * All the parameters except the first should be present in the + * tileset_info returned by the server. + * + * @param {number} zoomLevel - The zoom level at which to find the tiles (can be + * calculated using this.calcaulteZoomLevel, but needs to synchronized across + * both x and y scales so should be calculated externally) + * @param {import('../type').Scale} scale - A d3 scale mapping data domain to visible values + * @param {number} minX - The minimum possible value in the dataset + * @param {number} maxX - The maximum possible value in the dataset + * @param {number} maxZoom - The maximum zoom value in this dataset + * @param {number} maxDim - The largest dimension of the tileset (e.g., width or height) + * (roughlty equal to 2 ** maxZoom * tileSize * tileResolution) + * @returns {number[]} The indices of the tiles that should be visible + */ +const calculateTiles = (zoomLevel, scale, minX, maxX, maxZoom, maxDim) => { + const zoomLevelFinal = Math.min(zoomLevel, maxZoom); + + // the ski areas are positioned according to their + // cumulative widths, which means the tiles need to also + // be calculated according to cumulative width + + const tileWidth = maxDim / 2 ** zoomLevelFinal; + // console.log('maxDim:', maxDim); + + const epsilon = 0.0000001; + + /* + console.log('minX:', minX, 'zoomLevel:', zoomLevel); + console.log('domain:', scale.domain(), scale.domain()[0] - minX, + ((scale.domain()[0] - minX) / tileWidth)) + */ + + return range( + Math.max(0, Math.floor((scale.domain()[0] - minX) / tileWidth)), + Math.min(2 ** zoomLevelFinal, Math.ceil((scale.domain()[1] - minX - epsilon) / tileWidth)) + ); +}; + +const calculateTileWidth = (tilesetInfo, zoomLevel, binsPerTile) => { + if (tilesetInfo.resolutions) { + const sortedResolutions = tilesetInfo.resolutions.map(x => +x).sort((a, b) => b - a); + return sortedResolutions[zoomLevel] * binsPerTile; + } + return tilesetInfo.max_width / 2 ** zoomLevel; +}; + +/** + * Calculate the tiles that sould be visisble given the resolution and + * the minX and maxX values for the region + * + * @param {number} resolution - The number of base pairs per bin + * @param {import('../type').Scale} scale - The scale to use to calculate the currently visible tiles + * @param {number} minX - The minimum x position of the tileset + * @param {number} maxX - The maximum x position of the tileset + * @param {number=} pixelsPerTile - The number of pixels per tile + * @returns {number[]} The indices of the tiles that should be visible + */ +const calculateTilesFromResolution = (resolution, scale, minX, maxX, pixelsPerTile) => { + const epsilon = 0.0000001; + const PIXELS_PER_TILE = pixelsPerTile || 256; + const tileWidth = resolution * PIXELS_PER_TILE; + const MAX_TILES = 20; + // console.log('PIXELS_PER_TILE:', PIXELS_PER_TILE); + + if (!maxX) { + maxX = Number.MAX_VALUE; // eslint-disable-line no-param-reassign + } + + const lowerBound = Math.max(0, Math.floor((scale.domain()[0] - minX) / tileWidth)); + const upperBound = Math.ceil(Math.min(maxX, scale.domain()[1] - minX - epsilon) / tileWidth); + let tileRange = range(lowerBound, upperBound); + + if (tileRange.length > MAX_TILES) { + // too many tiles visible in this range + console.warn(`Too many visible tiles: ${tileRange.length} truncating to ${MAX_TILES}`); + tileRange = tileRange.slice(0, MAX_TILES); + } + // console.log('tileRange:', tileRange); + + return tileRange; +}; + +/** + * Render 2D tile data. Convert the raw values to an array of + * color values + * + * @param finished: A callback to let the caller know that the worker thread + * has converted tileData to pixData + * @param minVisibleValue: The minimum visible value (used for setting the color + * scale) + * @param maxVisibleValue: The maximum visible value + * @param valueScaleType: Either 'log' or 'linear' + * @param valueScaleDomain: The domain of the scale (the range is always [254,0]) + * @param colorScale: a 255 x 4 rgba array used as a color scale + * @param synchronous: Render this tile synchronously or pass it on to the threadpool (which doesn't exist yet). + * @param ignoreUpperRight: If this is a tile along the diagonal and there will + * be mirrored tiles present ignore the upper right values + * @param ignoreLowerLeft: If this is a tile along the diagonal and there will be + * mirrored tiles present ignore the lower left values + * @param {array} zeroValueColor: The color to use for rendering zero data values, [r, g, b, a]. + * @param {object} selectedRowsOptions Rendering options when using a `selectRows` track option. + */ +const tileDataToPixData = ( + tile, + valueScaleType, + valueScaleDomain, + pseudocount, + colorScale, + finished, + ignoreUpperRight, + ignoreLowerLeft, + zeroValueColor, + selectedRowsOptions +) => { + const { tileData } = tile; + + if (!tileData.dense) { + // if we didn't get any data from the server, don't do anything + finished(null); + return; + } + + if ( + tile.mirrored && + // Data is already copied over + !tile.isMirrored && + tile.tileData.tilePos.length > 0 && + tile.tileData.tilePos[0] === tile.tileData.tilePos[1] + ) { + // Copy the data before mutating it in case the same data is used elsewhere. + // During throttling/debouncing tile requests we also merge the requests so + // the very same tile data might be used by different tracks. + tile.tileData.dense = tile.tileData.dense.slice(); + + // if a center tile is mirrored, we'll just add its transpose + const tileWidth = Math.floor(Math.sqrt(tile.tileData.dense.length)); + for (let row = 0; row < tileWidth; row++) { + for (let col = row + 1; col < tileWidth; col++) { + tile.tileData.dense[row * tileWidth + col] = tile.tileData.dense[col * tileWidth + row]; + } + } + if (ignoreLowerLeft) { + for (let row = 0; row < tileWidth; row++) { + for (let col = 0; col < row; col++) { + tile.tileData.dense[row * tileWidth + col] = NaN; + } + } + } + tile.isMirrored = true; + } + + // console.log('tile', tile); + // clone the tileData so that the original array doesn't get neutered + // when being passed to the worker script + // const newTileData = tileData.dense; + + // comment this and uncomment the code afterwards to enable threading + const pixData = workerSetPix( + tileData.dense.length, + tileData.dense, + valueScaleType, + valueScaleDomain, + pseudocount, + colorScale, + ignoreUpperRight, + ignoreLowerLeft, + tile.tileData.shape, + zeroValueColor, + selectedRowsOptions + ); + + finished({ pixData }); + + // const newTileData = new Float32Array(tileData.dense.length); + // newTileData.set(tileData.dense); + /* + var params = { + size: newTileData.length, + data: newTileData, + valueScaleType: valueScaleType, + valueScaleDomain: valueScaleDomain, + pseudocount: pseudocount, + colorScale: colorScale + }; + + setPixPool.send(params, [ newTileData.buffer ]) + .promise() + .then(returned => { + finished(returned); + }) + .catch(reason => { + finished(null); + }); + ; + */ +}; + +function fetchEither(url, callback, textOrJson, pubSub) { + pubSub.publish('requestSent', url); + + let mime = null; + if (textOrJson === 'text') { + mime = null; + } else if (textOrJson === 'json') { + mime = 'application/json'; + } else { + throw new Error(`fetch either "text" or "json", not "${textOrJson}"`); + } + const headers = {}; + + if (mime) { + headers['Content-Type'] = mime; + } + return fetch(url, { credentials: 'same-origin', headers }) + .then(rep => { + if (!rep.ok) { + throw Error(rep.statusText); + } + + return rep[textOrJson](); + }) + .then(content => { + callback(undefined, content); + return content; + }) + .catch(error => { + console.error(`Could not fetch ${url}`, error); + callback(error, undefined); + return error; + }) + .finally(() => { + pubSub.publish('requestReceived', url); + }); +} + +/** + * Send a text request and mark it so that we can tell how many are in flight + * + * @param url: URL to fetch + * @param callback: Callback to execute with content from fetch + */ +function text(url, callback, pubSub) { + return fetchEither(url, callback, 'text', pubSub); +} + +/** + * Send a JSON request and mark it so that we can tell how many are in flight + * + * @param url: URL to fetch + * @param callback: Callback to execute with content from fetch + */ +async function json(url, callback, pubSub) { + // Fritz: What is going on here? Can someone explain? + if (url.indexOf('hg19') >= 0) { + await timeout(1); + } + // console.log('url:', url); + return fetchEither(url, callback, 'json', pubSub); +} + +/** + * Request a tilesetInfo for a track + * + * @param {string} server: The server where the data resides + * @param {string} tilesetUid: The identifier for the dataset + * @param {func} doneCb: A callback that gets called when the data is retrieved + * @param {func} errorCb: A callback that gets called when there is an error + */ +const trackInfo = (server, tilesetUid, doneCb, errorCb, pubSub) => { + const url = `${trimTrailingSlash(server)}/tileset_info/?d=${tilesetUid}&s=${sessionId}`; + pubSub.publish('requestSent', url); + // TODO: Is this used? + json( + url, + (error, data) => { + // eslint-disable-line + pubSub.publish('requestReceived', url); + if (error) { + // console.log('error:', error); + // don't do anything + // no tileset info just means we can't do anything with this file... + if (errorCb) { + errorCb(`Error retrieving tilesetInfo from: ${server}`); + } else { + console.warn('Error retrieving: ', url); + } + } else { + // console.log('got data', data); + doneCb(data); + } + }, + pubSub + ); +}; + +const api = { + calculateResolution, + calculateTileAndPosInTile, + calculateTiles, + calculateTilesFromResolution, + calculateTileWidth, + calculateZoomLevel, + calculateZoomLevelFromResolutions, + fetchTilesDebounced, + json, + text, + tileDataToPixData, + trackInfo +}; + +/** @typedef {import('../types').DataConfig} DataConfig */ +/** @typedef {import('../types').TilesetInfo} TilesetInfo */ +/** + * @template T + * @typedef {import('../types').AbstractDataFetcher} AbstractDataFetcher + */ + +/** + * @typedef Tile + * @property {number} min_value + * @property {number} max_value + * @property {DenseDataExtrema1D | DenseDataExtrema2D} denseDataExtrema + * @property {number} minNonZero + * @property {number} maxNonZero + * @property {Array | Float32Array} dense + * @property {string} dtype + * @property {string} server + * @property {number[]} tilePos + * @property {string} tilePositionId + * @property {string} tilesetUid + * @property {number} zoomLevel + */ + +/** @typedef {Pick} DividedTileA */ +/** @typedef {Pick} DividedTileB */ +/** @typedef {DividedTileA | DividedTileB} DividedTile */ +/** @typedef {Omit & { children?: DataFetcher[], tilesetUid?: string, tilesetInfo: TilesetInfo }} ResolvedDataConfig */ + +/** + * @template T + * @param {Array} x + * @returns {x is [T, T]} + */ +function isTuple(x) { + return x.length === 2; +} + +/** @implements {AbstractDataFetcher} */ +class DataFetcher { + /** + * @param {import('../types').DataConfig} dataConfig + * @param {import('pub-sub-es').PubSub} pubSub + */ + constructor(dataConfig, pubSub) { + /** @type {boolean} */ + this.tilesetInfoLoading = true; + + if (!dataConfig) { + // Trevor: This should probably throw? + console.error('No dataconfig provided'); + return; + } + + // copy the dataConfig so that it doesn't dirty so that + // it doesn't get modified when we make objects of its + // children below + /** @type {ResolvedDataConfig} */ + this.dataConfig = JSON.parse(JSON.stringify(dataConfig)); + /** @type {string} */ + this.uuid = slugid.nice(); + /** @type {import('pub-sub-es').PubSub} */ + this.pubSub = pubSub; + + if (dataConfig.children) { + // convert each child into an object + this.dataConfig.children = dataConfig.children.map(c => new DataFetcher(c, pubSub)); + } + } + + /** + * We don't a have a tilesetUid for this track. But we do have a url, filetype + * and server. Using these, we can use the server to fullfill tile requests + * from this dataset. + * + * @param {object} opts + * @param {string} opts.server - The server api location (e.g. 'localhost:8000/api/v1') + * @param {string} opts.url - The location of the data file (e.g. 'encode.org/my.file.bigwig') + * @param {string} opts.filetype - The type of file being served (e.g. 'bigwig') + * @param {string=} opts.coordSystem - The coordinate system being served (e.g. 'hg38') + */ + async registerFileUrl({ server, url, filetype, coordSystem }) { + const serverUrl = `${trimTrailingSlash(server)}/register_url/`; + + const payload = { + fileurl: url, + filetype, + coordSystem + }; + + return fetch(serverUrl, { + method: 'POST', + body: JSON.stringify(payload), + headers: { + 'Content-Type': 'application/json; charset=utf-8' + } + }); + } + + /** + * Obtain tileset infos for all of the tilesets listed + * @param {import('../types').HandleTilesetInfoFinished} finished - A callback that will be called + */ + tilesetInfo(finished) { + // if this track has a url, server and filetype + // then we need to register those with the server + const { server, url, filetype, coordSystem } = this.dataConfig; + if (server && url && filetype) { + return this.registerFileUrl({ server, url, filetype, coordSystem }) + .then(data => data.json()) + .then(data => { + this.dataConfig.tilesetUid = data.uid; + this.tilesetInfoAfterRegister(finished); + }) + .catch(rejected => { + console.error('Error registering url', rejected); + }); + } + + return new Promise(() => { + this.tilesetInfoAfterRegister(finished); + }); + } + + /** + * Obtain tileset infos for all of the tilesets listed + * + * If there is more than one tileset info, this function + * should (not currently implemented) check if the tileset + * infos have the same dimensions and then return a common + * one. + * + * @param {import('../types').HandleTilesetInfoFinished} finished - A callback that will be called + * when all tileset infos are loaded + */ + tilesetInfoAfterRegister(finished) { + if (!this.dataConfig.children) { + // this data source has no children so we + // just need to retrieve one tileset info + const { server, tilesetUid } = this.dataConfig; + if (!server || !tilesetUid) { + console.warn('No dataConfig children, server or tilesetUid:', this.dataConfig); + finished(null); + } else { + // pass in the callback + trackInfo( + server, + tilesetUid, + (/** @type {Record} */ tilesetInfo) => { + // tileset infos are indxed by by tilesetUids, we can just resolve + // that here before passing it back to the track + this.dataConfig.tilesetInfo = tilesetInfo[tilesetUid]; + finished(tilesetInfo[tilesetUid], tilesetUid); + }, + (/** @type {string} */ error) => { + this.tilesetInfoLoading = false; + finished({ error }); + }, + this.pubSub + ); + } + } else { + // this data source has children, so we need to wait to get + // all of their tileset infos in order to return them to the track + const promises = this.dataConfig.children.map( + x => + /** @type {Promise} */ + new Promise(resolve => { + x.tilesetInfo(resolve); + }) + ); + + Promise.all(promises).then(values => { + // this is where we should check if all the children's tileset + // infos match + finished(values[0]); + }); + } + } + + /** + * @param {string} tilesetUid - Uid of the tileset on the server + * @param {string} tileId - The tileId of the tile + * @returns {string} The full tile id that the server will parse. + * + * @example + * ```javascript + * // returns 'xyxx.0.0.0' + * fullTileId('xyxx', '0.0.0'); + * ``` + */ + fullTileId(tilesetUid, tileId) { + return `${tilesetUid}.${tileId}`; + } + + /** + * Fetch a set of tiles. + * + * Because the track shouldn't care about tileset ids, the tile ids + * should just include positions and any necessary transforms. + * + * @param {(tiles: Record) => void} receivedTiles - A function to call once the tiles have been fetched + * @param {string[]} tileIds - The tile ids to fetch + * @returns {Promise>} + */ + fetchTilesDebounced(receivedTiles, tileIds) { + if (this.dataConfig.type === 'horizontal-section') { + return this.fetchHorizontalSection(receivedTiles, tileIds); + } + if (this.dataConfig.type === 'vertical-section') { + return this.fetchHorizontalSection(receivedTiles, tileIds, true); + } + + if (!this.dataConfig.children && this.dataConfig.tilesetUid) { + // no children, just return the fetched tiles as is + /** @type {Promise>} */ + const promise = new Promise(resolve => { + fetchTilesDebounced( + { + id: slugid.nice(), + server: this.dataConfig.server, + done: resolve, + ids: tileIds.map(x => `${this.dataConfig.tilesetUid}.${x}`), + options: this.dataConfig.options + }, + this.pubSub, + true + ); + }); + + return promise.then(returnedTiles => { + const tilesetUid = dictValues(returnedTiles)[0].tilesetUid; + /** @type {Record} */ + const newTiles = {}; + + for (let i = 0; i < tileIds.length; i++) { + const fullTileId = this.fullTileId(tilesetUid, tileIds[i]); + + returnedTiles[fullTileId].tilePositionId = tileIds[i]; + newTiles[tileIds[i]] = returnedTiles[fullTileId]; + } + receivedTiles(newTiles); + return newTiles; + }); + } + + // multiple child tracks, need to wait for all of them to + // fetch their data before returning to the parent + /** @type {Promise>[]} Tiles */ + const promises = + this.dataConfig.children?.map( + x => + /** @type {Promise>} */ + new Promise(resolve => { + x.fetchTilesDebounced(resolve, tileIds); + }) + ) ?? []; + + return Promise.all(promises).then(returnedTiles => { + // if we're trying to divide two datasets, + if (this.dataConfig.type === 'divided' && isTuple(returnedTiles)) { + const newTiles = this.makeDivided(returnedTiles, tileIds); + receivedTiles(newTiles); + return newTiles; + } + // assume we're just returning raw tiles + console.warn('Unimplemented dataConfig type. Returning first data source.', this.dataConfig); + receivedTiles(returnedTiles[0]); + return returnedTiles[0]; + }); + } + + /** + * Return an array consisting of the division of the numerator + * array by the denominator array + * + * @param {ArrayLike} numeratorData - An array of numerical values + * @param {ArrayLike} denominatorData - An array of numerical values + * + * @returns {Float32Array} An array consisting of the division of the numerator by the denominator + */ + divideData(numeratorData, denominatorData) { + const result = new Float32Array(numeratorData.length); + + for (let i = 0; i < result.length; i++) { + if (denominatorData[i] === 0.0) result[i] = NaN; + else result[i] = numeratorData[i] / denominatorData[i]; + } + + return result; + } + + /* + * Take a horizontal slice across the returned tiles at the + * given position. + * + * @param {list} returnedTiles: The tiles returned from a fetch request + * @param {Number} sliceYPos: The y position across which to slice + */ + horizontalSlice(/* returnedTiles, sliceYPos */) { + return null; + } + + /** + * Extract a slice from a matrix at a given position. + * + * @param {Array} inputData - An array containing a matrix stored row-wise + * @param {Array} arrayShape - The shape of the array, should be a + * two element array e.g. [256,256]. + * @param {number} sliceIndex - The index across which to take the slice + * @param {number=} axis - The axis along which to take the slice + * @returns {Array} an array corresponding to a slice of this matrix + */ + extractDataSlice(inputData, arrayShape, sliceIndex, axis) { + if (!axis) { + return inputData.slice(arrayShape[1] * sliceIndex, arrayShape[1] * (sliceIndex + 1)); + } + + const returnArray = new Array(arrayShape[1]); + for (let i = sliceIndex; i < inputData.length; i += arrayShape[0]) { + returnArray[Math.floor(i / arrayShape[0])] = inputData[i]; + } + return returnArray; + } + + /** + * Fetch a horizontal section of a 2D dataset + * @param {(tiles: Record) => void} receivedTiles - A function to call once the tiles have been fetched + * @param {string[]} tileIds - The tile ids to fetch + * @param {boolean=} vertical - Whether to fetch a vertical section + * @returns {Promise>} + */ + fetchHorizontalSection(receivedTiles, tileIds, vertical = false) { + // We want to take a horizontal section of a 2D dataset + // that means that a 1D track is requesting data from a 2D source + // because the 1D track only requests 1D tiles, we need to calculate + // the 2D tile from which to take the slice + /** @type {string[]} */ + const newTileIds = []; + /** @type {boolean[]} */ + const mirrored = []; + + const { slicePos, tilesetInfo } = this.dataConfig; + if (!slicePos || !tilesetInfo) { + throw new Error('No slice position or tileset info'); + } + + for (const tileId of tileIds) { + const parts = tileId.split('.'); + const zoomLevel = +parts[0]; + const xTilePos = +parts[1]; + + // this is a dummy scale that we'll use to fetch tile positions + // along the y-axis of the 2D dataset (we already have the x positions + // from the track that is querying this data) + const scale = scaleLinear().domain([slicePos, slicePos]); + + // there's two different ways of calculating tile positions + // this needs to be consolidated into one function eventually + let yTiles = []; + + if ('resolutions' in tilesetInfo) { + const sortedResolutions = tilesetInfo.resolutions.map(x => +x).sort((a, b) => b - a); + + yTiles = calculateTilesFromResolution( + sortedResolutions[zoomLevel], + scale, + tilesetInfo.min_pos[vertical ? 1 : 0], + tilesetInfo.max_pos[vertical ? 1 : 0] + ); + } else { + yTiles = calculateTiles( + zoomLevel, + scale, + tilesetInfo.min_pos[vertical ? 1 : 0], + tilesetInfo.max_pos[vertical ? 1 : 0], + tilesetInfo.max_zoom, + tilesetInfo.max_width + ); + } + const sortedPosition = [xTilePos, yTiles[0]].sort((a, b) => a - b); + + // make note of whether we reversed the x and y tile positions + if (sortedPosition[0] === xTilePos) { + mirrored.push(false); + } else { + mirrored.push(true); + } + + const newTileId = `${zoomLevel}.${sortedPosition[0]}.${sortedPosition[1]}`; + newTileIds.push(newTileId); + // we may need to add something about the data transform + } + + // actually fetch the new tileIds + const promise = new Promise(resolve => { + fetchTilesDebounced( + { + id: slugid.nice(), + server: this.dataConfig.server, + done: resolve, + ids: newTileIds.map(x => `${this.dataConfig.tilesetUid}.${x}`) + }, + this.pubSub, + true + ); + }); + return promise.then(returnedTiles => { + // we've received some new tiles, but they're 2D + // we need to extract the row corresponding to the data we need + + const tilesetUid = dictValues(returnedTiles)[0].tilesetUid; + // console.log('tilesetUid:', tilesetUid); + /** @type {Record} */ + const newTiles = {}; + + for (let i = 0; i < newTileIds.length; i++) { + const parts = newTileIds[i].split('.'); + const zoomLevel = +parts[0]; + const xTilePos = +parts[1]; + const yTilePos = +parts[2]; + + const sliceIndex = calculateTileAndPosInTile( + tilesetInfo, + // @ts-expect-error - This is undefined for legacy tilesets, but + // `calculateTileAndPosInTile` ignores this argument with `resolutions`. + // We should probably refactor `calculateTileAndPosInTile` to just take + // the `tilesetInfo` object. + tilesetInfo.max_width, + tilesetInfo.min_pos[1], + zoomLevel, + +slicePos + )[1]; + + const fullTileId = this.fullTileId(tilesetUid, newTileIds[i]); + const tile = returnedTiles[fullTileId]; + + let dataSlice = null; + + if (xTilePos === yTilePos) { + // this is tile along the diagonal that we have to mirror + dataSlice = this.extractDataSlice(tile.dense, [256, 256], sliceIndex); + const mirroredDataSlice = this.extractDataSlice(tile.dense, [256, 256], sliceIndex, 1); + for (let j = 0; j < dataSlice.length; j++) { + dataSlice[j] += mirroredDataSlice[j]; + } + } else if (mirrored[i]) { + // this tile is in the upper right triangle but the data is only available for + // the lower left so we have to mirror it + dataSlice = this.extractDataSlice(tile.dense, [256, 256], sliceIndex, 1); + } else { + dataSlice = this.extractDataSlice(tile.dense, [256, 256], sliceIndex); + } + + const newTile = { + min_value: Math.min.apply(null, dataSlice), + max_value: Math.max.apply(null, dataSlice), + denseDataExtrema: new DenseDataExtrema1D(dataSlice), + minNonZero: minNonZero(dataSlice), + maxNonZero: maxNonZero(dataSlice), + dense: dataSlice, + dtype: tile.dtype, + server: tile.server, + tilePos: mirrored[i] ? [yTilePos] : [xTilePos], + tilePositionId: tileIds[i], + tilesetUid, + zoomLevel: tile.zoomLevel + }; + + newTiles[tileIds[i]] = newTile; + } + + receivedTiles(newTiles); + return newTiles; + }); + } + + /** + * @typedef {{ zoomLevel: number, tilePos: number[], dense?: ArrayLike }} Dividable + * @param {[Record, Record]} returnedTiles + * @param {string[]} tileIds + * @returns {Record} + */ + makeDivided(returnedTiles, tileIds) { + if (returnedTiles.length < 2) { + console.warn('Only one tileset specified for a divided datafetcher:', this.dataConfig); + } + + /** @type {Record} */ + const newTiles = {}; + + for (let i = 0; i < tileIds.length; i++) { + // const numeratorUid = this.fullTileId(numeratorTilesetUid, tileIds[i]); + // const denominatorUid = this.fullTileId(denominatorTilesetUid, tileIds[i]); + const zoomLevel = returnedTiles[0][tileIds[i]].zoomLevel; + const tilePos = returnedTiles[0][tileIds[i]].tilePos; + + /** @type {DividedTile} */ + let newTile = { + zoomLevel, + tilePos, + tilePositionId: tileIds[i] + }; + + const denseA = returnedTiles[0][tileIds[i]].dense; + const denseB = returnedTiles[1][tileIds[i]].dense; + + if (denseA && denseB) { + const newData = this.divideData(denseA, denseB); + const dde = tilePos.length === 2 ? new DenseDataExtrema2D(newData) : new DenseDataExtrema1D(newData); + + newTile = { + dense: newData, + denseDataExtrema: dde, + minNonZero: minNonZero(newData), + maxNonZero: maxNonZero(newData), + zoomLevel, + tilePos, + tilePositionId: tileIds[i] + }; + } + + // returned ids will be indexed by the tile id and won't include the + // tileset uid + newTiles[tileIds[i]] = newTile; + } + + return newTiles; + } +} + +/** + * Throttle and debounce a function call + * + * Throttling a function call means that the function is called at most every + * `interval` milliseconds no matter how frequently you trigger a call. + * Debouncing a function call means that the function is called the earliest + * after `finalWait` milliseconds wait time where the function was not called. + * Combining the two ensures that the function is called at most every + * `interval` milliseconds and is ensured to be called with the very latest + * arguments after after `finalWait` milliseconds wait time at the end. + * + * The following imaginary scenario describes the behavior: + * + * MS | interval=2 and finalWait=2 + * 01. y(f, 2, 2)(args_01) => f(args_01) call + * 02. y(f, 2, 2)(args_02) => throttled call + * 03. y(f, 2, 2)(args_03) => f(args_03) call + * 04. y(f, 2, 2)(args_04) => throttled call + * 05. y(f, 2, 2)(args_05) => f(args_05) call + * 06. y(f, 2, 2)(args_06) => throttled call + * 07. y(f, 2, 2)(args_07) => f(args_03) call + * 08. y(f, 2, 2)(args_08) => throttled call + * 09. nothing + * 10. y(f, 2, 2)(args_10) => f(args_10) call from debouncing + * + * @template {any[]} Args + * @param {(...args: Args) => void} func - Function to be throttled and debounced + * @param {number} interval - Throttle intevals in milliseconds + * @param {number} finalWait - Debounce wait time in milliseconds + * @return {(request: unknown, ...args: Args) => void} - Throttled and debounced function + */ +const throttleAndDebounce = (func, interval, finalWait) => { + /** @type {ReturnType | undefined} */ + let timeout; + let blockedCalls = 0; + + const reset = () => { + timeout = undefined; + }; + + /** @param {Args} args */ + const debounced = (...args) => { + const later = () => { + // Since we throttle and debounce we should check whether there were + // actually multiple attempts to call this function after the most recent + // throttled call. If there were no more calls we don't have to call + // the function again. + if (blockedCalls > 0) { + func(...args); + blockedCalls = 0; + } + }; + + clearTimeout(timeout); + timeout = setTimeout(later, finalWait); + }; + + debounced.cancel = () => { + clearTimeout(timeout); + reset(); + }; + + /** @param {Args} args */ + debounced.immediate = (...args) => { + func(...args); + }; + + let wait = false; + /** + * @param {unknown} _request + * @param {Args} args + */ + const throttled = (_request, ...args) => { + if (!wait) { + func(...args); + debounced(...args); + + wait = true; + blockedCalls = 0; + + setTimeout(() => { + wait = false; + }, interval); + } else { + blockedCalls++; + } + }; + + return throttled; +}; + +/** @typedef {[string, number]} ChromsizeRow */ + +/** + * @typedef CumulativeChromsizeEntry + * @property {number} id + * @property {string} chr + * @property {number} pos + */ + +/** + * @typedef ParsedChromsizes + * @property {CumulativeChromsizeEntry[]} cumPositions + * @property {Record} chrPositions + * @property {number} totalLength + * @property {Record} chromLengths + */ + +/** + * Parse an array of chromsizes, for example that result from reading rows of a chromsizes CSV file. + * + * @param {ArrayLike} data - Array of [chrName, chrLen] "tuples". + * @returns {ParsedChromsizes} + */ +function parseChromsizesRows(data) { + /** @type {Array} */ + const cumValues = []; + /** @type {Record} */ + const chromLengths = {}; + /** @type {Record} */ + const chrPositions = {}; + + let totalLength = 0; + + for (let i = 0; i < data.length; i++) { + const length = Number(data[i][1]); + totalLength += length; + + const newValue = { + id: i, + chr: data[i][0], + pos: totalLength - length + }; + + cumValues.push(newValue); + chrPositions[newValue.chr] = newValue; + chromLengths[data[i][0]] = length; + } + + return { + cumPositions: cumValues, + chrPositions, + totalLength, + chromLengths + }; +} + +/** + * @template T + * @typedef DataTask + * @property {T} data + * @property {(data: T) => void} handler + * @property {string=} trackId + */ + +/** + * @typedef NullDataTask + * @property {null} data + * @property {() => void} handler + * @property {string=} trackId + */ + +/** @typedef {DataTask | NullDataTask} Task */ + +/** + * @param {Task} task + * @return {task is NullDataTask} + */ +function isNullDataTask(task) { + return task.data === null; +} + +/** + */ +class BackgroundTaskScheduler { + constructor() { + /** @type {Task[]} */ + this.taskList = []; + this.taskHandle = null; + this.requestIdleCallbackTimeout = 300; + } + + /** + * @template T + * @overload + * @param {(data: T) => void} taskHandler + * @param {T} taskData + * @param {string | null=} trackId + * @return {void} + */ + /** + * If `taskData` is `null` the `taskHandler` will eventaully be called without any arguments. + * + * @overload + * @param {() => void} taskHandler + * @param {null} taskData + * @param {string | null=} trackId + * @return {void} + */ + /** + * @param {(data: any) => void} taskHandler + * @param {any} taskData + * @param {string | null} trackId + * @return {void} + */ + enqueueTask(taskHandler, taskData, trackId = null) { + if (trackId === null) { + this.taskList.push({ + handler: taskHandler, + data: taskData + }); + } else { + // If a trackId is given we delete all previous tasks in the taskList of the same track + // We only want to rerender the latest version of a track + this.taskList = this.taskList.filter(task => task.trackId !== trackId); + + this.taskList.push({ + handler: taskHandler, + data: taskData, + trackId + }); + } + + if (!this.taskHandle) { + this.taskHandle = requestIdleCallback(this.runTaskQueue.bind(this), { + timeout: this.requestIdleCallbackTimeout + }); + } + } + + /** + * @param {{ timeRemaining(): number, didTimeout: boolean }} deadline + */ + runTaskQueue(deadline) { + while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && this.taskList.length) { + const task = this.taskList.shift(); + + if (task && isNullDataTask(task)) { + task.handler(); + } else if (task) { + task.handler(task.data); + } + } + + if (this.taskList.length) { + this.taskHandle = requestIdleCallback(this.runTaskQueue.bind(this), { + timeout: this.requestIdleCallbackTimeout + }); + } else { + this.taskHandle = 0; + } + } +} + +const backgroundTaskScheduler = new BackgroundTaskScheduler(); + +// @ts-nocheck + +/** + * Get a valueScale for a heatmap. + * + * If the scalingType isn't specified, then default to the defaultScaling. + * + * @param {string} scalingType: The type of the (e.g. 'linear', or 'log') + * @param {number} minValue: The minimum data value to which this scale will apply + * @param {number} pseudocount: A value to add to all numbers to prevent taking the log of 0 + * @param {number} maxValue: The maximum data value to which this scale will apply + * @param {string} defaultScaling: The default scaling type to use in case + * 'scalingType' is null (e.g. 'linear' or 'log') + * + * @returns {array} An array of [string, scale] containin the scale type + * and a scale with an appropriately set domain and range + */ +function getValueScale(scalingType, minValue, pseudocountIn, maxValue, defaultScaling) { + const scalingTypeToUse = scalingType || defaultScaling; + + // purposely set to not equal pseudocountIn for now + // eventually this will be an option + const pseudocount = 0; + + if (scalingTypeToUse === 'log' && minValue > 0) { + return [ + 'log', + scaleLog() + .range([254, 0]) + .domain([minValue + pseudocount, maxValue + pseudocount]) + ]; + } + + return ['linear', scaleLinear().range([254, 0]).domain([minValue, maxValue])]; +} + +class TiledPixiTrack extends PixiTrack { + /** + * A track that must pull remote tiles + * + * @param (PIXI.scene) scene A PIXI.js scene to draw everything to. + * @param (Object) dataConfig: A data source. Usually a + * ``{{server: 'x/api/v1/', tilesetUuid: 'y'}}`` Object. + * @param {Object} handleTilesetInfoReceived: A callback to do something once once the tileset + * info is received. Usually it registers some information about the tileset with its + * definition + * @param {Object} options The track's options + * @param {function} animate A function to redraw this track. Typically called when an + * asynchronous event occurs (i.e. tiles loaded) + * @param {function} onValueScaleChanged The range of values has changed so we need to inform + * the higher ups that the value scale has changed. Only occurs on tracks with ``dense`` data. + */ + constructor(context, options) { + super(context, options); + const { pubSub, dataConfig, handleTilesetInfoReceived, animate, onValueScaleChanged } = context; + + // keep track of which render we're on so that we save ourselves + // rerendering all rendering in the same version will have the same + // scaling so tiles rendered in the same version will have the same + // output. Mostly useful for heatmap tiles. + this.renderVersion = 1; + + // the tiles which should be visible (although they're not necessarily fetched) + this.visibleTiles = new Set(); + this.visibleTileIds = new Set(); + + // keep track of tiles that are currently being rendered + this.renderingTiles = new Set(); + + // the tiles we already have requests out for + this.fetching = new Set(); + this.scale = {}; + + // tiles we have fetched and ready to be rendered + this.fetchedTiles = {}; + + // the graphics that have already been drawn for this track + this.tileGraphics = {}; + + this.maxZoom = 0; + this.medianVisibleValue = null; + + this.backgroundTaskScheduler = backgroundTaskScheduler; + + // If the browser supports requestIdleCallback we use continuous + // instead of tile based scaling + this.continuousScaling = 'requestIdleCallback' in window; + + this.valueScaleMin = null; + this.fixedValueScaleMin = null; + this.valueScaleMax = null; + this.fixedValueScaleMax = null; + + this.listeners = {}; + + this.pubSub = pubSub; + this.animate = animate; + this.onValueScaleChanged = onValueScaleChanged; + + // store the server and tileset uid so they can be used in draw() + // if the tileset info is not found + this.prevValueScale = null; + + if (!context.dataFetcher) { + this.dataFetcher = new DataFetcher(dataConfig, this.pubSub); + } else { + this.dataFetcher = context.dataFetcher; + } + + // To indicate that this track is requiring a tileset info + this.tilesetInfo = null; + this.uuid = slugid.nice(); + + // this needs to be above the tilesetInfo() call because if that + // executes first, the call to draw() will complain that this text + // doesn't exist + this.trackNotFoundText = new GLOBALS.PIXI.Text('', { + fontSize: '12px', + fontFamily: 'Arial', + fill: 'black' + }); + + this.pLabel.addChild(this.trackNotFoundText); + + this.refreshTilesDebounced = throttleAndDebounce(this.refreshTiles.bind(this), ZOOM_DEBOUNCE, ZOOM_DEBOUNCE); + + this.dataFetcher.tilesetInfo((tilesetInfo, tilesetUid) => { + if (!tilesetInfo) return; + + this.tilesetInfo = tilesetInfo; + // If the dataConfig contained a fileUrl, then + // we need to update the tilesetUid based + // on the registration of the fileUrl. + if (!this.dataFetcher.dataConfig.tilesetUid) { + this.dataFetcher.dataConfig.tilesetUid = tilesetUid; + } + + this.tilesetUid = this.dataFetcher.dataConfig.tilesetUid; + this.server = this.dataFetcher.dataConfig.server || 'unknown'; + + if (this.tilesetInfo && this.tilesetInfo.chromsizes) { + this.chromInfo = parseChromsizesRows(this.tilesetInfo.chromsizes); + } + + if ('error' in this.tilesetInfo) { + // no tileset info for this track + console.warn('Error retrieving tilesetInfo:', dataConfig, this.tilesetInfo.error); + + // Fritz: Not sure why it's reset + // this.trackNotFoundText = ''; + this.tilesetInfo = null; + + this.setError(this.tilesetInfo.error); + return; + } + + if (this.tilesetInfo.resolutions) { + this.maxZoom = this.tilesetInfo.resolutions.length; + } else { + this.maxZoom = +this.tilesetInfo.max_zoom; + } + + if (this.options && this.options.maxZoom) { + if (this.options.maxZoom >= 0) { + this.maxZoom = Math.min(this.options.maxZoom, this.maxZoom); + } else { + console.error('Invalid maxZoom on track:', this); + } + } + + this.refreshTiles(); + + if (handleTilesetInfoReceived) handleTilesetInfoReceived(tilesetInfo); + + if (!this.options) this.options = {}; + + this.options.name = this.options.name || tilesetInfo.name; + + this.checkValueScaleLimits(); + + this.draw(); + this.drawLabel(); // draw the label so that the current resolution is displayed + this.animate(); + }); + } + + setError(error) { + this.errorTextText = error; + this.draw(); + this.animate(); + } + + setFixedValueScaleMin(value) { + if (!Number.isNaN(+value)) this.fixedValueScaleMin = +value; + else this.fixedValueScaleMin = null; + } + + setFixedValueScaleMax(value) { + if (!Number.isNaN(+value)) this.fixedValueScaleMax = +value; + else this.fixedValueScaleMax = null; + } + + checkValueScaleLimits() { + this.valueScaleMin = typeof this.options.valueScaleMin !== 'undefined' ? +this.options.valueScaleMin : null; + + if (this.fixedValueScaleMin !== null) { + this.valueScaleMin = this.fixedValueScaleMin; + } + + this.valueScaleMax = typeof this.options.valueScaleMax !== 'undefined' ? +this.options.valueScaleMax : null; + + if (this.fixedValueScaleMax !== null) { + this.valueScaleMax = this.fixedValueScaleMax; + } + } + + /** + * Register an event listener for track events. Currently, the only supported + * event is ``dataChanged``. + * + * @param {string} event The event to listen for + * @param {function} callback The callback to call when the event occurs. The + * parameters for the event depend on the event called. + * + * @example + * + * trackObj.on('dataChanged', (newData) => { + * console.log('newData:', newData) + * }); + */ + on(event, callback) { + if (!this.listeners[event]) { + this.listeners[event] = []; + } + + this.listeners[event].push(callback); + } + + off(event, callback) { + const id = this.listeners[event].indexOf(callback); + if (id === -1 || id >= this.listeners[event].length) return; + + this.listeners[event].splice(id, 1); + } + + rerender(options) { + super.rerender(options); + + this.renderVersion += 1; + + if (!this.tilesetInfo) { + return; + } + + this.checkValueScaleLimits(); + + if (this.tilesetInfo.resolutions) { + this.maxZoom = this.tilesetInfo.resolutions.length; + } else { + this.maxZoom = +this.tilesetInfo.max_zoom; + } + + if (this.options && this.options.maxZoom) { + if (this.options.maxZoom >= 0) { + this.maxZoom = Math.min(this.options.maxZoom, this.maxZoom); + } else { + console.error('Invalid maxZoom on track:', this); + } + } + } + + /** + * Return the set of ids of all tiles which are both visible and fetched. + */ + visibleAndFetchedIds() { + return Object.keys(this.fetchedTiles).filter(x => this.visibleTileIds.has(x)); + } + + visibleAndFetchedTiles() { + return this.visibleAndFetchedIds().map(x => this.fetchedTiles[x]); + } + + /** + * Set which tiles are visible right now. + * + * @param tiles: A set of tiles which will be considered the currently visible + * tile positions. + */ + setVisibleTiles(tilePositions) { + this.visibleTiles = tilePositions.map(x => ({ + tileId: this.tileToLocalId(x), + remoteId: this.tileToRemoteId(x), + mirrored: x.mirrored + })); + + this.visibleTileIds = new Set(this.visibleTiles.map(x => x.tileId)); + } + + removeOldTiles() { + this.calculateVisibleTiles(); + + // tiles that are fetched + const fetchedTileIDs = new Set(Object.keys(this.fetchedTiles)); + // + // calculate which tiles are obsolete and remove them + // fetchedTileID are remote ids + const toRemove = [...fetchedTileIDs].filter(x => !this.visibleTileIds.has(x)); + + this.removeTiles(toRemove); + } + + refreshTiles() { + if (!this.tilesetInfo) { + return; + } + + this.calculateVisibleTiles(); + + // tiles that are fetched + const fetchedTileIDs = new Set(Object.keys(this.fetchedTiles)); + + // fetch the tiles that should be visible but haven't been fetched + // and aren't in the process of being fetched + const toFetch = [...this.visibleTiles].filter( + x => !this.fetching.has(x.remoteId) && !fetchedTileIDs.has(x.tileId) + ); + + for (let i = 0; i < toFetch.length; i++) { + this.fetching.add(toFetch[i].remoteId); + } + + this.removeOldTiles(); + this.fetchNewTiles(toFetch); + } + + parentInFetched(tile) { + const uid = tile.tileData.tilesetUid; + let zl = tile.tileData.zoomLevel; + let pos = tile.tileData.tilePos; + + while (zl > 0) { + zl -= 1; + pos = pos.map(x => Math.floor(x / 2)); + + const parentId = `${uid}.${zl}.${pos.join('.')}`; + if (parentId in this.fetchedTiles) { + return true; + } + } + + return false; + } + + parentTileId(tile) { + const parentZoomLevel = tile.tileData.zoomLevel - 1; + const parentPos = tile.tileData.tilePos.map(x => Math.floor(x / 2)); + const parentUid = tile.tileData.tilesetUid; + + return `${parentUid}.${parentZoomLevel}.${parentPos.join('.')}`; + } + + /** + * Remove obsolete tiles + * + * @param toRemoveIds: An array of tile ids to remove from the list of fetched tiles. + */ + removeTiles(toRemoveIds) { + // if there's nothing to remove, don't bother doing anything + if (!toRemoveIds.length || !this.areAllVisibleTilesLoaded() || this.renderingTiles.size) { + return; + } + + toRemoveIds.forEach(x => { + const tileIdStr = x; + this.destroyTile(this.fetchedTiles[tileIdStr]); + + if (tileIdStr in this.tileGraphics) { + this.pMain.removeChild(this.tileGraphics[tileIdStr]); + delete this.tileGraphics[tileIdStr]; + } + + delete this.fetchedTiles[tileIdStr]; + }); + + this.synchronizeTilesAndGraphics(); + this.draw(); + } + + zoomed(newXScale, newYScale, k = 1, tx = 0, ty = 0) { + this.xScale(newXScale); + this.yScale(newYScale); + + this.refreshTilesDebounced(); + + this.pMobile.position.x = tx; + this.pMobile.position.y = this.position[1]; + + this.pMobile.scale.x = k; + this.pMobile.scale.y = 1; + } + + setPosition(newPosition) { + super.setPosition(newPosition); + + // this.draw(); + } + + setDimensions(newDimensions) { + super.setDimensions(newDimensions); + + // this.draw(); + } + + /** + * Check to see if all the visible tiles are loaded. + * + * If they are, remove all other tiles. + */ + areAllVisibleTilesLoaded() { + // tiles that are fetched + const fetchedTileIDs = new Set(Object.keys(this.fetchedTiles)); + + const visibleTileIdsList = [...this.visibleTileIds]; + + for (let i = 0; i < visibleTileIdsList.length; i++) { + if (!fetchedTileIDs.has(visibleTileIdsList[i])) { + return false; + } + } + + return true; + } + + /** + * Function is called when all tiles that should be visible have + * been received. + */ + allTilesLoaded() {} + + minValue(_) { + if (_) { + this.scale.minValue = _; + return this; + } + return this.valueScaleMin !== null ? this.valueScaleMin : this.scale.minValue; + } + + maxValue(_) { + if (_) { + this.scale.maxValue = _; + return this; + } + return this.valueScaleMax !== null ? this.valueScaleMax : this.scale.maxValue; + } + + minRawValue() { + // this is the minimum value from all the tiles that + // hasn't been externally modified by locked scales + return this.scale.minRawValue; + } + + maxRawValue() { + // this is the maximum value from all the tiles that + // hasn't been externally modified by locked scales + return this.scale.maxRawValue; + } + + initTile(/* tile */) { + // create the tile + // should be overwritten by child classes + this.scale.minRawValue = this.continuousScaling ? this.minVisibleValue() : this.minVisibleValueInTiles(); + this.scale.maxRawValue = this.continuousScaling ? this.maxVisibleValue() : this.maxVisibleValueInTiles(); + + this.scale.minValue = this.scale.minRawValue; + this.scale.maxValue = this.scale.maxRawValue; + } + + updateTile(/* tile */) {} + + destroyTile(/* tile */) { + // remove all data structures needed to draw this tile + } + + addMissingGraphics() { + /** + * Add graphics for tiles that have no graphics + */ + const fetchedTileIDs = Object.keys(this.fetchedTiles); + this.renderVersion += 1; + + for (let i = 0; i < fetchedTileIDs.length; i++) { + if (!(fetchedTileIDs[i] in this.tileGraphics)) { + // console.trace('adding:', fetchedTileIDs[i]); + + const newGraphics = new GLOBALS.PIXI.Graphics(); + this.pMain.addChild(newGraphics); + + this.fetchedTiles[fetchedTileIDs[i]].graphics = newGraphics; + this.initTile(this.fetchedTiles[fetchedTileIDs[i]]); + + this.tileGraphics[fetchedTileIDs[i]] = newGraphics; + } + } + + /* + if (added) + this.draw(); + */ + } + + /** + * Change the graphics for existing tiles + */ + updateExistingGraphics() { + const fetchedTileIDs = Object.keys(this.fetchedTiles); + + for (let i = 0; i < fetchedTileIDs.length; i++) { + const tile = this.fetchedTiles[fetchedTileIDs[i]]; + + this.updateTile(tile); + } + } + + synchronizeTilesAndGraphics() { + /** + * Make sure that we have a one to one mapping between tiles + * and graphics objects + * + */ + + // keep track of which tiles are visible at the moment + this.addMissingGraphics(); + this.removeOldTiles(); + this.updateExistingGraphics(); + + if (this.listeners.dataChanged) { + for (const callback of this.listeners.dataChanged) { + callback(this.visibleAndFetchedTiles().map(x => x.tileData)); + } + } + } + + loadTileData(tile, dataLoader) { + /** + * Extract drawable data from a tile loaded by a generic tile loader + * + * @param tile: A tile returned by a TiledArea. + * @param dataLoader: A function for extracting drawable data from a tile. This + * usually means differentiating the between dense and sparse + * tiles and putting the data into an array. + */ + + // see if the data is already cached + let loadedTileData = this.lruCache.get(tile.tileId); + + // if not, load it and put it in the cache + if (!loadedTileData) { + loadedTileData = dataLoader(tile.data, tile.type); + this.lruCache.put(tile.tileId, loadedTileData); + } + + return loadedTileData; + } + + fetchNewTiles(toFetch) { + if (toFetch.length > 0) { + const toFetchList = [...new Set(toFetch.map(x => x.remoteId))]; + + this.dataFetcher.fetchTilesDebounced(this.receivedTiles.bind(this), toFetchList); + } + } + + /** + * We've gotten a bunch of tiles from the server in + * response to a request from fetchTiles. + */ + receivedTiles(loadedTiles) { + for (let i = 0; i < this.visibleTiles.length; i++) { + const { tileId } = this.visibleTiles[i]; + + if (!loadedTiles[this.visibleTiles[i].remoteId]) continue; + + if (this.visibleTiles[i].remoteId in loadedTiles) { + if (!(tileId in this.fetchedTiles)) { + // this tile may have graphics associated with it + this.fetchedTiles[tileId] = this.visibleTiles[i]; + } + + // Fritz: Store a shallow copy. If necessary we perform a deep copy of + // the dense data in `tile-proxy.js :: tileDataToPixData()` + // Somehow 2d rectangular domain tiles do not come in the flavor of an + // object but an object array... + if (Array.isArray(loadedTiles[this.visibleTiles[i].remoteId])) { + const tileData = loadedTiles[this.visibleTiles[i].remoteId]; + this.fetchedTiles[tileId].tileData = [...tileData]; + // Fritz: this is sooo hacky... we should really not use object arrays + Object.keys(tileData) + .filter(key => Number.isNaN(+key)) + .forEach(key => { + this.fetchedTiles[tileId].tileData[key] = tileData[key]; + }); + } else { + this.fetchedTiles[tileId].tileData = { + ...loadedTiles[this.visibleTiles[i].remoteId] + }; + } + + if (this.fetchedTiles[tileId].tileData.error) { + console.warn('Error in loaded tile', tileId, this.fetchedTiles[tileId].tileData); + } + } + } + + // const fetchedTileIDs = new Set(Object.keys(this.fetchedTiles)); + + for (const key in loadedTiles) { + if (loadedTiles[key]) { + const tileId = loadedTiles[key].tilePositionId; + + if (this.fetching.has(tileId)) { + this.fetching.delete(tileId); + } + } + } + + /* + * Mainly called to remove old unnecessary tiles + */ + this.synchronizeTilesAndGraphics(); + + // we need to draw when we receive new data + this.draw(); + this.drawLabel(); // update the current zoom level + + // Let HiGlass know we need to re-render + // check if the value scale has changed + if (this.valueScale) { + if ( + !this.prevValueScale || + JSON.stringify(this.valueScale.domain()) !== JSON.stringify(this.prevValueScale.domain()) + ) { + this.prevValueScale = this.valueScale.copy(); + + if (this.onValueScaleChanged) { + // this is used to synchronize tracks with locked value scales + this.onValueScaleChanged(); + } + } + } + + this.animate(); + + // 1. Check if all visible tiles are loaded + // 2. If `true` then send out event + if (this.areAllVisibleTilesLoaded()) { + if (this.pubSub) { + this.pubSub.publish('TiledPixiTrack.tilesLoaded', { uuid: this.uuid }); + } + } + } + + draw() { + if (this.delayDrawing) return; + + if (!this.tilesetInfo) { + if (this.dataFetcher.tilesetInfoLoading) { + this.trackNotFoundText.text = 'Loading...'; + } else { + this.trackNotFoundText.text = `Tileset info not found. Server: [${this.server}] tilesetUid: [${this.tilesetUid}]`; + } + + [this.trackNotFoundText.x, this.trackNotFoundText.y] = this.position; + + if (this.flipText) { + this.trackNotFoundText.anchor.x = 1; + this.trackNotFoundText.scale.x = -1; + } + + this.trackNotFoundText.visible = true; + } else { + this.trackNotFoundText.visible = false; + } + + if (this.pubSub) { + this.pubSub.publish('TiledPixiTrack.tilesDrawnStart', { + uuid: this.uuid + }); + } + const errors = Object.values(this.fetchedTiles) + .map(x => x.tileData && x.tileData.error && `${x.tileId}: ${x.tileData.error}`) + .filter(x => x); + + if (errors.length) { + this.errorTextText = errors.join('\n'); + } else { + this.errorTextText = ''; + } + + super.draw(); + + Object.keys(this.fetchedTiles).forEach(tilesetUid => { + this.drawTile(this.fetchedTiles[tilesetUid]); + }); + // console.log('errors:', errors); + + if (this.pubSub) { + this.pubSub.publish('TiledPixiTrack.tilesDrawnEnd', { uuid: this.uuid }); + } + } + + /** + * Draw a tile on some graphics + */ + drawTile(/* tileData, graphics */) {} + + calculateMedianVisibleValue() { + if (this.areAllVisibleTilesLoaded()) { + this.allTilesLoaded(); + } + + let visibleAndFetchedIds = this.visibleAndFetchedIds(); + + if (visibleAndFetchedIds.length === 0) { + visibleAndFetchedIds = Object.keys(this.fetchedTiles); + } + + const values = [] + .concat( + ...visibleAndFetchedIds + .filter(x => this.fetchedTiles[x].tileData.dense) + .map(x => Array.from(this.fetchedTiles[x].tileData.dense)) + ) + .filter(x => x > 0); + + this.medianVisibleValue = median(values); + return this.medianVisibleValue; + } + + allVisibleValues() { + return [].concat(...this.visibleAndFetchedIds().map(x => Array.from(this.fetchedTiles[x].tileData.dense))); + } + + // Should be overwriten by child clases to get the true minimal + // visible value in the currently viewed area + minVisibleValue(ignoreFixedScale = false) { + return this.minVisibleValueInTiles(ignoreFixedScale); + } + + minVisibleValueInTiles(ignoreFixedScale = false) { + // Get minimum in currently visible tiles + let visibleAndFetchedIds = this.visibleAndFetchedIds(); + + if (visibleAndFetchedIds.length === 0) { + visibleAndFetchedIds = Object.keys(this.fetchedTiles); + } + + let min = Math.min(...visibleAndFetchedIds.map(x => this.fetchedTiles[x].tileData.minNonZero)); + + // if there's no data, use null + if (min === Number.MAX_SAFE_INTEGER) { + min = null; + } + + if (ignoreFixedScale) return min; + + return this.valueScaleMin !== null ? this.valueScaleMin : min; + } + + // Should be overwriten by child clases to get the true maximal + // visible value in the currently viewed area + maxVisibleValue(ignoreFixedScale = false) { + return this.maxVisibleValueInTiles(ignoreFixedScale); + } + + maxVisibleValueInTiles(ignoreFixedScale = false) { + // Get maximum in currently visible tiles + let visibleAndFetchedIds = this.visibleAndFetchedIds(); + + if (visibleAndFetchedIds.length === 0) { + visibleAndFetchedIds = Object.keys(this.fetchedTiles); + } + + let max = Math.max(...visibleAndFetchedIds.map(x => this.fetchedTiles[x].tileData.maxNonZero)); + + // if there's no data, use null + if (max === Number.MIN_SAFE_INTEGER) { + max = null; + } + + if (ignoreFixedScale) return max; + + return this.valueScaleMax !== null ? this.valueScaleMax : max; + } + + makeValueScale(minValue, medianValue, maxValue, inMargin) { + /* + * Create a value scale that will be used to position values + * along the y axis. + * + * Parameters + * ---------- + * minValue: number + * The minimum value of the data + * medianValue: number + * The median value of the data. Potentially used for adding + * a pseudocount + * maxValue: number + * The maximum value of the data + * margin: number + * A number of pixels to be left free on the top and bottom + * of the track. For example if the glyphs have a certain + * width and we want all of them to fit into the space. + * + * Returns + * ------- + * valueScale: d3.scale + * A d3 value scale + */ + let valueScale = null; + let offsetValue = 0; + + let margin = inMargin; + + if (margin === null || typeof margin === 'undefined') { + margin = 6; // set a default value + } + + let minDimension = Math.min(this.dimensions[1] - margin, margin); + let maxDimension = Math.max(this.dimensions[1] - margin, margin); + + if (this.dimensions[1] - margin < margin) { + // if the track becomes smaller than the margins, then just draw a flat + // line in the center + minDimension = this.dimensions[1] / 2; + maxDimension = this.dimensions[1] / 2; + } + + if (this.options.valueScaling === 'log') { + offsetValue = medianValue; + + if (!offsetValue) { + offsetValue = minValue; + } + + valueScale = scaleLog() + // .base(Math.E) + .domain([offsetValue, maxValue + offsetValue]) + // .domain([offsetValue, this.maxValue()]) + .range([minDimension, maxDimension]); + + // pseudocount = offsetValue; + } else if (this.options.valueScaling === 'quantile') { + const start = this.dimensions[1] - margin; + const end = margin; + const quantScale = scaleQuantile() + .domain(this.allVisibleValues()) + .range(range(start, end, (end - start) / 256)); + quantScale.ticks = n => ticks(start, end, n); + + return [quantScale, 0]; + } else if (this.options.valueScaling === 'setquantile') { + const start = this.dimensions[1] - margin; + const end = margin; + const s = new Set(this.allVisibleValues()); + const quantScale = scaleQuantile() + .domain([...s]) + .range(range(start, end, (end - start) / 256)); + quantScale.ticks = n => ticks(start, end, n); + + return [quantScale, 0]; + } else { + // linear scale + valueScale = scaleLinear().domain([minValue, maxValue]).range([maxDimension, minDimension]); + } + + return [valueScale, offsetValue]; + } +} + +// @ts-nocheck + +class SVGTrack extends Track { + constructor(context, options) { + super(context, options); + const { svgElement } = context; + /** + * Create a new SVG track. It will contain a g element + * that maintains all of its element. + */ + this.gMain = select(svgElement).append('g'); + this.clipUid = slugid.nice(); + + this.clipRect = this.gMain.append('clipPath').attr('id', `track-bounds-${this.clipUid}`).append('rect'); + + this.gMain.attr('clip-path', `url(#track-bounds-${this.clipUid})`); + } + + setPosition(newPosition) { + this.position = newPosition; + + this.gMain.attr('transform', `translate(${this.position[0]},${this.position[1]})`); + this.draw(); + } + + setDimensions(newDimensions) { + this.dimensions = newDimensions; + + this._xScale.range([0, this.dimensions[0]]); + this._yScale.range([0, this.dimensions[1]]); + + if (newDimensions[0] >= 0 && newDimensions[1] >= 0) { + this.clipRect.attr('width', newDimensions[0]); + this.clipRect.attr('height', newDimensions[1]); + } else { + this.clipRect.attr('width', 0); + this.clipRect.attr('height', 0); + } + + this.draw(); + } + + remove() { + this.gMain.remove(); + this.gMain = null; + } + + draw() { + return this; + } +} + +// @ts-nocheck + +class ViewportTrackerHorizontal extends SVGTrack { + constructor(context, options) { + // create a clipped SVG Path + super(context, options); + const { registerViewportChanged, removeViewportChanged, setDomainsCallback } = context; + + const uid = slugid.nice(); + this.uid = uid; + this.options = options; + + // Is there actually a linked _from_ view? Or is this projection "independent"? + this.hasFromView = !context.projectionXDomain; + + this.removeViewportChanged = removeViewportChanged; + this.setDomainsCallback = setDomainsCallback; + + this.viewportXDomain = this.hasFromView ? null : context.projectionXDomain; + this.viewportYDomain = this.hasFromView ? null : [0, 0]; + + this.brush = brushX().on('brush', this.brushed.bind(this)); + + this.gBrush = this.gMain.append('g').attr('id', `brush-${this.uid}`).call(this.brush); + + // turn off the ability to select new regions for this brush + this.gBrush.selectAll('.overlay').style('pointer-events', 'none'); + + // turn off the ability to modify the aspect ratio of the brush + this.gBrush.selectAll('.handle--ne').style('pointer-events', 'none'); + + this.gBrush.selectAll('.handle--nw').style('pointer-events', 'none'); + + this.gBrush.selectAll('.handle--sw').style('pointer-events', 'none'); + + this.gBrush.selectAll('.handle--se').style('pointer-events', 'none'); + + this.gBrush.selectAll('.handle--n').style('pointer-events', 'none'); + + this.gBrush.selectAll('.handle--s').style('pointer-events', 'none'); + + // the viewport will call this.viewportChanged immediately upon + // hearing registerViewportChanged + registerViewportChanged(uid, this.viewportChanged.bind(this)); + + this.rerender(); + this.draw(); + } + + brushed(event) { + /** + * Should only be called on active brushing, not in response to the + * draw event + */ + const s = event.selection; + + if (!this._xScale || !this._yScale) { + return; + } + + const xDomain = [this._xScale.invert(s[0]), this._xScale.invert(s[1])]; + + const yDomain = this.viewportYDomain; + + if (!this.hasFromView) { + this.viewportXDomain = xDomain; + } + + // console.log('xDomain:', xDomain); + // console.log('yDomain:', yDomain); + + this.setDomainsCallback(xDomain, yDomain); + } + + viewportChanged(viewportXScale, viewportYScale, update = true) { + // console.log('viewport changed:', viewportXScale.domain()); + const viewportXDomain = viewportXScale.domain(); + const viewportYDomain = viewportYScale.domain(); + + this.viewportXDomain = viewportXDomain; + this.viewportYDomain = viewportYDomain; + + this.draw(); + } + + remove() { + // remove the event handler that updates this viewport tracker + this.removeViewportChanged(this.uid); + + super.remove(); + } + + rerender() { + // set the fill and stroke colors + this.gBrush + .selectAll('.selection') + .attr('fill', this.options.projectionFillColor) + .attr('stroke', this.options.projectionStrokeColor) + .attr('fill-opacity', this.options.projectionFillOpacity) + .attr('stroke-opacity', this.options.projectionStrokeOpacity) + .attr('stroke-width', this.options.strokeWidth); + } + + draw() { + if (!this._xScale || !this.yScale) { + return; + } + + if (!this.viewportXDomain || !this.viewportYDomain) { + return; + } + + const x0 = this._xScale(this.viewportXDomain[0]); + const x1 = this._xScale(this.viewportXDomain[1]); + + const dest = [x0, x1]; + + // console.log('dest:', dest[0], dest[1]); + + // user hasn't actively brushed so we don't want to emit a + // 'brushed' event + this.brush.on('brush', null); + this.gBrush.call(this.brush.move, dest); + this.brush.on('brush', this.brushed.bind(this)); + } + + zoomed(newXScale, newYScale) { + this.xScale(newXScale); + this.yScale(newYScale); + + this.draw(); + } + + setPosition(newPosition) { + super.setPosition(newPosition); + + this.draw(); + } + + setDimensions(newDimensions) { + super.setDimensions(newDimensions); + + const xRange = this._xScale.range(); + const yRange = this._yScale.range(); + const xDiff = xRange[1] - xRange[0]; + + this.brush.extent([ + [xRange[0] - xDiff, yRange[0]], + [xRange[1] + xDiff, yRange[1]] + ]); + this.gBrush.call(this.brush); + + this.draw(); + } +} + +/** + * Convert a regular color value (e.g. 'red', '#FF0000', 'rgb(255,0,0)') to a + * RGBA array, with support for the value "transparent". + * + * @param {string} colorValue - An RGB(A) color value to convert. + * @return {[r: number, g: number, b: number, a: number]} An RGBA array. + */ +const colorToRgba = colorValue => { + if (colorValue === 'transparent') { + return [255, 255, 255, 0]; + } + /** @type {import('d3-color').RGBColor} */ + // @ts-expect-error - FIXME: `color` can return many different types + // depending on the string input. We should probably use a different + // the more strict `rgb` function instead? + const c = color(colorValue); + return [c.r, c.g, c.b, 255]; +}; + +const chromInfoBisector = bisector((/** @type {{ pos: number }} */ d) => d.pos).left; + +/** + * @template {string} Name + * @typedef {[name: Name, pos: number, offset: number, insertPoint: number ]} ChromosomePosition + */ + +/** + * Convert an absolute genome position to a chromosome position. + * @template {string} Name + * @param {number} absPosition - Absolute genome position. + * @param {import('../types').ChromInfo} chromInfo - Chromosome info object. + * @return {ChromosomePosition | null} The chromosome position. + */ +const absToChr = (absPosition, chromInfo) => { + if (!chromInfo || !chromInfo.cumPositions || !chromInfo.cumPositions.length) { + return null; + } + + let insertPoint = chromInfoBisector(chromInfo.cumPositions, absPosition); + const lastChr = chromInfo.cumPositions[chromInfo.cumPositions.length - 1].chr; + const lastLength = chromInfo.chromLengths[lastChr]; + + if (insertPoint > 0) { + insertPoint -= 1; + } + + let chrPosition = Math.floor(absPosition - chromInfo.cumPositions[insertPoint].pos); + let offset = 0; + + if (chrPosition < 0) { + // before the start of the genome + offset = chrPosition - 1; + chrPosition = 1; + } + + if (insertPoint === chromInfo.cumPositions.length - 1 && chrPosition > lastLength) { + // beyond the last chromosome + offset = chrPosition - lastLength; + chrPosition = lastLength; + } + + return [chromInfo.cumPositions[insertPoint].chr, chrPosition, offset, insertPoint]; +}; + +// @ts-nocheck + +/** + * Convert a color domain to a 255 element array of [r,g,b,a] + * values (all from 0 to 255). The last color (255) will always be + * transparent + */ +const colorDomainToRgbaArray = (colorRange, noTansparent = false) => { + // we should always have at least two values in the color range + const domain = colorRange.map((x, i) => i * (255 / (colorRange.length - 1))); + + const d3Scale = scaleLinear().domain(domain).range(colorRange); + + const fromX = noTansparent ? 255 : 254; + + const rgbaArray = range(fromX, -1, -1) + .map(d3Scale) + .map(x => { + const r = rgb(x); + return [r.r, r.g, r.b, r.opacity * 255]; + }); + + // add a transparent color at the end for missing values and, more + // importantly, non-existing values such as the empty upper right or lower + // left triangle of tiles on the diagonal. + if (rgbaArray.length < 256) rgbaArray.push([255, 255, 255, 0]); + + return rgbaArray; +}; + +/** + * Download a file to the user's computer. + * @param {string} filename - Name of the file to download + * @param {string | Blob} stringOrBlob - Contents of the file to download + */ +function download(filename, stringOrBlob) { + // yanked from here + // https://stackoverflow.com/questions/3665115/create-a-file-in-memory-for-user-to-download-not-through-server + + const blob = + typeof stringOrBlob === 'string' + ? new Blob([stringOrBlob], { type: 'application/octet-stream' }) + : stringOrBlob; + const elem = window.document.createElement('a'); + elem.href = window.URL.createObjectURL(blob); + elem.download = filename; + document.body.appendChild(elem); + elem.click(); + document.body.removeChild(elem); + URL.revokeObjectURL(elem.href); +} + +// @ts-nocheck +const ndarrayAssign = (target, source) => { + const numSource = +source; + const isScalar = !Number.isNaN(numSource); + + if (isScalar) { + if (target.dimension === 1) { + for (let i = 0; i < target.shape[0]; ++i) { + target.set(i, numSource); + } + } else { + for (let i = 0; i < target.shape[0]; ++i) { + for (let j = 0; j < target.shape[1]; ++j) { + target.set(i, j, numSource); + } + } + } + } else { + const ty = target.shape[0]; + const tx = target.shape[1]; + const sy = source.shape[0]; + const sx = source.shape[1]; + + if (ty !== sy || tx !== sx) { + console.warn('Cannot assign source to target ndarray as the dimensions do not match', ty, sy, tx, sx); + return; + } + + if (target.dimension === 1) { + for (let i = 0; i < target.shape[0]; ++i) { + target.set(i, source.get(i)); + } + } else { + for (let i = 0; i < target.shape[0]; ++i) { + for (let j = 0; j < target.shape[1]; ++j) { + target.set(i, j, source.get(i, j)); + } + } + } + } +}; + +// @ts-nocheck +const ndarrayToList = arr => { + const size = arr.shape.reduce((s, x) => s * x, 1); + const list = new Array(size); + + if (arr.dimension === 1) { + let l = 0; + for (let i = 0; i < arr.shape[0]; ++i) { + list[l] = arr.get(i); + l++; + } + } else { + let l = 0; + for (let i = 0; i < arr.shape[0]; ++i) { + for (let j = 0; j < arr.shape[1]; ++j) { + list[l] = arr.get(i, j); + l++; + } + } + } + + return list; +}; + +// @ts-nocheck + +const ndarrayFlatten = arr => { + if (arr.shape.length === 1) return arr; + + return ndarray(ndarrayToList(arr)); +}; + +/** + * Exposed map function. You can do cool stuff with that! + * + * @description + * The pure map function is more powerful because it can be used on data types + * other than Array too. + * + * @template T, B + * @param {(item: T, idx?: number) => B} f - Mapping function. + * @return {(x: Array) => Array} Mapped array. + */ +// @ts-expect-error - TS can't infer the type of the returned function. +const map = f => x => Array.prototype.map.call(x, f); + +// @ts-nocheck + +/** + * Convert an object into array which entries are the prop values of the object + * + * @param {Object} obj - Object to be arrayified + * @return {Array} Array of the object. + */ +const objVals = obj => map(key => obj[key])(Object.keys(obj)); + +/** + * Convert a HEX string into a HEX integer. + * + * @example + * ```js + * // returns 16711680 + * hexStrToInt("#FF0000"); + * ``` + * + * @param {string} str - HEX string + * @return {number} An (integer) HEX number + */ +const hexStrToInt = str => parseInt(str.replace(/^#/, ''), 16); + +const COLOR = 0xaaaaaa; +const ALPHA = 1.0; + +/** + * @typedef MouseTrackOptions + * @property {string=} mousePositionColor - Color of the mouse position. + * @property {number=} mousePositionAlpha - Alpha of the mouse position. + */ + +/** + * Actual interface for initializing to show the mouse location + * + * @param {import('pub-sub-es').PubSub} pubSub - PubSub service. + * @param {Array} pubSubs - Subscribed PubSub events. + * @param {MouseTrackOptions} options - Track options. + * @param {() => [import('../types').Scale, import('../types').Scale]} getScales - Getter for the track's X and Y scales. + * @param {() => [number, number]} getPosition - Getter for the track's position. + * @param {() => [number, number]} getDimensions - Getter for the track's dimensions. + * @param {() => boolean} getIsFlipped - Getter determining if a track has been + * flipped from horizontal to vertical. + * @param {boolean} is2d - If `true` draw both dimensions of the mouse location. + * @param {boolean} isGlobal - If `true` local and global events will trigger + * the mouse position drawing. + * @return {import('pixi.js').Graphics} - PIXI graphics the mouse location is drawn on. + */ +const showMousePosition = ( + pubSub, + pubSubs, + options, + getScales, + getPosition, + getDimensions, + getIsFlipped, + is2d, + isGlobal +) => { + pubSub.publish('app.animateOnMouseMove', true); + + const color = options.mousePositionColor ? hexStrToInt(options.mousePositionColor) : COLOR; + + const alpha = options.mousePositionAlpha || ALPHA; + + // Graphics for cursor position + const graphics = new GLOBALS.PIXI.Graphics(); + + // This clears the mouse position graphics, i.e., the mouse position will not + // be visible afterwards. + const clearGraphics = () => { + graphics.clear(); + }; + + /** + * Draw 1D mouse location (cross) hair onto the PIXI graphics. + * + * @param {number} mousePos - One dimension of the mouse location (integer). + * @param {boolean=} isHorizontal - If `true` the dimension to be drawn is + * horizontal. + * @param {boolean=} isNoClear If `true` do not clear the graphics. + * @return {void} + */ + const drawMousePosition = (mousePos, isHorizontal, isNoClear) => { + if (!isNoClear) clearGraphics(); + + graphics.lineStyle(1, color, alpha); + + if (isHorizontal) { + const addition = is2d ? getPosition()[0] : 0; + graphics.moveTo(0, mousePos); + graphics.lineTo(getDimensions()[0] + addition, mousePos); + } else { + const addition = is2d ? getPosition()[1] : 0; + graphics.moveTo(mousePos, 0); + graphics.lineTo(mousePos, getDimensions()[1] + addition); + } + }; + + /** + * @typedef NoHoveredTracksEvent + * @property {true} noHoveredTracks - If `true` no tracks are hovered. + * @property {false=} isFromVerticalTrack - If `true` the event is from a vertical track. + */ + + /** + * @typedef TrackEvent + * @property {false=} noHoveredTracks - If `true` no tracks are hovered. + * @property {boolean} isFromVerticalTrack - If `true` the event is from a vertical track. + * @property {boolean} isFrom2dTrack - If `true` the event is from a 2D track. + * @property {number} dataY - Y position of the mouse. + * @property {number} dataX - X position of the mouse. + */ + + /** + * Mouse move handler + * + * @param {Event & (NoHoveredTracksEvent | TrackEvent)} event - Event object. + */ + const mouseMoveHandler = event => { + if (event.noHoveredTracks) { + clearGraphics(); + return graphics; + } + + let x; + let y; + if (event.isFromVerticalTrack) { + x = event.dataY; + y = event.dataY; + } else { + x = event.dataX; + y = event.isFrom2dTrack ? event.dataY : event.dataX; + } + + // 2d or central tracks are not offset and rather rely on a mask, i.e., the + // top left *visible* position is *not* [0,0] but given by `getPosition()`. + const offset = is2d ? getPosition() : [0, 0]; + + // `getIsFlipped()` is `true` when a horizontal track has been flipped by 90 + // degree, i.e., is a vertical track. + const mousePos = getIsFlipped() ? getScales()[0](y) + offset[1] : getScales()[0](x) + offset[0]; + + drawMousePosition(mousePos); + + // Also draw the second dimension + if (is2d) drawMousePosition(getScales()[1](y) + offset[1], true, true); + + return graphics; + }; + + pubSubs.push(pubSub.subscribe('app.mouseMove', mouseMoveHandler)); + pubSubs.push(pubSub.subscribe('app.mouseLeave', clearGraphics)); + pubSubs.push(pubSub.subscribe('blur', clearGraphics)); + + if (isGlobal) { + pubSubs.push(globalPubSub.subscribe('higlass.mouseMove', mouseMoveHandler)); + } + + return graphics; +}; + +/** + * @typedef ClassContext + * @property {import('pixi.js').Container=} pForeground + * @property {import('pixi.js').Container=} pMasked + * @property {import('pixi.js').Container=} pMain + * @property {() => import('../types').Scale} xScale + * @property {() => import('../types').Scale} yScale + * @property {() => [number, number]} getPosition + * @property {() => [number, number]} getDimensions + * @property {import('pub-sub-es').PubSub} pubSub + * @property {Array} pubSubs + * @property {(prop: 'flipText') => () => boolean} getProp + * @property {{}} options + */ + +/** + * Public API for showing the mouse location. + * + * @description + * This is just a convenience wrapper to avoid code duplication. + * `showMousePosition` is the actual function and could be called from within + * each class as well. + * + * @param {ClassContext} context - Class context, i.e., `this`. + * @param {Boolean} is2d - If `true` both dimensions of the mouse location + * should be shown. E.g., on a central track. + * @param {Boolean} isGlobal - If `true` local and global events will trigger + * the mouse position drawing. + * @return {Function} - Method to remove graphics showing the mouse location. + */ +const setupShowMousePosition = (context, is2d = false, isGlobal = false) => { + const scene = is2d ? context.pMasked : context.pForeground || context.pMain; + if (!scene) { + throw new Error( + 'setupShowMousePosition: No scene found. Please make sure to call this method after the scene has been initialized.' + ); + } + /** @type {() => [import('../types').Scale, import('../types').Scale]} */ + const getScales = () => [context.xScale(), context.yScale()]; + + const graphics = showMousePosition( + context.pubSub, + context.pubSubs, + context.options, + getScales, + context.getPosition.bind(context), + context.getDimensions.bind(context), + context.getProp('flipText'), + is2d, + isGlobal + ); + + scene.addChild(graphics); + + return () => { + scene.removeChild(graphics); + }; +}; + +/** + * Factory function for a value to RGB color converter + * + * @template T + * @param {(value: number) => number} valueScale - Value scaling function. + * @param {Array} colorScale - Color scale array. + * @param {number} pseudoCounts - Pseudo counts used as a pseudocount to prevent taking the log of 0. + * @param {number} eps - Epsilon. + * @return {(value: number) => T} RGB color array. + */ +const valueToColor = + (valueScale, colorScale, pseudoCounts = 0, eps = 0.000001) => + value => { + let rgbIdx = 255; + + if (value > eps) { + // values less than espilon are considered NaNs and made transparent + // (rgbIdx 255) + rgbIdx = Math.max(0, Math.min(255, Math.floor(valueScale(value + pseudoCounts)))); + } + + return colorScale[rgbIdx]; + }; + +// @ts-nocheck + +const THEME_DARK = Symbol('Dark theme'); + +// @ts-nocheck + +const TICK_HEIGHT = 40; +const TICK_MARGIN = 0; +const TICK_LENGTH = 5; +const TICK_LABEL_MARGIN = 4; + +class AxisPixi { + constructor(track) { + this.pAxis = new GLOBALS.PIXI.Graphics(); + this.track = track; + + this.axisTexts = []; + this.axisTextFontFamily = 'Arial'; + this.axisTextFontSize = 10; + } + + startAxis(axisHeight) { + const graphics = this.pAxis; + + graphics.clear(); + graphics.lineStyle(1, this.track.getTheme() === THEME_DARK ? colorToHex('#ffffff') : 0x000000, 1); + + // draw the axis line + graphics.moveTo(0, 0); + graphics.lineTo(0, axisHeight); + } + + createAxisTexts(valueScale, axisHeight) { + this.tickValues = this.calculateAxisTickValues(valueScale, axisHeight); + let i = 0; + + const color = this.track.getTheme() === THEME_DARK ? 'white' : 'black'; + + if ( + !this.track.options || + !this.track.options.axisLabelFormatting || + this.track.options.axisLabelFormatting === 'scientific' + ) { + this.tickFormat = format('.2'); + } else { + this.tickFormat = x => x; + } + + while (i < this.tickValues.length) { + const tick = this.tickValues[i]; + + while (this.axisTexts.length <= i) { + const newText = new GLOBALS.PIXI.Text(tick, { + fontSize: `${this.axisTextFontSize}px`, + fontFamily: this.axisTextFontFamily, + fill: color + }); + this.axisTexts.push(newText); + + this.pAxis.addChild(newText); + } + + this.axisTexts[i].text = this.tickFormat(tick); + this.axisTexts[i].anchor.y = 0.5; + this.axisTexts[i].anchor.x = 0.5; + i++; + } + + while (this.axisTexts.length > this.tickValues.length) { + const lastText = this.axisTexts.pop(); + this.pAxis.removeChild(lastText); + lastText.destroy(true); + } + } + + calculateAxisTickValues(valueScale, axisHeight) { + const tickCount = Math.max(Math.ceil(axisHeight / TICK_HEIGHT), 1); + + // create scale ticks but not all the way to the top + // tick values have not been formatted here + let tickValues = valueScale.ticks(tickCount); + + if (tickValues.length < 1) { + tickValues = valueScale.ticks(tickCount + 1); + + if (tickValues.length > 1) { + // sometimes the ticks function will return 0 and then 2 + // if it didn't return enough previously, we probably only want a single + // tick + tickValues = [tickValues[0]]; + } + } + + return tickValues; + } + + drawAxisLeft(valueScale, axisHeight) { + // Draw a left-oriented axis (ticks pointing to the right) + this.startAxis(axisHeight); + this.createAxisTexts(valueScale, axisHeight); + + const graphics = this.pAxis; + + if (this.track.getTheme() === THEME_DARK) { + graphics.lineStyle(graphics.lineWidth || graphics._lineStyle.width, colorToHex('#ffffff')); + } + + // draw the top, potentially unlabelled, ticke + graphics.moveTo(0, 0); + graphics.lineTo(-(TICK_MARGIN + TICK_LENGTH), 0); + + graphics.moveTo(0, axisHeight); + graphics.lineTo(-(TICK_MARGIN + TICK_LENGTH), axisHeight); + + for (let i = 0; i < this.axisTexts.length; i++) { + const tick = this.tickValues[i]; + + // draw ticks to the left of the axis + this.axisTexts[i].x = -(TICK_MARGIN + TICK_LENGTH + TICK_LABEL_MARGIN + this.axisTexts[i].width / 2); + this.axisTexts[i].y = valueScale(tick); + + graphics.moveTo(-TICK_MARGIN, valueScale(tick)); + graphics.lineTo(-(TICK_MARGIN + TICK_LENGTH), valueScale(tick)); + + if (this.track && this.track.flipText) { + this.axisTexts[i].scale.x = -1; + } + } + + this.hideOverlappingAxisLabels(); + } + + drawAxisRight(valueScale, axisHeight) { + // Draw a right-oriented axis (ticks pointint to the left) + this.startAxis(axisHeight); + this.createAxisTexts(valueScale, axisHeight); + + const graphics = this.pAxis; + + if (this.track.getTheme() === THEME_DARK) { + graphics.lineStyle(graphics.lineWidth || graphics._lineStyle.width, colorToHex('#ffffff')); + } + + // draw the top, potentially unlabelled, ticke + graphics.moveTo(0, 0); + graphics.lineTo(TICK_MARGIN + TICK_LENGTH, 0); + + graphics.moveTo(0, axisHeight); + graphics.lineTo(TICK_MARGIN + TICK_LENGTH, axisHeight); + + for (let i = 0; i < this.axisTexts.length; i++) { + const tick = this.tickValues[i]; + + this.axisTexts[i].x = TICK_MARGIN + TICK_LENGTH + TICK_LABEL_MARGIN + this.axisTexts[i].width / 2; + this.axisTexts[i].y = valueScale(tick); + + graphics.moveTo(TICK_MARGIN, valueScale(tick)); + graphics.lineTo(TICK_MARGIN + TICK_LENGTH, valueScale(tick)); + + if (this.track && this.track.flipText) { + this.axisTexts[i].scale.x = -1; + } + } + + this.hideOverlappingAxisLabels(); + } + + hideOverlappingAxisLabels() { + // show all tick marks initially + for (let i = this.axisTexts.length - 1; i >= 0; i--) { + this.axisTexts[i].visible = true; + } + + for (let i = this.axisTexts.length - 1; i >= 0; i--) { + // if this tick mark is invisible, it's not going to + // overlap with any others + if (!this.axisTexts[i].visible) { + continue; + } + + let j = i - 1; + + while (j >= 0) { + // go through and hide all overlapping tick marks + if ( + this.axisTexts[i].y + this.axisTexts[i].height / 2 > + this.axisTexts[j].y - this.axisTexts[j].height / 2 + ) { + this.axisTexts[j].visible = false; + } else { + // because the tick marks are ordered from top to bottom, if this + // one doesn't overlap, then the ones below it won't either, so + // we can stop looking + break; + } + + j -= 1; + } + } + } + + exportVerticalAxis(axisHeight) { + const gAxis = document.createElement('g'); + gAxis.setAttribute('class', 'axis-vertical'); + + let stroke = 'black'; + + if (this.track && this.track.options.lineStrokeColor) { + stroke = this.track.options.lineStrokeColor; + } + // TODO: On the canvas, there is no vertical line beside the scale, + // but it also has the draggable control to the right. + // Confirm that this difference between SVG and Canvas is intentional, + // and if not, remove this. + if (this.track.getTheme() === THEME_DARK) stroke = '#cccccc'; + + const line = document.createElement('path'); + + line.setAttribute('fill', 'transparent'); + line.setAttribute('stroke', stroke); + line.setAttribute('id', 'axis-line'); + + line.setAttribute('d', `M0,0 L0,${axisHeight}`); + + gAxis.appendChild(line); + + return gAxis; + } + + createAxisSVGLine() { + // factor out the styling for axis lines + let stroke = 'black'; + + if (this.track && this.track.options.lineStrokeColor) { + stroke = this.track.options.lineStrokeColor; + } + + if (this.track.getTheme() === THEME_DARK) stroke = '#cccccc'; + + const line = document.createElement('path'); + line.setAttribute('id', 'tick-mark'); + line.setAttribute('fill', 'transparent'); + line.setAttribute('stroke', stroke); + + return line; + } + + createAxisSVGText(text) { + // factor out the creation of axis texts + const t = document.createElement('text'); + + t.innerHTML = text; + t.setAttribute('id', 'axis-text'); + t.setAttribute('text-anchor', 'middle'); + t.setAttribute('font-family', this.axisTextFontFamily); + t.setAttribute('font-size', this.axisTextFontSize); + t.setAttribute('dy', this.axisTextFontSize / 2 - 2); + + return t; + } + + exportAxisLeftSVG(valueScale, axisHeight) { + const gAxis = this.exportVerticalAxis(axisHeight); + + const topTickLine = this.createAxisSVGLine(); + gAxis.appendChild(topTickLine); + topTickLine.setAttribute('d', `M0,0 L${+(TICK_MARGIN + TICK_LENGTH)},0`); + + const bottomTickLine = this.createAxisSVGLine(); + gAxis.appendChild(bottomTickLine); + bottomTickLine.setAttribute('d', `M0,${axisHeight} L${+(TICK_MARGIN + TICK_LENGTH)},${axisHeight}`); + + for (let i = 0; i < this.axisTexts.length; i++) { + const tick = this.tickValues[i]; + const text = this.axisTexts[i]; + + const tickLine = this.createAxisSVGLine(); + + gAxis.appendChild(tickLine); + + tickLine.setAttribute( + 'd', + `M${+TICK_MARGIN},${valueScale(tick)} L${+(TICK_MARGIN + TICK_LENGTH)},${valueScale(tick)}` + ); + + const g = document.createElement('g'); + gAxis.appendChild(g); + if (text.visible) { + const t = this.createAxisSVGText(text.text); + g.appendChild(t); + } + + g.setAttribute( + 'transform', + `translate(${text.position.x},${text.position.y}) + scale(${text.scale.x},${text.scale.y})` + ); + } + + return gAxis; + } + + exportAxisRightSVG(valueScale, axisHeight) { + const gAxis = this.exportVerticalAxis(axisHeight); + + const topTickLine = this.createAxisSVGLine(); + gAxis.appendChild(topTickLine); + topTickLine.setAttribute('d', `M0,0 L${-(TICK_MARGIN + TICK_LENGTH)},0`); + + const bottomTickLine = this.createAxisSVGLine(); + gAxis.appendChild(bottomTickLine); + bottomTickLine.setAttribute('d', `M0,${axisHeight} L${-(TICK_MARGIN + TICK_LENGTH)},${axisHeight}`); + + for (let i = 0; i < this.axisTexts.length; i++) { + const tick = this.tickValues[i]; + const text = this.axisTexts[i]; + + const tickLine = this.createAxisSVGLine(); + + gAxis.appendChild(tickLine); + + tickLine.setAttribute( + 'd', + `M${-TICK_MARGIN},${valueScale(tick)} L${-(TICK_MARGIN + TICK_LENGTH)},${valueScale(tick)}` + ); + + const g = document.createElement('g'); + gAxis.appendChild(g); + + if (text.visible) { + const t = this.createAxisSVGText(text.text); + g.appendChild(t); + } + + g.setAttribute( + 'transform', + `translate(${text.position.x},${text.position.y}) + scale(${text.scale.x},${text.scale.y})` + ); + } + + return gAxis; + } + + clearAxis() { + const graphics = this.pAxis; + while (this.axisTexts.length) { + const axisText = this.axisTexts.pop(); + graphics.removeChild(axisText); + } + + graphics.clear(); + } +} + +// @ts-nocheck +const HEATED_OBJECT_MAP = [ + [0, 0, 0, 255], + [35, 0, 0, 255], + [52, 0, 0, 255], + [60, 0, 0, 255], + [63, 1, 0, 255], + [64, 2, 0, 255], + [68, 5, 0, 255], + [69, 6, 0, 255], + [72, 8, 0, 255], + [74, 10, 0, 255], + [77, 12, 0, 255], + [78, 14, 0, 255], + [81, 16, 0, 255], + [83, 17, 0, 255], + [85, 19, 0, 255], + [86, 20, 0, 255], + [89, 22, 0, 255], + [91, 24, 0, 255], + [92, 25, 0, 255], + [94, 26, 0, 255], + [95, 28, 0, 255], + [98, 30, 0, 255], + [100, 31, 0, 255], + [102, 33, 0, 255], + [103, 34, 0, 255], + [105, 35, 0, 255], + [106, 36, 0, 255], + [108, 38, 0, 255], + [109, 39, 0, 255], + [111, 40, 0, 255], + [112, 42, 0, 255], + [114, 43, 0, 255], + [115, 44, 0, 255], + [117, 45, 0, 255], + [119, 47, 0, 255], + [119, 47, 0, 255], + [120, 48, 0, 255], + [122, 49, 0, 255], + [123, 51, 0, 255], + [125, 52, 0, 255], + [125, 52, 0, 255], + [126, 53, 0, 255], + [128, 54, 0, 255], + [129, 56, 0, 255], + [129, 56, 0, 255], + [131, 57, 0, 255], + [132, 58, 0, 255], + [134, 59, 0, 255], + [134, 59, 0, 255], + [136, 61, 0, 255], + [137, 62, 0, 255], + [137, 62, 0, 255], + [139, 63, 0, 255], + [139, 63, 0, 255], + [140, 65, 0, 255], + [142, 66, 0, 255], + [142, 66, 0, 255], + [143, 67, 0, 255], + [143, 67, 0, 255], + [145, 68, 0, 255], + [145, 68, 0, 255], + [146, 70, 0, 255], + [146, 70, 0, 255], + [148, 71, 0, 255], + [148, 71, 0, 255], + [149, 72, 0, 255], + [149, 72, 0, 255], + [151, 73, 0, 255], + [151, 73, 0, 255], + [153, 75, 0, 255], + [153, 75, 0, 255], + [154, 76, 0, 255], + [154, 76, 0, 255], + [154, 76, 0, 255], + [156, 77, 0, 255], + [156, 77, 0, 255], + [157, 79, 0, 255], + [157, 79, 0, 255], + [159, 80, 0, 255], + [159, 80, 0, 255], + [159, 80, 0, 255], + [160, 81, 0, 255], + [160, 81, 0, 255], + [162, 82, 0, 255], + [162, 82, 0, 255], + [163, 84, 0, 255], + [163, 84, 0, 255], + [165, 85, 0, 255], + [165, 85, 0, 255], + [166, 86, 0, 255], + [166, 86, 0, 255], + [166, 86, 0, 255], + [168, 87, 0, 255], + [168, 87, 0, 255], + [170, 89, 0, 255], + [170, 89, 0, 255], + [171, 90, 0, 255], + [171, 90, 0, 255], + [173, 91, 0, 255], + [173, 91, 0, 255], + [174, 93, 0, 255], + [174, 93, 0, 255], + [176, 94, 0, 255], + [176, 94, 0, 255], + [177, 95, 0, 255], + [177, 95, 0, 255], + [179, 96, 0, 255], + [179, 96, 0, 255], + [180, 98, 0, 255], + [182, 99, 0, 255], + [182, 99, 0, 255], + [183, 100, 0, 255], + [183, 100, 0, 255], + [185, 102, 0, 255], + [185, 102, 0, 255], + [187, 103, 0, 255], + [187, 103, 0, 255], + [188, 104, 0, 255], + [188, 104, 0, 255], + [190, 105, 0, 255], + [191, 107, 0, 255], + [191, 107, 0, 255], + [193, 108, 0, 255], + [193, 108, 0, 255], + [194, 109, 0, 255], + [196, 110, 0, 255], + [196, 110, 0, 255], + [197, 112, 0, 255], + [197, 112, 0, 255], + [199, 113, 0, 255], + [200, 114, 0, 255], + [200, 114, 0, 255], + [202, 116, 0, 255], + [202, 116, 0, 255], + [204, 117, 0, 255], + [205, 118, 0, 255], + [205, 118, 0, 255], + [207, 119, 0, 255], + [208, 121, 0, 255], + [208, 121, 0, 255], + [210, 122, 0, 255], + [211, 123, 0, 255], + [211, 123, 0, 255], + [213, 124, 0, 255], + [214, 126, 0, 255], + [214, 126, 0, 255], + [216, 127, 0, 255], + [217, 128, 0, 255], + [217, 128, 0, 255], + [219, 130, 0, 255], + [221, 131, 0, 255], + [221, 131, 0, 255], + [222, 132, 0, 255], + [224, 133, 0, 255], + [224, 133, 0, 255], + [225, 135, 0, 255], + [227, 136, 0, 255], + [227, 136, 0, 255], + [228, 137, 0, 255], + [230, 138, 0, 255], + [230, 138, 0, 255], + [231, 140, 0, 255], + [233, 141, 0, 255], + [233, 141, 0, 255], + [234, 142, 0, 255], + [236, 144, 0, 255], + [236, 144, 0, 255], + [238, 145, 0, 255], + [239, 146, 0, 255], + [241, 147, 0, 255], + [241, 147, 0, 255], + [242, 149, 0, 255], + [244, 150, 0, 255], + [244, 150, 0, 255], + [245, 151, 0, 255], + [247, 153, 0, 255], + [247, 153, 0, 255], + [248, 154, 0, 255], + [250, 155, 0, 255], + [251, 156, 0, 255], + [251, 156, 0, 255], + [253, 158, 0, 255], + [255, 159, 0, 255], + [255, 159, 0, 255], + [255, 160, 0, 255], + [255, 161, 0, 255], + [255, 163, 0, 255], + [255, 163, 0, 255], + [255, 164, 0, 255], + [255, 165, 0, 255], + [255, 167, 0, 255], + [255, 167, 0, 255], + [255, 168, 0, 255], + [255, 169, 0, 255], + [255, 169, 0, 255], + [255, 170, 0, 255], + [255, 172, 0, 255], + [255, 173, 0, 255], + [255, 173, 0, 255], + [255, 174, 0, 255], + [255, 175, 0, 255], + [255, 177, 0, 255], + [255, 178, 0, 255], + [255, 179, 0, 255], + [255, 181, 0, 255], + [255, 181, 0, 255], + [255, 182, 0, 255], + [255, 183, 0, 255], + [255, 184, 0, 255], + [255, 187, 7, 255], + [255, 188, 10, 255], + [255, 189, 14, 255], + [255, 191, 18, 255], + [255, 192, 21, 255], + [255, 193, 25, 255], + [255, 195, 29, 255], + [255, 197, 36, 255], + [255, 198, 40, 255], + [255, 200, 43, 255], + [255, 202, 51, 255], + [255, 204, 54, 255], + [255, 206, 61, 255], + [255, 207, 65, 255], + [255, 210, 72, 255], + [255, 211, 76, 255], + [255, 214, 83, 255], + [255, 216, 91, 255], + [255, 219, 98, 255], + [255, 221, 105, 255], + [255, 223, 109, 255], + [255, 225, 116, 255], + [255, 228, 123, 255], + [255, 232, 134, 255], + [255, 234, 142, 255], + [255, 237, 149, 255], + [255, 239, 156, 255], + [255, 240, 160, 255], + [255, 243, 167, 255], + [255, 246, 174, 255], + [255, 248, 182, 255], + [255, 249, 185, 255], + [255, 252, 193, 255], + [255, 253, 196, 255], + [255, 255, 204, 255], + [255, 255, 207, 255], + [255, 255, 211, 255], + [255, 255, 218, 255], + [255, 255, 222, 255], + [255, 255, 225, 255], + [255, 255, 229, 255], + [255, 255, 233, 255], + [255, 255, 236, 255], + [255, 255, 240, 255], + [255, 255, 244, 255], + [255, 255, 247, 255], + [255, 255, 255, 0] +]; + +// @ts-nocheck + +const COLORBAR_MAX_HEIGHT = 200; +const COLORBAR_WIDTH = 10; +const COLORBAR_LABELS_WIDTH = 40; +const COLORBAR_MARGIN = 10; +const BRUSH_WIDTH = COLORBAR_MARGIN; +const BRUSH_HEIGHT = 4; +const BRUSH_COLORBAR_GAP = 1; +const BRUSH_MARGIN = 4; +const SCALE_LIMIT_PRECISION = 5; +const BINS_PER_TILE = 256; +const COLORBAR_AREA_WIDTH = + COLORBAR_WIDTH + COLORBAR_LABELS_WIDTH + COLORBAR_MARGIN + BRUSH_COLORBAR_GAP + BRUSH_WIDTH + BRUSH_MARGIN; + +class HeatmapTiledPixiTrack extends TiledPixiTrack { + constructor(context, options) { + // Fritz: this smells very hacky! + const newContext = { ...context }; + newContext.onValueScaleChanged = () => { + context.onValueScaleChanged(); + this.drawColorbar(); + }; + super(newContext, options); + const { + pubSub, + animate, + svgElement, + onTrackOptionsChanged, + onMouseMoveZoom, + isShowGlobalMousePosition, + isValueScaleLocked + } = context; + + this.pubSub = pubSub; + this.is2d = true; + this.animate = animate; + this.uid = slugid.nice(); + this.scaleBrush = brushY(); + + this.onTrackOptionsChanged = onTrackOptionsChanged; + this.isShowGlobalMousePosition = isShowGlobalMousePosition; + + this.isValueScaleLocked = isValueScaleLocked; + + // Graphics for drawing the colorbar + this.pColorbarArea = new GLOBALS.PIXI.Graphics(); + this.pMasked.addChild(this.pColorbarArea); + + this.pColorbar = new GLOBALS.PIXI.Graphics(); + this.pColorbarArea.addChild(this.pColorbar); + + this.axis = new AxisPixi(this); + this.pColorbarArea.addChild(this.axis.pAxis); + + // [[255,255,255,0], [237,218,10,4] ... + // a 256 element array mapping the values 0-255 to rgba values + // not a d3 color scale for speed + this.colorScale = HEATED_OBJECT_MAP; + + if (options && options.colorRange) { + this.colorScale = colorDomainToRgbaArray(options.colorRange); + } + + this.gBase = select(svgElement).append('g'); + this.gMain = this.gBase.append('g'); + this.gColorscaleBrush = this.gMain.append('g'); + + this.brushing = false; + this.prevOptions = ''; + + // Contains information about which part of the upper left tile is visible + this.prevIndUpperLeftTile = ''; + + /* + chromInfoService + .get(`${dataConfig.server}/chrom-sizes/?id=${dataConfig.tilesetUid}`) + .then((chromInfo) => { this.chromInfo = chromInfo; }); + */ + + this.onMouseMoveZoom = onMouseMoveZoom; + this.setDataLensSize(11); + this.dataLens = new Float32Array(this.dataLensSize ** 2); + + this.mouseMoveHandlerBound = this.mouseMoveHandler.bind(this); + + if (this.onMouseMoveZoom) { + this.pubSubs.push(this.pubSub.subscribe('app.mouseMove', this.mouseMoveHandlerBound)); + } + + if (this.options && this.options.showMousePosition && !this.hideMousePosition) { + this.hideMousePosition = setupShowMousePosition(this, this.is2d, this.isShowGlobalMousePosition()); + } + + this.prevOptions = JSON.stringify(options); + } + + /** + * Mouse move handler + * + * @param {Object} e Event object. + */ + mouseMoveHandler(e) { + if (!this.isWithin(e.x, e.y)) return; + + this.mouseX = e.x; + this.mouseY = e.y; + + this.mouseMoveZoomHandler(); + } + + /** + * Mouse move and zoom handler. Is triggered on both events. + * + * @param {Number} absX Absolute X coordinate. + * @param {Number} absY Absolute Y coordinate + */ + mouseMoveZoomHandler(absX = this.mouseX, absY = this.mouseY) { + if (typeof absX === 'undefined' || typeof absY === 'undefined' || !this.areAllVisibleTilesLoaded()) return; + + if (!this.tilesetInfo) { + return; + } + + const relX = absX - this.position[0]; + const relY = absY - this.position[1]; + + let data; + let dataLens; + try { + dataLens = this.getVisibleRectangleData( + relX - this.dataLensPadding, + relY - this.dataLensPadding, + this.dataLensSize, + this.dataLensSize + ); + // The center value + data = dataLens.get(this.dataLensPadding, this.dataLensPadding); + } catch (e) { + return; + } + + const dim = this.dataLensSize; + + let toRgb; + try { + toRgb = valueToColor(this.limitedValueScale, this.colorScale, this.valueScale.domain()[0]); + } catch (err) { + return; + } + + if (!toRgb) return; + + const dataX = Math.round(this._xScale.invert(relX)); + const dataY = Math.round(this._yScale.invert(relY)); + + let center = [dataX, dataY]; + let xRange = [ + Math.round(this._xScale.invert(relX - this.dataLensPadding)), + Math.round(this._xScale.invert(relX + this.dataLensPadding)) + ]; + let yRange = [ + Math.round(this._yScale.invert(relY - this.dataLensPadding)), + Math.round(this._yScale.invert(relY + this.dataLensPadding)) + ]; + + if (this.chromInfo) { + center = center.map(pos => absToChr(pos, this.chromInfo).slice(0, 2)); + xRange = xRange.map(pos => absToChr(pos, this.chromInfo).slice(0, 2)); + yRange = yRange.map(pos => absToChr(pos, this.chromInfo).slice(0, 2)); + } + + this.onMouseMoveZoom({ + trackId: this.id, + data, + absX, + absY, + relX, + relY, + dataX, + dataY, + orientation: '2d', + // Specific to 2D matrices + dataLens, + dim, + toRgb, + center, + xRange, + yRange, + isGenomicCoords: !!this.chromInfo + }); + } + + scheduleRerender() { + this.backgroundTaskScheduler.enqueueTask(this.handleRerender.bind(this), null, this.uuid); + } + + handleRerender() { + this.rerender(this.options, true); + } + + /** + * Get absolute (i.e., display) tile dimension and position. + * + * @param {Number} zoomLevel Current zoom level. + * @param {Array} tilePos Tile position. + * @return {Object} Object holding the absolute x, y, width, and height. + */ + getAbsTileDim(zoomLevel, tilePos, mirrored) { + const { tileX, tileY, tileWidth, tileHeight } = this.getTilePosAndDimensions(zoomLevel, tilePos); + + const dim = {}; + + dim.width = this._refXScale(tileX + tileWidth) - this._refXScale(tileX); + dim.height = this._refYScale(tileY + tileHeight) - this._refYScale(tileY); + + if (mirrored) { + // this is a mirrored tile that represents the other half of a + // triangular matrix + dim.x = this._refXScale(tileY); + dim.y = this._refYScale(tileX); + } else { + dim.x = this._refXScale(tileX); + dim.y = this._refYScale(tileY); + } + + return dim; + } + + updateValueScale() { + let minValue = this.minValue(); + let maxValue = this.maxValue(); + + // There might be only one value in the visible area. We extend the + // valuescale artificially, so that point is still displayed + const epsilon = 1e-6; + if ( + minValue !== undefined && + minValue !== null && + maxValue !== undefined && + maxValue !== null && + Math.abs(minValue - maxValue) < epsilon + ) { + // don't go to or below 0 in case there is a log scale + const offset = 1e-3; + minValue = Math.max(epsilon, minValue - offset); + maxValue += offset; + } + + const [scaleType, valueScale] = getValueScale( + (this.options && this.options.heatmapValueScaling) || 'log', + minValue, + this.medianVisibleValue, + maxValue, + 'log' + ); + + this.valueScale = valueScale; + + this.limitedValueScale = this.valueScale.copy(); + + if ( + this.options && + typeof this.options.scaleStartPercent !== 'undefined' && + typeof this.options.scaleEndPercent !== 'undefined' + ) { + this.limitedValueScale.domain([ + this.valueScale.domain()[0] + + (this.valueScale.domain()[1] - this.valueScale.domain()[0]) * this.options.scaleStartPercent, + this.valueScale.domain()[0] + + (this.valueScale.domain()[1] - this.valueScale.domain()[0]) * this.options.scaleEndPercent + ]); + } + + return [scaleType, valueScale]; + } + + rerender(options, force) { + super.rerender(options, force); + + // We need to update the value scale prior to updating the colorbar + this.updateValueScale(); + + // if force is set, then we force a rerender even if the options + // haven't changed rerender will force a brush.move + const strOptions = JSON.stringify(options); + this.drawColorbar(); + + if (!force && strOptions === this.prevOptions) return; + + this.prevOptions = strOptions; + this.options = options; + + super.rerender(options, force); + + // the normalization method may have changed + this.calculateVisibleTiles(); + + if (options && options.colorRange) { + this.colorScale = colorDomainToRgbaArray(options.colorRange); + } + + this.visibleAndFetchedTiles().forEach(tile => this.renderTile(tile)); + + // hopefully draw isn't rerendering all the tiles + // this.drawColorbar(); + + if (this.hideMousePosition) { + this.hideMousePosition(); + this.hideMousePosition = undefined; + } + + if (this.options && this.options.showMousePosition && !this.hideMousePosition) { + this.hideMousePosition = setupShowMousePosition(this, this.is2d, this.isShowGlobalMousePosition()); + } + } + + drawLabel() { + if (this.options.labelPosition === this.options.colorbarPosition) { + this.labelXOffset = COLORBAR_AREA_WIDTH; + } else { + this.labelXOffset = 0; + } + + super.drawLabel(); + } + + tileDataToCanvas(pixData) { + const canvas = document.createElement('canvas'); + + canvas.width = this.binsPerTile(); + canvas.height = this.binsPerTile(); + + const ctx = canvas.getContext('2d'); + + ctx.fillStyle = 'transparent'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const pix = new ImageData(pixData, canvas.width, canvas.height); + + ctx.putImageData(pix, 0, 0); + + return canvas; + } + + exportData() { + if (this.tilesetInfo) { + // const currentResolution = tileProxy.calculateResolution(this.tilesetInfo, + // this.zoomLevel); + + // const pixelsWidth = (this._xScale.domain()[1] - this._xScale.domain()[0]) + // / currentResolution; + // const pixelsHeight = (this._yScale.domain()[1] - this._yScale.domain()[0]) + // / currentResolution; + + const data = this.getVisibleRectangleData(0, 0, this.dimensions[0], this.dimensions[1]); + const output = { + bounds: [this._xScale.domain(), this._yScale.domain()], + dimensions: data.shape, + data: ndarrayFlatten(data) + }; + + download('data.json', JSON.stringify(output)); + } + } + + /** + * Position sprite (the rendered tile) + * + * @param {Object} sprite PIXI sprite object. + * @param {Number} zoomLevel Current zoom level. + * @param {Array} tilePos X,Y position of tile. + * @param {Boolean} mirrored If `true` tile is mirrored. + */ + setSpriteProperties(sprite, zoomLevel, tilePos, mirrored) { + const dim = this.getAbsTileDim(zoomLevel, tilePos, mirrored); + + sprite.width = dim.width; + sprite.height = dim.height; + sprite.x = dim.x; + sprite.y = dim.y; + + if (mirrored && tilePos[0] !== tilePos[1]) { + // sprite.pivot = [this._refXScale()[1] / 2, this._refYScale()[1] / 2]; + + // I think PIXIv3 used a different method to set the pivot value + // because the code above no longer works as of v4 + sprite.rotation = -Math.PI / 2; + sprite.scale.x = Math.abs(sprite.scale.x) * -1; + } + } + + refXScale(_) { + super.refXScale(_); + + this.draw(); + } + + refYScale(_) { + super.refYScale(_); + + this.draw(); + } + + draw() { + super.draw(); + + // this.drawColorbar(); + } + + newBrushOptions(selection) { + const newOptions = JSON.parse(JSON.stringify(this.options)); + + const axisValueScale = this.valueScale.copy().range([this.colorbarHeight, 0]); + + const endDomain = axisValueScale.invert(selection[0]); + const startDomain = axisValueScale.invert(selection[1]); + + // Fritz: I am disabling ESLint here twice because moving the slash onto the + // next line breaks my editors style template somehow. + const startPercent = + (startDomain - axisValueScale.domain()[0]) / // eslint-disable-line operator-linebreak + (axisValueScale.domain()[1] - axisValueScale.domain()[0]); + const endPercent = + (endDomain - axisValueScale.domain()[0]) / // eslint-disable-line operator-linebreak + (axisValueScale.domain()[1] - axisValueScale.domain()[0]); + + newOptions.scaleStartPercent = startPercent.toFixed(SCALE_LIMIT_PRECISION); + newOptions.scaleEndPercent = endPercent.toFixed(SCALE_LIMIT_PRECISION); + + return newOptions; + } + + brushStart() { + this.brushing = true; + } + + brushMoved(event) { + if (!event.selection) { + return; + } + const newOptions = this.newBrushOptions(event.selection); + + const strOptions = JSON.stringify(newOptions); + + this.gColorscaleBrush + .selectAll('.handle--custom') + .attr('y', d => (d.type === 'n' ? event.selection[0] : event.selection[1] - BRUSH_HEIGHT / 2)); + + if (strOptions === this.prevOptions) return; + + this.prevOptions = strOptions; + + // force a rerender because we've already set prevOptions + // to the new options + // this is necessary for when value scales are synced between + // tracks + this.rerender(newOptions, true); + + this.onTrackOptionsChanged(newOptions); + + if (this.isValueScaleLocked()) { + this.onValueScaleChanged(); + } + } + + brushEnd() { + // let newOptions = this.newBrushOptions(event.selection); + + // this.rerender(newOptions); + // this.animate(); + this.brushing = false; + } + + setPosition(newPosition) { + super.setPosition(newPosition); + + this.drawColorbar(); + } + + setDimensions(newDimensions) { + super.setDimensions(newDimensions); + + this.drawColorbar(); + } + + removeColorbar() { + this.pColorbarArea.visible = false; + + if (this.scaleBrush.on('.brush')) { + this.gColorscaleBrush.call(this.scaleBrush.move, null); + } + + // turn off the color scale brush + this.gColorscaleBrush.on('.brush', null); + this.gColorscaleBrush.selectAll('rect').remove(); + } + + drawColorbar() { + this.pColorbar.clear(); + // console.trace('draw colorbar'); + + if (!this.options || !this.options.colorbarPosition || this.options.colorbarPosition === 'hidden') { + this.removeColorbar(); + + return; + } + + this.pColorbarArea.visible = true; + + if (!this.valueScale) { + return; + } + + if (Number.isNaN(+this.valueScale.domain()[0]) || Number.isNaN(+this.valueScale.domain()[1])) { + return; + } + + const colorbarAreaHeight = Math.min(this.dimensions[1] / 2, COLORBAR_MAX_HEIGHT); + this.colorbarHeight = colorbarAreaHeight - 2 * COLORBAR_MARGIN; + + // no point in drawing the colorbar if it's not going to be visible + if (this.colorbarHeight < 0) { + // turn off the color scale brush + this.removeColorbar(); + return; + } + + if (this.valueScale.domain()[1] === this.valueScale.domain()[0]) { + // degenerate color bar + this.removeColorbar(); + return; + } + + const axisValueScale = this.valueScale.copy().range([this.colorbarHeight, 0]); + + // this.scaleBrush = brushY(); + + // this is to make the handles of the scale brush stick out away + // from the colorbar + if (this.options.colorbarPosition === 'topLeft' || this.options.colorbarPosition === 'bottomLeft') { + this.scaleBrush.extent([ + [BRUSH_MARGIN, 0], + [BRUSH_WIDTH, this.colorbarHeight] + ]); + } else { + this.scaleBrush.extent([ + [0, 0], + [BRUSH_WIDTH - BRUSH_MARGIN, this.colorbarHeight] + ]); + } + + if (this.options.colorbarPosition === 'topLeft') { + // draw the background for the colorbar + [this.pColorbarArea.x, this.pColorbarArea.y] = this.position; + + this.pColorbar.y = COLORBAR_MARGIN; + this.axis.pAxis.y = COLORBAR_MARGIN; + + this.axis.pAxis.x = BRUSH_MARGIN + BRUSH_WIDTH + BRUSH_COLORBAR_GAP + COLORBAR_WIDTH; + this.pColorbar.x = BRUSH_MARGIN + BRUSH_WIDTH + BRUSH_COLORBAR_GAP; + + this.gColorscaleBrush.attr( + 'transform', + `translate(${this.pColorbarArea.x + BRUSH_MARGIN},${this.pColorbarArea.y + this.pColorbar.y - 1})` + ); + } + + if (this.options.colorbarPosition === 'topRight') { + // draw the background for the colorbar + this.pColorbarArea.x = this.position[0] + this.dimensions[0] - COLORBAR_AREA_WIDTH; + this.pColorbarArea.y = this.position[1]; + + this.pColorbar.y = COLORBAR_MARGIN; + this.axis.pAxis.y = COLORBAR_MARGIN; + + // default to 'inside' + this.axis.pAxis.x = COLORBAR_LABELS_WIDTH + COLORBAR_MARGIN; + + this.pColorbar.x = COLORBAR_LABELS_WIDTH + COLORBAR_MARGIN; + + this.gColorscaleBrush.attr( + 'transform', + `translate(${this.pColorbarArea.x + this.pColorbar.x + COLORBAR_WIDTH + 2},${ + this.pColorbarArea.y + this.pColorbar.y - 1 + })` + ); + } + + if (this.options.colorbarPosition === 'bottomRight') { + this.pColorbarArea.x = this.position[0] + this.dimensions[0] - COLORBAR_AREA_WIDTH; + this.pColorbarArea.y = this.position[1] + this.dimensions[1] - colorbarAreaHeight; + + this.pColorbar.y = COLORBAR_MARGIN; + this.axis.pAxis.y = COLORBAR_MARGIN; + + // default to "inside" + this.axis.pAxis.x = COLORBAR_LABELS_WIDTH + COLORBAR_MARGIN; + this.pColorbar.x = COLORBAR_LABELS_WIDTH + COLORBAR_MARGIN; + + this.gColorscaleBrush.attr( + 'transform', + `translate(${this.pColorbarArea.x + this.pColorbar.x + COLORBAR_WIDTH + BRUSH_COLORBAR_GAP},${ + this.pColorbarArea.y + this.pColorbar.y - 1 + })` + ); + } + + if (this.options.colorbarPosition === 'bottomLeft') { + this.pColorbarArea.x = this.position[0]; + this.pColorbarArea.y = this.position[1] + this.dimensions[1] - colorbarAreaHeight; + + this.pColorbar.y = COLORBAR_MARGIN; + this.axis.pAxis.y = COLORBAR_MARGIN; + + // default to "inside" + this.axis.pAxis.x = BRUSH_MARGIN + BRUSH_WIDTH + BRUSH_COLORBAR_GAP + COLORBAR_WIDTH; + this.pColorbar.x = BRUSH_MARGIN + BRUSH_WIDTH + BRUSH_COLORBAR_GAP; + + this.gColorscaleBrush.attr( + 'transform', + `translate(${this.pColorbarArea.x + 2},${this.pColorbarArea.y + this.pColorbar.y - 1})` + ); + } + + this.pColorbarArea.clear(); + this.pColorbarArea.beginFill( + colorToHex(this.options.colorbarBackgroundColor || 'white'), + +this.options.colorbarBackgroundOpacity >= 0 ? +this.options.colorbarBackgroundOpacity : 0.6 + ); + this.pColorbarArea.drawRect(0, 0, COLORBAR_AREA_WIDTH, colorbarAreaHeight); + + if (!this.options) { + this.options = { scaleStartPercent: 0, scaleEndPercent: 1 }; + } else { + if (!this.options.scaleStartPercent) { + this.options.scaleStartPercent = 0; + } + if (!this.options.scaleEndPercent) { + this.options.scaleEndPercent = 1; + } + } + + const domainWidth = axisValueScale.domain()[1] - axisValueScale.domain()[0]; + + const startBrush = axisValueScale(this.options.scaleStartPercent * domainWidth + axisValueScale.domain()[0]); + const endBrush = axisValueScale(this.options.scaleEndPercent * domainWidth + axisValueScale.domain()[0]); + + // endBrush and startBrush are reversed because lower values come first + // only set if the user isn't brushing at the moment + if (!this.brushing) { + this.scaleBrush + .on('start', this.brushStart.bind(this)) + .on('brush', this.brushMoved.bind(this)) + .on('end', this.brushEnd.bind(this)) + .handleSize(0); + + this.gColorscaleBrush.on('.brush', null); + this.gColorscaleBrush.call(this.scaleBrush); + + this.northHandle = this.gColorscaleBrush + .selectAll('.handle--custom') + .data([{ type: 'n' }, { type: 's' }]) + .enter() + .append('rect') + .classed('handle--custom', true) + .attr('cursor', 'ns-resize') + .attr('width', BRUSH_WIDTH) + .attr('height', BRUSH_HEIGHT) + .style('fill', '#666') + .style('stroke', 'white'); + + if (this.flipText) { + this.northHandle.attr('cursor', 'ew-resize'); + } + + this.gColorscaleBrush.call(this.scaleBrush.move, [endBrush, startBrush]); + } + + const posScale = scaleLinear().domain([0, 255]).range([0, this.colorbarHeight]); + + // draw a small rectangle for each color of the colorbar + for (let i = 0; i < this.colorbarHeight; i++) { + const value = this.limitedValueScale(axisValueScale.invert(i)); + + const rgbIdx = Math.max(0, Math.min(254, Math.floor(value))); + + this.pColorbar.beginFill( + colorToHex( + `rgb(${this.colorScale[rgbIdx][0]},${this.colorScale[rgbIdx][1]},${this.colorScale[rgbIdx][2]})` + ) + ); + + // each rectangle in the colorbar will be one pixel high + this.pColorbar.drawRect(0, i, COLORBAR_WIDTH, 1); + } + + // draw an axis on the right side of the colorbar + this.pAxis.position.x = COLORBAR_WIDTH; + this.pAxis.position.y = posScale(0); + + if (this.options.colorbarPosition === 'topLeft' || this.options.colorbarPosition === 'bottomLeft') { + this.axis.drawAxisRight(axisValueScale, this.colorbarHeight); + } else if (this.options.colorbarPosition === 'topRight' || this.options.colorbarPosition === 'bottomRight') { + this.axis.drawAxisLeft(axisValueScale, this.colorbarHeight); + } + } + + exportColorBarSVG() { + const gColorbarArea = document.createElement('g'); + gColorbarArea.setAttribute('class', 'color-bar'); + + if (!this.options.colorbarPosition || this.options.colorbarPosition === 'hidden') { + // if there's no visible colorbar, we don't need to export anything + return gColorbarArea; + } + + // no value scale, no colorbar + if (!this.valueScale) return gColorbarArea; + + gColorbarArea.setAttribute('transform', `translate(${this.pColorbarArea.x}, ${this.pColorbarArea.y})`); + + const rectColorbarArea = document.createElement('rect'); + gColorbarArea.appendChild(rectColorbarArea); + + const gColorbar = document.createElement('g'); + gColorbarArea.appendChild(gColorbar); + + gColorbar.setAttribute('transform', `translate(${this.pColorbar.x}, ${this.pColorbar.y})`); + + const colorbarAreaHeight = Math.min(this.dimensions[1] / 2, COLORBAR_MAX_HEIGHT); + this.colorbarHeight = colorbarAreaHeight - 2 * COLORBAR_MARGIN; + + rectColorbarArea.setAttribute('x', 0); + rectColorbarArea.setAttribute('y', 0); + rectColorbarArea.setAttribute('width', COLORBAR_AREA_WIDTH); + rectColorbarArea.setAttribute('height', colorbarAreaHeight); + rectColorbarArea.setAttribute('style', 'fill: white; stroke-width: 0; opacity: 0.7'); + + const barsToDraw = 256; + const posScale = scaleLinear() + .domain([0, barsToDraw - 1]) + .range([0, this.colorbarHeight]); + const colorHeight = this.colorbarHeight / barsToDraw; + + for (let i = 0; i < barsToDraw; i++) { + const rectColor = document.createElement('rect'); + gColorbar.appendChild(rectColor); + + rectColor.setAttribute('x', 0); + rectColor.setAttribute('y', posScale(i)); + rectColor.setAttribute('width', COLORBAR_WIDTH); + rectColor.setAttribute('height', colorHeight); + rectColor.setAttribute('class', 'color-rect'); + + const limitedIndex = Math.min( + this.colorScale.length - 1, + Math.max(0, Math.floor(this.limitedValueScale(this.valueScale.invert(i)))) + ); + + const color = this.colorScale[limitedIndex]; + if (color) { + rectColor.setAttribute('style', `fill: rgb(${color[0]}, ${color[1]}, ${color[2]})`); + } else { + // when no tiles are loaded, color will be undefined and we don't want to crash + rectColor.setAttribute('style', `fill: rgb(255,255,255,0)`); + } + } + + const gAxisHolder = document.createElement('g'); + gColorbarArea.appendChild(gAxisHolder); + gAxisHolder.setAttribute('transform', `translate(${this.axis.pAxis.position.x},${this.axis.pAxis.position.y})`); + + let gAxis = null; + + const axisValueScale = this.valueScale.copy().range([this.colorbarHeight, 0]); + if (this.options.colorbarPosition === 'topLeft' || this.options.colorbarPosition === 'bottomLeft') { + gAxis = this.axis.exportAxisRightSVG(axisValueScale, this.colorbarHeight); + } else if (this.options.colorbarPosition === 'topRight' || this.options.colorbarPosition === 'bottomRight') { + gAxis = this.axis.exportAxisLeftSVG(axisValueScale, this.colorbarHeight); + } + + gAxisHolder.appendChild(gAxis); + + return gColorbarArea; + } + + /** + * Set data lens size + * + * @param {Integer} newDataLensSize New data lens size. Needs to be an odd + * integer. + */ + setDataLensSize(newDataLensSize) { + this.dataLensPadding = Math.max(0, Math.floor((newDataLensSize - 1) / 2)); + this.dataLensSize = this.dataLensPadding * 2 + 1; + } + + binsPerTile() { + return this.tilesetInfo.bins_per_dimension || BINS_PER_TILE; + } + + /** + * Get the data in the visible rectangle + * + * The parameter coordinates are in pixel coordinates + * + * @param {int} x: The upper left corner of the rectangle in pixel coordinates + * @param {int} y: The upper left corner of the rectangle in pixel coordinates + * @param {int} width: The width of the rectangle (pixels) + * @param {int} height: The height of the rectangle (pixels) + * + * @returns {Array} A numjs array containing the data in the visible region + * + */ + getVisibleRectangleData(x, y, width, height) { + let zoomLevel = this.calculateZoomLevel(); + zoomLevel = this.tilesetInfo.max_zoom ? Math.min(this.tilesetInfo.max_zoom, zoomLevel) : zoomLevel; + + const calculatedWidth = calculateTileWidth(this.tilesetInfo, zoomLevel, this.binsPerTile()); + + // BP resolution of a tile's bin (i.e., numbe of base pairs per bin / pixel) + const tileRes = calculatedWidth / this.binsPerTile(); + + // the data domain of the currently visible region + const xDomain = [this._xScale.invert(x), this._xScale.invert(x + width)]; + const yDomain = [this._yScale.invert(y), this._yScale.invert(y + height)]; + + // we need to limit the domain of the requested region + // to the bounds of the data + const limitedXDomain = [ + Math.max(xDomain[0], this.tilesetInfo.min_pos[0]), + Math.min(xDomain[1], this.tilesetInfo.max_pos[0]) + ]; + + const limitedYDomain = [ + Math.max(yDomain[0], this.tilesetInfo.min_pos[1]), + Math.min(yDomain[1], this.tilesetInfo.max_pos[1]) + ]; + + // the bounds of the currently visible region in bins + const leftXBin = Math.floor(limitedXDomain[0] / tileRes); + const leftYBin = Math.floor(limitedYDomain[0] / tileRes); + const binWidth = Math.max(0, Math.ceil((limitedXDomain[1] - limitedXDomain[0]) / tileRes)); + const binHeight = Math.max(0, Math.ceil((limitedYDomain[1] - limitedYDomain[0]) / tileRes)); + + const out = ndarray(new Array(binHeight * binWidth).fill(NaN), [binHeight, binWidth]); + + // iterate through all the visible tiles + this.visibleAndFetchedTiles().forEach(tile => { + const tilePos = tile.mirrored + ? [tile.tileData.tilePos[1], tile.tileData.tilePos[0]] + : tile.tileData.tilePos; + + // get the tile's position and width (in data coordinates) + // if it's mirrored then we have to switch the position indeces + const { tileX, tileY, tileWidth, tileHeight } = this.getTilePosAndDimensions( + tile.tileData.zoomLevel, + tilePos, + this.binsPerTile() + ); + + // calculate the tile's position in bins + const tileXStartBin = Math.floor(tileX / tileRes); + const tileXEndBin = Math.floor((tileX + tileWidth) / tileRes); + const tileYStartBin = Math.floor(tileY / tileRes); + const tileYEndBin = Math.floor((tileY + tileHeight) / tileRes); + + // calculate which part of this tile is present in the current window + let tileSliceXStart = Math.max(leftXBin, tileXStartBin) - tileXStartBin; + let tileSliceYStart = Math.max(leftYBin, tileYStartBin) - tileYStartBin; + const tileSliceXEnd = Math.min(leftXBin + binWidth, tileXEndBin) - tileXStartBin; + const tileSliceYEnd = Math.min(leftYBin + binHeight, tileYEndBin) - tileYStartBin; + + // where in the output array will the portion of this tile which is in the + // visible window be placed? + const tileXOffset = Math.max(tileXStartBin - leftXBin, 0); + const tileYOffset = Math.max(tileYStartBin - leftYBin, 0); + const tileSliceWidth = tileSliceXEnd - tileSliceXStart; + const tileSliceHeight = tileSliceYEnd - tileSliceYStart; + + // the region is outside of this tile + if (tileSliceWidth < 0 || tileSliceHeight < 0) return; + + if (tile.mirrored && tileSliceXStart > tileSliceYStart) { + const tmp = tileSliceXStart; + tileSliceXStart = tileSliceYStart; + tileSliceYStart = tmp; + } + + ndarrayAssign( + out.hi(tileYOffset + tileSliceHeight, tileXOffset + tileSliceWidth).lo(tileYOffset, tileXOffset), + tile.dataArray + .hi(tileSliceYStart + tileSliceHeight, tileSliceXStart + tileSliceWidth) + .lo(tileSliceYStart, tileSliceXStart) + ); + }); + + return out; + } + + /** + * Convert the raw tile data to a rendered array of values which can be represented as a sprite. + * + * @param tile: The data structure containing all the tile information. Relevant to + * this function are tile.tileData = \{'dense': [...], ...\} + * and tile.graphics + */ + initTile(tile) { + super.initTile(tile); + + // prepare the data for fast retrieval in getVisibleRectangleData + if (tile.tileData.dense.length === this.binsPerTile() ** 2) { + tile.dataArray = ndarray(Array.from(tile.tileData.dense), [this.binsPerTile(), this.binsPerTile()]); + + // Recompute DenseDataExtrema for diagonal tiles which have been mirrored + if (this.continuousScaling && tile.tileData.tilePos[0] === tile.tileData.tilePos[1] && tile.mirrored) { + tile.tileData.denseDataExtrema.mirrorPrecomputedExtrema(); + super.initTile(tile); + } + } + + // no data present + if (this.scale.minValue === null || this.scale.maxValue === null) { + return; + } + + this.renderTile(tile); + } + + // /** + // * Draw a border around tiles + // * + // * @param {Array} pixData Pixel data to be adjusted + // */ + // addBorder(pixData) { + // for (let i = 0; i < 256; i++) { + // if (i === 0) { + // const prefix = i * 256 * 4; + // for (let j = 0; j < 255; j++) { + // pixData[prefix + (j * 4)] = 0; + // pixData[prefix + (j * 4) + 1] = 0; + // pixData[prefix + (j * 4) + 2] = 255; + // pixData[prefix + (j * 4) + 3] = 255; + // } + // } + // pixData[(i * 256 * 4)] = 0; + // pixData[(i * 256 * 4) + 1] = 0; + // pixData[(i * 256 * 4) + 2] = 255; + // pixData[(i * 256 * 4) + 3] = 255; + // } + // } + // + updateTile(tile) { + if ( + tile.scale && + this.scale && + this.scale.minValue === tile.scale.minValue && + this.scale.maxValue === tile.scale.maxValue + ); + else { + // not rendered using the current scale, so we need to rerender + this.renderTile(tile); + this.drawColorbar(); + } + } + + destroyTile(tile) { + // sprite have to be explicitly destroyed in order to + // free the texture cache + tile.sprite.destroy(true); + + tile.canvas = null; + tile.sprite = null; + tile.texture = null; + + // this is a handy method for checking what's in the texture + // cache + // console.log('destroy', PIXI.utils.BaseTextureCache); + } + + pixDataFunction(tile, pixData) { + // the tileData has been converted to pixData by the worker script and + // needs to be loaded as a sprite + if (pixData) { + const { graphics } = tile; + const canvas = this.tileDataToCanvas(pixData.pixData); + + if (tile.sprite) { + // if this tile has already been rendered with a sprite, we + // have to destroy it before creating a new one + tile.sprite.destroy(true); + } + + const texture = + GLOBALS.PIXI.VERSION[0] === '4' + ? GLOBALS.PIXI.Texture.fromCanvas(canvas, GLOBALS.PIXI.SCALE_MODES.NEAREST) + : GLOBALS.PIXI.Texture.from(canvas, { + scaleMode: GLOBALS.PIXI.SCALE_MODES.NEAREST + }); + + const sprite = new GLOBALS.PIXI.Sprite(texture); + + tile.sprite = sprite; + tile.texture = texture; + // store the pixData so that we can export it + tile.canvas = canvas; + this.setSpriteProperties(tile.sprite, tile.tileData.zoomLevel, tile.tileData.tilePos, tile.mirrored); + + graphics.removeChildren(); + graphics.addChild(tile.sprite); + } + this.renderingTiles.delete(tile.tileId); + } + + /** + * Render / draw a tile. + * + * @param {Object} tile Tile data to be rendered. + */ + renderTile(tile) { + const [scaleType] = this.updateValueScale(); + const pseudocount = 0; + + this.renderingTiles.add(tile.tileId); + + if (this.tilesetInfo.tile_size) { + if (tile.tileData.dense.length < this.tilesetInfo.tile_size) { + // we haven't gotten a full tile from the server so we want to pad + // it with nan values + const newArray = new Float32Array(this.tilesetInfo.tile_size); + + newArray.fill(NaN); + newArray.set(tile.tileData.dense); + + tile.tileData.dense = newArray; + } + } + + tileDataToPixData( + tile, + scaleType, + this.limitedValueScale.domain(), + pseudocount, // used as a pseudocount to prevent taking the log of 0 + this.colorScale, + pixData => this.pixDataFunction(tile, pixData), + this.mirrorTiles() && !tile.mirrored && tile.tileData.tilePos[0] === tile.tileData.tilePos[1], + this.options.extent === 'upper-right' && tile.tileData.tilePos[0] === tile.tileData.tilePos[1], + this.options.zeroValueColor ? colorToRgba(this.options.zeroValueColor) : undefined, + { + selectedRows: this.options.selectRows, + selectedRowsAggregationMode: this.options.selectRowsAggregationMode, + selectedRowsAggregationWithRelativeHeight: this.options.selectRowsAggregationWithRelativeHeight, + selectedRowsAggregationMethod: this.options.selectRowsAggregationMethod + } + ); + } + + /** + * Remove this track from the view + */ + remove() { + this.gMain.remove(); + this.gMain = null; + + super.remove(); + } + + refScalesChanged(refXScale, refYScale) { + super.refScalesChanged(refXScale, refYScale); + + objVals(this.fetchedTiles) + .filter(tile => tile.sprite) + .forEach(tile => + this.setSpriteProperties(tile.sprite, tile.tileData.zoomLevel, tile.tileData.tilePos, tile.mirrored) + ); + } + + /** + * Bypass this track's exportSVG function + */ + superSVG() { + return super.exportSVG(); + } + + exportSVG() { + let track = null; + let base = null; + + if (super.exportSVG) { + [base, track] = super.exportSVG(); + } else { + base = document.createElement('g'); + track = base; + } + + const output = document.createElement('g'); + track.appendChild(output); + + output.setAttribute( + 'transform', + `translate(${this.pMain.position.x},${this.pMain.position.y}) scale(${this.pMain.scale.x},${this.pMain.scale.y})` + ); + + for (const tile of this.visibleAndFetchedTiles()) { + const rotation = (tile.sprite.rotation * 180) / Math.PI; + const g = document.createElement('g'); + g.setAttribute( + 'transform', + `translate(${tile.sprite.x},${tile.sprite.y}) rotate(${rotation}) scale(${tile.sprite.scale.x},${tile.sprite.scale.y})` + ); + + const image = document.createElement('image'); + image.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', tile.canvas.toDataURL()); + image.setAttribute('width', tile.canvas.width); + image.setAttribute('height', tile.canvas.height); + image.setAttribute('style', 'image-rendering: pixelated'); + + g.appendChild(image); + output.appendChild(g); + } + + const gColorbar = this.exportColorBarSVG(); + track.appendChild(gColorbar); + + return [base, base]; + } + + // This function gets the indices of the visible part of the upper left tile. + // The indices are 'rounded' to the grid used by the DenseDataExrema module. + // It is used to determine if we should check for a new value scale in + // the case of continuous scaling + getVisiblePartOfUppLeftTile() { + const tilePositions = this.visibleAndFetchedTiles().map(tile => { + const tilePos = tile.mirrored + ? [tile.tileData.tilePos[1], tile.tileData.tilePos[0]] + : tile.tileData.tilePos; + return [tilePos[0], tilePos[1], tile.tileId]; + }); + + if (tilePositions.length === 0) return null; + + let minTilePosition = tilePositions[0]; + + for (let i = 0; i < tilePositions.length; i++) { + const curPos = tilePositions[i]; + if (curPos[0] < minTilePosition[0] || curPos[1] < minTilePosition[1]) { + minTilePosition = curPos; + } + } + + const numSubsets = Math.min(NUM_PRECOMP_SUBSETS_PER_2D_TTILE, this.binsPerTile()); + const subsetSize = this.binsPerTile() / numSubsets; + + const upperLeftTile = this.visibleAndFetchedTiles().filter(tile => tile.tileId === minTilePosition[2])[0]; + + const upperLeftTileInd = this.getIndicesOfVisibleDataInTile(upperLeftTile); + + const startX = upperLeftTileInd[0]; + const startY = upperLeftTileInd[1]; + // round to nearest grid point as used in the DenseDataExtrema Module + const startXadjusted = startX - (startX % subsetSize); + const startYadjusted = startY - (startY % subsetSize); + + return [upperLeftTile.tileId, startXadjusted, startYadjusted]; + } + + getIndicesOfVisibleDataInTile(tile) { + const visibleX = this._xScale.range(); + const visibleY = this._yScale.range(); + + const tilePos = tile.mirrored ? [tile.tileData.tilePos[1], tile.tileData.tilePos[0]] : tile.tileData.tilePos; + + const { tileX, tileY, tileWidth, tileHeight } = this.getTilePosAndDimensions( + tile.tileData.zoomLevel, + tilePos, + this.binsPerTile() + ); + + const tileXScale = scaleLinear() + .domain([0, this.binsPerTile()]) + .range([tileX, tileX + tileWidth]); + + const startX = Math.max(0, Math.round(tileXScale.invert(this._xScale.invert(visibleX[0]))) - 1); + + const endX = Math.min(this.binsPerTile(), Math.round(tileXScale.invert(this._xScale.invert(visibleX[1])))); + + const tileYScale = scaleLinear() + .domain([0, this.binsPerTile()]) + .range([tileY, tileY + tileHeight]); + + const startY = Math.max(0, Math.round(tileYScale.invert(this._yScale.invert(visibleY[0]))) - 1); + + const endY = Math.min(this.binsPerTile(), Math.round(tileYScale.invert(this._yScale.invert(visibleY[1])))); + + const result = + tile.mirrored && tilePos[0] !== tilePos[1] ? [startY, startX, endY, endX] : [startX, startY, endX, endY]; + + return result; + } + + minVisibleValue(ignoreFixedScale = false) { + const minimumsPerTile = this.visibleAndFetchedTiles().map(tile => { + if (tile.tileData.denseDataExtrema === undefined) { + return null; + } + const ind = this.getIndicesOfVisibleDataInTile(tile); + return tile.tileData.denseDataExtrema.getMinNonZeroInSubset(ind); + }); + + if (minimumsPerTile.length === 0 && this.valueScaleMax === null) { + return null; + } + + const min = Math.min.apply(null, minimumsPerTile); + + // If there is no data or no denseDataExtrema, go to parent method + if (min === Number.MAX_SAFE_INTEGER) { + return super.minVisibleValue(ignoreFixedScale); + } + + if (ignoreFixedScale) return min; + + return this.valueScaleMin !== null ? this.valueScaleMin : min; + } + + maxVisibleValue(ignoreFixedScale = false) { + const maximumsPerTile = this.visibleAndFetchedTiles().map(tile => { + if (tile.tileData.denseDataExtrema === undefined) { + return null; + } + + const ind = this.getIndicesOfVisibleDataInTile(tile); + + return tile.tileData.denseDataExtrema.getMaxNonZeroInSubset(ind); + }); + + if (maximumsPerTile.length === 0 && this.valueScaleMax === null) { + return null; + } + + const max = Math.max.apply(null, maximumsPerTile); + + // If there is no data or no deseDataExtrema, go to parent method + if (max === Number.MIN_SAFE_INTEGER) { + return super.maxVisibleValue(ignoreFixedScale); + } + + if (ignoreFixedScale) return max; + + return this.valueScaleMax !== null ? this.valueScaleMax : max; + } + + zoomed(newXScale, newYScale, k, tx, ty) { + if (this.brushing) { + return; + } + + super.zoomed(newXScale, newYScale); + + this.pMain.position.x = tx; // translateX; + this.pMain.position.y = ty; // translateY; + + this.pMain.scale.x = k; // scaleX; + this.pMain.scale.y = k; // scaleY; + + const isValueScaleLocked = this.isValueScaleLocked(); + + if (this.continuousScaling && this.minValue() !== undefined && this.maxValue() !== undefined) { + // Get the indices of the visible part of the upper left tile. + // Helps to determine if we zoomed far enough to justify a min/max computation + const indUpperLeftTile = JSON.stringify(this.getVisiblePartOfUppLeftTile()); + + if ( + this.valueScaleMin === null && + this.valueScaleMax === null && + !isValueScaleLocked && + // syncs the recomputation with the grid used in the DenseDataExtrema module + indUpperLeftTile !== this.prevIndUpperLeftTile + ) { + const newMin = this.minVisibleValue(); + const newMax = this.maxVisibleValue(); + + const epsilon = 1e-6; + + if ( + newMin !== null && // can happen if tiles haven't loaded + newMax !== null && + (Math.abs(this.minValue() - newMin) > epsilon || Math.abs(this.maxValue() - newMax) > epsilon) + ) { + this.minValue(newMin); + this.maxValue(newMax); + + this.scheduleRerender(); + } + this.prevIndUpperLeftTile = indUpperLeftTile; + } + + if (isValueScaleLocked) { + this.onValueScaleChanged(); + } + } + + this.mouseMoveZoomHandler(); + } + + /** + * Helper method for adding a tile ID in place. Used by `tilesToId()`. + * + * @param {Array} tiles Array tile ID should be added to. + * @param {Integer} zoomLevel Zoom level. + * @param {Integer} row Column ID, i.e., y. + * @param {Integer} column Column ID, i.e., x. + * @param {Objwect} dataTransform ?? + * @param {Boolean} mirrored If `true` tile is mirrored. + */ + addTileId(tiles, zoomLevel, row, column, dataTransform, mirrored = false) { + const newTile = [zoomLevel, row, column]; + newTile.mirrored = mirrored; + newTile.dataTransform = dataTransform; + tiles.push(newTile); + } + + /** + * Convert tile positions to tile IDs + * + * @param {Array} xTiles X positions of tiles + * @param {Array} yTiles Y positions of tiles + * @param {Array} zoomLevel Current zoom level + * @return {Array} List of tile IDs + */ + tilesToId(xTiles, yTiles, zoomLevel) { + const rows = xTiles; + const cols = yTiles; + const dataTransform = (this.options && this.options.dataTransform) || 'default'; + + // if we're mirroring tiles, then we only need tiles along the diagonal + const tiles = []; + + // calculate the ids of the tiles that should be visible + for (let i = 0; i < rows.length; i++) { + for (let j = 0; j < cols.length; j++) { + if (this.mirrorTiles()) { + if (rows[i] >= cols[j]) { + if (this.options.extent !== 'lower-left') { + // if we're in the upper triangular part of the matrix, then we need + // to load a mirrored tile + this.addTileId(tiles, zoomLevel, cols[j], rows[i], dataTransform, true); + } + } else if (this.options.extent !== 'upper-right') { + // otherwise, load an original tile + this.addTileId(tiles, zoomLevel, rows[i], cols[j], dataTransform); + } + + if (rows[i] === cols[j] && this.options.extent === 'lower-left') { + // on the diagonal, load original tiles + this.addTileId(tiles, zoomLevel, rows[i], cols[j], dataTransform); + } + } else { + this.addTileId(tiles, zoomLevel, rows[i], cols[j], dataTransform); + } + } + } + + return tiles; + } + + calculateVisibleTiles() { + // if we don't know anything about this dataset, no point + // in trying to get tiles + if (!this.tilesetInfo) { + return; + } + + this.zoomLevel = this.calculateZoomLevel(); + + // this.zoomLevel = 0; + if (this.tilesetInfo.resolutions) { + const sortedResolutions = this.tilesetInfo.resolutions.map(x => +x).sort((a, b) => b - a); + + this.xTiles = calculateTilesFromResolution( + sortedResolutions[this.zoomLevel], + this._xScale, + this.tilesetInfo.min_pos[0], + this.tilesetInfo.max_pos[0] + ); + this.yTiles = calculateTilesFromResolution( + sortedResolutions[this.zoomLevel], + this._yScale, + this.tilesetInfo.min_pos[0], + this.tilesetInfo.max_pos[0] + ); + } else { + this.xTiles = calculateTiles( + this.zoomLevel, + this._xScale, + this.tilesetInfo.min_pos[0], + this.tilesetInfo.max_pos[0], + this.tilesetInfo.max_zoom, + this.tilesetInfo.max_width + ); + + this.yTiles = calculateTiles( + this.zoomLevel, + this._yScale, + this.options.reverseYAxis ? -this.tilesetInfo.max_pos[1] : this.tilesetInfo.min_pos[1], + this.options.reverseYAxis ? -this.tilesetInfo.min_pos[1] : this.tilesetInfo.max_pos[1], + this.tilesetInfo.max_zoom, + this.tilesetInfo.max_width1 || this.tilesetInfo.max_width + ); + } + + this.setVisibleTiles(this.tilesToId(this.xTiles, this.yTiles, this.zoomLevel)); + } + + mirrorTiles() { + return !( + this.tilesetInfo.mirror_tiles && + (this.tilesetInfo.mirror_tiles === false || this.tilesetInfo.mirror_tiles === 'false') + ); + } + + getMouseOverHtml(trackX, trackY) { + if (!this.options || !this.options.showTooltip) { + return ''; + } + + if (!this.tilesetInfo) { + return ''; + } + + const currentResolution = calculateResolution(this.tilesetInfo, this.zoomLevel); + + const maxWidth = Math.max( + this.tilesetInfo.max_pos[1] - this.tilesetInfo.min_pos[1], + this.tilesetInfo.max_pos[0] - this.tilesetInfo.min_pos[0] + ); + + const formatResolution = Math.ceil(Math.log(maxWidth / currentResolution) / Math.log(10)); + + this.setDataLensSize(1); + + const dataX = this._xScale.invert(trackX); + const dataY = this._yScale.invert(trackY); + + let positionText = 'Position: '; + + if (this.chromInfo) { + const atcX = absToChr(dataX, this.chromInfo); + const atcY = absToChr(dataY, this.chromInfo); + + const f = n => format(`.${formatResolution}s`)(n); + + positionText += `${atcX[0]}:${f(atcX[1])} & ${atcY[0]}:${f(atcY[1])}`; + positionText += '
'; + } + + let data = null; + try { + data = this.getVisibleRectangleData(trackX, trackY, 1, 1).get(0, 0); + } catch (err) { + return ''; + } + + if (this.options && this.options.heatmapValueScaling === 'log') { + if (data > 0) { + return `${positionText}Value: 1e${format('.3f')(Math.log(data) / Math.log(10))}`; + } + + if (data === 0) { + return `${positionText}Value: 0`; + } + + return `${positionText}Value: N/A`; + } + return `${positionText}Value: ${format('.3f')(data)}`; + } + + /** + * Get the tile's position in its coordinate system. + * + * @description + * Normally the absolute coordinate system are the genome basepair positions + */ + getTilePosAndDimensions(zoomLevel, tilePos, binsPerTileIn) { + /** + * Get the tile's position in its coordinate system. + */ + const binsPerTile = binsPerTileIn || this.binsPerTile(); + + if (this.tilesetInfo.resolutions) { + const sortedResolutions = this.tilesetInfo.resolutions.map(x => +x).sort((a, b) => b - a); + + const chosenResolution = sortedResolutions[zoomLevel]; + + const tileWidth = chosenResolution * binsPerTile; + const tileHeight = tileWidth; + + const tileX = chosenResolution * binsPerTile * tilePos[0]; + const tileY = chosenResolution * binsPerTile * tilePos[1]; + + return { + tileX, + tileY, + tileWidth, + tileHeight + }; + } + + const xTilePos = tilePos[0]; + const yTilePos = tilePos[1]; + + const minX = this.tilesetInfo.min_pos[0]; + + const minY = this.options.reverseYAxis ? -this.tilesetInfo.max_pos[1] : this.tilesetInfo.min_pos[1]; + + const tileWidth = this.tilesetInfo.max_width / 2 ** zoomLevel; + const tileHeight = this.tilesetInfo.max_width / 2 ** zoomLevel; + + const tileX = minX + xTilePos * tileWidth; + const tileY = minY + yTilePos * tileHeight; + + return { + tileX, + tileY, + tileWidth, + tileHeight + }; + } + + calculateZoomLevel() { + this.tilesetInfo.min_pos[0]; + this.tilesetInfo.max_pos[0]; + + this.tilesetInfo.min_pos[1]; + this.tilesetInfo.max_pos[1]; + + let zoomLevel = null; + + if (this.tilesetInfo.resolutions) { + const zoomIndexX = calculateZoomLevelFromResolutions(this.tilesetInfo.resolutions, this._xScale); + const zoomIndexY = calculateZoomLevelFromResolutions(this.tilesetInfo.resolutions, this._yScale); + + zoomLevel = Math.min(zoomIndexX, zoomIndexY); + } else { + const xZoomLevel = calculateZoomLevel( + this._xScale, + this.tilesetInfo.min_pos[0], + this.tilesetInfo.max_pos[0], + this.binsPerTile() + ); + + const yZoomLevel = calculateZoomLevel( + this._xScale, + this.tilesetInfo.min_pos[1], + this.tilesetInfo.max_pos[1], + this.binsPerTile() + ); + + zoomLevel = Math.max(xZoomLevel, yZoomLevel); + zoomLevel = Math.min(zoomLevel, this.maxZoom); + } + + if (this.options && this.options.maxZoom) { + if (this.options.maxZoom >= 0) { + zoomLevel = Math.min(this.options.maxZoom, zoomLevel); + } else { + console.error('Invalid maxZoom on track:', this); + } + } + + return zoomLevel; + } + + /** + * The local tile identifier + * + * @param {array} tile Tile definition array to be converted to id. Tile + * array must contain `[zoomLevel, xPos, yPos]` and two props `mirrored` and + * `dataTransform`. + */ + tileToLocalId(tile) { + // tile + if (tile.dataTransform && tile.dataTransform !== 'default') { + return `${tile.join('.')}.${tile.mirrored}.${tile.dataTransform}`; + } + return `${tile.join('.')}.${tile.mirrored}`; + } + + /** + * The tile identifier used on the server + */ + tileToRemoteId(tile) { + // tile contains [zoomLevel, xPos, yPos] + if (tile.dataTransform && tile.dataTransform !== 'default') { + return `${tile.join('.')}.${tile.dataTransform}`; + } + return `${tile.join('.')}`; + } + + localToRemoteId(remoteId) { + const idParts = remoteId.split('.'); + return idParts.slice(0, idParts.length - 1).join('.'); + } +} + +/** + * Convert a chromosome position to an absolute genome position. + * + * @template {string} Name + * @param {Name} chrom - Chromosome name + * @param {number} chromPos - Chromosome position + * @param {import('../types').ChromInfo} chromInfo - Chromosome info object + */ +const chrToAbs = (chrom, chromPos, chromInfo) => chromInfo.chrPositions[chrom].pos + chromPos; + +// @ts-nocheck +/** + * Export a PIXI text to an SVG element + * + * param {PIXI.Text} pixiText A PIXI.Text object that we want to create an SVG element for + * returns {Element} A DOM SVG Element with all of the attributes set as to display + * the given text. + */ +const pixiTextToSvg = pixiText => { + const g = document.createElement('g'); + const t = document.createElement('text'); + + if (pixiText.anchor.x === 0) { + t.setAttribute('text-anchor', 'start'); + } else if (pixiText.anchor.x === 1) { + t.setAttribute('text-anchor', 'end'); + } else { + t.setAttribute('text-anchor', 'middle'); + } + + t.setAttribute('font-family', pixiText.style.fontFamily); + t.setAttribute('font-size', pixiText.style.fontSize); + g.setAttribute('transform', `scale(${pixiText.scale.x},1)`); + + t.setAttribute('fill', pixiText.style.fill); + t.innerHTML = pixiText.text; + + g.appendChild(t); + g.setAttribute('transform', `translate(${pixiText.x},${pixiText.y})scale(${pixiText.scale.x},1)`); + + return g; +}; + +// @ts-nocheck +/** + * Generate a SVG line + * @param {number} x1 Start X + * @param {number} y1 Start Y + * @param {number} x2 End X + * @param {number} y2 End Y + * @param {number} strokeWidth Line width + * @param {number} strokeColor Color HEX string + * @return {object} SVG line object + */ +const svgLine = (x1, y1, x2, y2, strokeWidth, strokeColor) => { + const line = document.createElement('line'); + + line.setAttribute('x1', x1); + line.setAttribute('x2', x2); + line.setAttribute('y1', y1); + line.setAttribute('y2', y2); + + if (strokeWidth) { + line.setAttribute('stroke-width', strokeWidth); + } + if (strokeColor) { + line.setAttribute('stroke', strokeColor); + } + + return line; +}; + +export { + DataFetcher, + DenseDataExtrema1D, + HeatmapTiledPixiTrack, + PixiTrack, + SVGTrack, + TiledPixiTrack, + Track, + ViewportTrackerHorizontal, + absToChr, + chrToAbs, + chromInfoBisector, + colorToHex, + fakePubSub, + pixiTextToSvg, + setupShowMousePosition as showMousePosition, + svgLine, + api as tileProxy +}; From 3c1d8d27c573750402e2e4e71d796f670c2a8900 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 15:56:04 -0400 Subject: [PATCH 009/139] feat: higlass alias, fix pixi manager --- tsconfig.json | 3 ++- vite.config.js | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 40d35e8ac..72c87782d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,8 +31,9 @@ "@gosling-lang/gosling-genomic-axis": ["./src/tracks/gosling-genomic-axis/index.ts"], "@gosling-lang/gosling-brush": ["./src/tracks/gosling-brush/index.ts"], "@gosling-lang/dummy-track": ["./src/tracks/dummy-track/index.ts"], - "pixi-manager": ["./src/pixi-manager/index.ts"], + "@pixi-manager": ["./src/pixi-manager/index.ts"], "@data-fetchers": ["./src/data-fetchers/index.ts"], + "@higlass": ["./src/higlass"], "zlib": ["./src/alias/zlib.ts"] }, "baseUrl": ".", diff --git a/vite.config.js b/vite.config.js index 800efa6dc..738175519 100644 --- a/vite.config.js +++ b/vite.config.js @@ -75,6 +75,7 @@ const alias = { '@gosling-lang/dummy-track': path.resolve(__dirname, './src/tracks/dummy-track/index.ts'), '@pixi-manager': path.resolve(__dirname, './src/pixi-manager/index.ts'), '@data-fetchers': path.resolve(__dirname, './src/data-fetchers/index.ts'), + '@higlass': path.resolve(__dirname, './src/higlass'), zlib: path.resolve(__dirname, './src/alias/zlib.ts'), stream: path.resolve(__dirname, './node_modules/stream-browserify') // gmod/gff uses stream-browserify }; From a0e8fb6c29b93df76be9ac191ff9e54cb0d94f33 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 15:56:31 -0400 Subject: [PATCH 010/139] feat: dummy track --- src/tracks/dummy-track/dummy-track-plot.ts | 24 +++++ src/tracks/dummy-track/dummy-track.ts | 105 +++++++++------------ src/tracks/dummy-track/index.ts | 4 +- 3 files changed, 74 insertions(+), 59 deletions(-) create mode 100644 src/tracks/dummy-track/dummy-track-plot.ts diff --git a/src/tracks/dummy-track/dummy-track-plot.ts b/src/tracks/dummy-track/dummy-track-plot.ts new file mode 100644 index 000000000..acfd2ffc2 --- /dev/null +++ b/src/tracks/dummy-track/dummy-track-plot.ts @@ -0,0 +1,24 @@ +import { DummyTrackClass } from './dummy-track'; +import { type DummyTrackOptions } from './dummy-track'; + +export class DummyTrack extends DummyTrackClass { + constructor(options: DummyTrackOptions, overlayDiv: HTMLElement) { + const height = overlayDiv.clientHeight; + const width = overlayDiv.clientWidth; + // Create a new svg element. The brush will be drawn on this element + const svgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svgElement.style.width = `${width}px`; + svgElement.style.height = `${height}px`; + // Add it to the overlay div + overlayDiv.appendChild(svgElement); + + // Setup the context object + const context = { + id: 'test', + svgElement: svgElement, + getTheme: () => 'light' + }; + + super(context, options); + } +} diff --git a/src/tracks/dummy-track/dummy-track.ts b/src/tracks/dummy-track/dummy-track.ts index c8d62b7f5..c250b665c 100644 --- a/src/tracks/dummy-track/dummy-track.ts +++ b/src/tracks/dummy-track/dummy-track.ts @@ -1,70 +1,59 @@ -import { createPluginTrack, type PluginTrackFactory, type TrackConfig } from '../../core/utils/define-plugin-track'; -import { publish } from '../../api/pubsub'; +import { SVGTrack, type SVGTrackContext } from '@higlass/tracks'; import { type DummyTrackStyle } from '@gosling-lang/gosling-schema'; -interface DummyTrackOptions extends DummyTrackStyle { +export interface DummyTrackOptions extends DummyTrackStyle { title: string; height: number; width: number; } -const config: TrackConfig = { - type: 'dummy-track', - defaultOptions: { - height: 0, // default height gets set in when spec is preprocessed - width: 0, // default width gets set in when spec is preprocessed - title: '', - background: '#fff', - textFontSize: 12, - textFontWeight: 'normal', - textStroke: '#000', - textStrokeWidth: 0.1, - outline: '#fff' - } +const defaultOptions = { + height: 0, // default height gets set in when spec is preprocessed + width: 0, // default width gets set in when spec is preprocessed + title: '', + background: '#fff', + textFontSize: 12, + textFontWeight: 'normal', + textStroke: '#000', + textStrokeWidth: 0.1, + outline: '#fff' }; -const factory: PluginTrackFactory = (HGC, context, options) => { - // Services - const { SVGTrack } = HGC.tracks; - - class DummyTrackClass extends SVGTrack { - constructor() { - super(context, options); - this.#drawBackground(); - this.#drawText(); - publish('onNewTrack', { - id: context.viewUid - }); - } +export class DummyTrackClass extends SVGTrack { + constructor(context: SVGTrackContext, options: DummyTrackOptions) { + super(context, options); + // @ts-expect-error Typescript things that the default option for textFontWeight is not compatible + this.options = { ...defaultOptions, ...options }; + // Set the width and height of the rect element so that other elements appended to gMain will be shown + this.clipRect.attr('width', this.options.width).attr('height', this.options.height); - #drawBackground() { - this.gMain - .append('rect') - .attr('fill', options.background) - .attr('x', 0) - .attr('y', 0) - .attr('width', options.width) - .attr('height', options.height) - .style('stroke', options.outline); - } - /** - * Draws the title of the dummy track - */ - #drawText() { - this.gMain - .append('text') - .attr('x', options.width / 2) - .attr('y', (options.height + options.textFontSize!) / 2) - .style('text-anchor', 'middle') - .style('font-size', `${options.textFontSize}px`) - .style('font-weight', options.textFontWeight) - .style('stroke', options.textStroke) - .style('stroke-width', options.textStrokeWidth) - .text(options.title); - } + this.#drawBackground(); + this.#drawText(); } - return new DummyTrackClass(); -}; - -export default createPluginTrack(config, factory); + #drawBackground() { + this.gMain + .append('rect') + .attr('fill', this.options.background) + .attr('x', 0) + .attr('y', 0) + .attr('width', this.options.width) + .attr('height', this.options.height) + .style('stroke', this.options.outline); + } + /** + * Draws the title of the dummy track + */ + #drawText() { + this.gMain + .append('text') + .attr('x', this.options.width / 2) + .attr('y', (this.options.height + this.options.textFontSize!) / 2) + .style('text-anchor', 'middle') + .style('font-size', `${this.options.textFontSize}px`) + .style('font-weight', this.options.textFontWeight) + .style('stroke', this.options.textStroke) + .style('stroke-width', this.options.textStrokeWidth) + .text(this.options.title); + } +} diff --git a/src/tracks/dummy-track/index.ts b/src/tracks/dummy-track/index.ts index bc2d89c7d..5d1533008 100644 --- a/src/tracks/dummy-track/index.ts +++ b/src/tracks/dummy-track/index.ts @@ -1 +1,3 @@ -export { default as DummyTrack } from './dummy-track'; +export { DummyTrackClass } from './dummy-track'; +export type { DummyTrackOptions } from './dummy-track'; +export { DummyTrack } from './dummy-track-plot'; From 186ebd149eb50a64f114028bcd3e01fc7e843d51 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 15:56:49 -0400 Subject: [PATCH 011/139] feat: dummy track example --- demo/App.tsx | 3 +++ demo/examples/dummy-track.ts | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 demo/examples/dummy-track.ts diff --git a/demo/App.tsx b/demo/App.tsx index 544291df8..7d97d87cb 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { PixiManager } from '@pixi-manager'; +import { addDummyTrack } from './examples/dummy-track'; import './App.css'; @@ -12,6 +13,8 @@ function App() { plotElement.innerHTML = ''; // Initialize the PixiManager. This will be used to get containers and overlay divs for the plots const pixiManager = new PixiManager(1000, 600, plotElement, setFps); + // Add the dummy track + addDummyTrack(pixiManager); }, []); return ( diff --git a/demo/examples/dummy-track.ts b/demo/examples/dummy-track.ts new file mode 100644 index 000000000..aed500be7 --- /dev/null +++ b/demo/examples/dummy-track.ts @@ -0,0 +1,24 @@ +import { PixiManager } from '@pixi-manager'; +import { DummyTrack } from '@gosling-lang/dummy-track'; + +export function addDummyTrack(pixiManager: PixiManager) { + new DummyTrack( + { + width: 350, + height: 130, + title: 'Placeholder', + background: '#e6e6e6', + textFontSize: 12, + textFontWeight: 'normal', + textStroke: '#000', + textStrokeWidth: 0.1, + outline: '#fff' + }, + pixiManager.makeContainer({ + x: 10, + y: 10, + width: 350, + height: 130 + }).overlayDiv + ); +} From a6c1a74926e00f5263319fdfe5b6c62603ff71a9 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 15:57:00 -0400 Subject: [PATCH 012/139] feat: higlass types --- .../higlass-vendored.js | 0 src/higlass/tracks.ts | 1 + src/missing-types.d.ts | 70 +++++++++++++++++-- 3 files changed, 64 insertions(+), 7 deletions(-) rename src/{higlass-vendored => higlass}/higlass-vendored.js (100%) create mode 100644 src/higlass/tracks.ts diff --git a/src/higlass-vendored/higlass-vendored.js b/src/higlass/higlass-vendored.js similarity index 100% rename from src/higlass-vendored/higlass-vendored.js rename to src/higlass/higlass-vendored.js diff --git a/src/higlass/tracks.ts b/src/higlass/tracks.ts new file mode 100644 index 000000000..6374288f4 --- /dev/null +++ b/src/higlass/tracks.ts @@ -0,0 +1 @@ +export { SVGTrack } from './higlass-vendored'; diff --git a/src/missing-types.d.ts b/src/missing-types.d.ts index 1a65af8f4..66397e786 100644 --- a/src/missing-types.d.ts +++ b/src/missing-types.d.ts @@ -124,6 +124,7 @@ declare module '@higlass/tracks' { import type * as d3Selection from 'd3-selection'; import type { TilesetInfo, ColorRGBA } from '@higlass/services'; import type { ChromInfo } from '@higlass/utils'; + import type { DataFetcher } from '@higlass/datafetcher'; export type Scale = d3.ScaleContinuousNumeric; @@ -207,6 +208,7 @@ declare module '@higlass/tracks' { refXScale(scale: Scale): void; refYScale(): this['_refYScale']; refYScale(scale: Scale): void; + refScalesChanged(refXScale: Scale, refYScale: Scale): void; xScale(): this['_xScale']; xScale(scale: Scale): void; yScale(): this['_yScale']; @@ -233,11 +235,6 @@ declare module '@higlass/tracks' { } type DataConfig = Record; - export interface DataFetcher { - tilesetInfo(finished: (info: TilesetInfo) => void): void; - fetchTilesDebounced(receivedTiles: (tiles: Record) => void, tileIds: string[]): void; - track?: any; - } type TilePosition1D = [zoom: number, x: number]; type TilePosition2D = [zoom: number, x: number, y: number]; @@ -266,7 +263,7 @@ declare module '@higlass/tracks' { prevOptions: string; flipText?: boolean; // Property never assigned https://github.com/higlass/higlass/blob/develop/app/scripts/PixiTrack.js /* Constructor */ - constructor(context: Context, options: Options); + constructor(context: PixiTrackContext, options: Options); /* Methods */ setMask(position: [number, number], dimensions: [number, number]): void; getForeground(): void; @@ -484,6 +481,10 @@ declare module '@higlass/tracks' { abstract mouseMoveZoomHandler(absX?: number, abxY?: number): void; } + export abstract class HeatmapTiledPixiTrack extends Tiled1DPixiTrack { + // TODO: fill this out + } + class AxisPixi { pAxis: PIXI.Graphics; track: Track; @@ -571,7 +572,45 @@ declare module '@higlass/tracks' { clipUid: string; clipRect: d3Selection.Selection; /* Constructor */ - constructor(context: Context, options: Options); + constructor(context: SVGTrackContext, options: Options); + } + + interface PixiTrackContext extends TrackContext { + scene: PIXI.Container; + } + + interface TrackContext { + id: string; + pubSub?: PubSub; + getTheme: () => unknown; + } + interface SVGTrackContext extends TrackContext { + svgElement: SVGElement; + } + interface ViewportTrackerHorizontalContext extends SVGTrackContext { + registerViewportChanged: ( + uid: string, + callback: (viewportXScale: ScaleLinear, viewportYScale: ScaleLinear) => void + ) => void; + removeViewportChanged: (uid: string) => void; + setDomainsCallback: (xDomain: [number, number], yDomain: [number, number]) => void; + projectionXDomain: [number, number]; // The domain of the brush + } + + interface ViewportTrackerHorizontalOptions { + projectionFillColor: string; + projectionStrokeColor: string; + projectionFillOpacity: number; + projectionStrokeOpacity: number; + strokeWidth: number; + } + + export class ViewportTrackerHorizontal extends SVGTrack { + options: Options & ViewportTrackerHorizontalOptions; + context: ViewportTrackerHorizontalContext; + viewportChanged: (viewportXScale: Scale, viewportYScale: Scale) => void; + + constructor(context: ViewportTrackerHorizontalContext, options: Options & ViewportTrackerHorizontalOptions); } /* eslint-disable-next-line @typescript-eslint/ban-types */ @@ -630,6 +669,14 @@ declare module '@higlass/utils' { import type { ScaleContinuousNumeric } from 'd3-scale'; import type { TilesetInfo } from '@higlass/services'; + export const fakePubSub = { + __fake__: true, + publish: () => {}, + subscribe: () => ({ event: 'fake', handler: () => {} }), + unsubscribe: () => {}, + clear: () => {} + }; + export type ChromInfo = { cumPositions: { id?: number; pos: number; chr: string }[]; chrPositions: Record; @@ -674,4 +721,13 @@ declare module '@higlass/utils' { scale: ScaleContinuousNumeric ): [zoomLevel: number, x: number][]; }; + export function uuid(): string; +} + +declare module '@higlass/datafetcher' { + export class DataFetcher { + tilesetInfo(finished: (info: TilesetInfo) => void): void; + fetchTilesDebounced(receivedTiles: (tiles: Record) => void, tileIds: string[]): void; + track?: any; + } } From f257186d1b078aff04e09bb99a87a531db7a0b94 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 16:02:28 -0400 Subject: [PATCH 013/139] feat: export PixiTrack --- src/higlass/tracks.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/higlass/tracks.ts b/src/higlass/tracks.ts index 6374288f4..306a4ea84 100644 --- a/src/higlass/tracks.ts +++ b/src/higlass/tracks.ts @@ -1 +1,2 @@ export { SVGTrack } from './higlass-vendored'; +export { PixiTrack } from './higlass-vendored'; From e43796ee422cd47326cd9c220baf8d33b77ccab1 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 16:02:37 -0400 Subject: [PATCH 014/139] feat: text track --- src/tracks/text-track/index.ts | 2 + src/tracks/text-track/text-track.ts | 224 ++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 src/tracks/text-track/index.ts create mode 100644 src/tracks/text-track/text-track.ts diff --git a/src/tracks/text-track/index.ts b/src/tracks/text-track/index.ts new file mode 100644 index 000000000..df98fe9eb --- /dev/null +++ b/src/tracks/text-track/index.ts @@ -0,0 +1,2 @@ +export { TextTrackClass } from './text-track'; +export type { TextTrackOptions, TextTrackContext } from './text-track'; diff --git a/src/tracks/text-track/text-track.ts b/src/tracks/text-track/text-track.ts new file mode 100644 index 000000000..2e1a6aa14 --- /dev/null +++ b/src/tracks/text-track/text-track.ts @@ -0,0 +1,224 @@ +import { PixiTrack } from '@higlass/tracks'; +import * as PIXI from 'pixi.js'; +import { uuid } from '../../core/utils/uuid'; +import colorToHex from '../../core/utils/color-to-hex'; +import { type PixiTrackContext } from '@higlass/tracks'; + +const defaultOptions = { + backgroundColor: '#ededed', + textColor: '#333333', + fontSize: 14, + fontFamily: 'Arial', + fontWeight: 'normal', + align: 'left' as const, + offsetY: 0, + text: '' +}; + +export interface TextTrackOptions { + backgroundColor: string; + textColor: string; + fontSize: number; + fontFamily: string; + fontWeight: string; + align: 'left' | 'right' | 'middle'; + offsetY: number; + text: string; +} + +export type TextTrackContext = PixiTrackContext; + +function initColors(textColor: string) { + return { + textColor: colorToHex(textColor), + black: colorToHex('#000000'), + white: colorToHex('#ffffff'), + lightgrey: colorToHex('#ededed') + }; +} + +export class TextTrackClass extends PixiTrack { + colors: { [key: string]: number } = {}; + text = ''; + fontSize = 14; + textOptions = {}; + isTrackShownVertically: boolean; + svgAnchor: string; + svgX: number; + + constructor(context: TextTrackContext, options: Partial) { + const completeOptions = { ...defaultOptions, ...options }; + super(context, completeOptions); + + // TODO: make this a part of the options + this.isTrackShownVertically = false; + this.initOptions(); + + // These are the default values, will be updated in renderText + this.svgAnchor = 'start'; + this.svgX = 0; + } + + initOptions() { + this.colors = initColors(this.options.textColor); + this.text = this.options.text; + this.fontSize = +this.options.fontSize; + + this.textOptions = { + fontSize: `${this.fontSize}px`, + fontFamily: this.options.fontFamily, + fontWeight: this.options.fontWeight, + fill: this.colors['textColor'] + }; + } + + /* + * Redraw the track because the options + * changed + */ + rerender(options: TextTrackOptions, force: boolean) { + const strOptions = JSON.stringify(options); + if (!force && strOptions === this.prevOptions) return; + + this.options = options; + this.initOptions(); + + this.prevOptions = strOptions; + + this.renderText(); + } + + draw() {} + + renderText() { + // this.pForeground.clear(); + // this.pForeground.removeChildren(); + + const text = new PIXI.Text(this.text, this.textOptions); + text.interactive = true; + text.anchor.x = 0; + text.anchor.y = 0; + text.visible = true; + text.y = this.options.offsetY; + + const margin = 5; + text.x = margin; + + if (!this.isTrackShownVertically) { + if (this.options.align === 'left') { + text.anchor.x = 0; + text.x = margin; + this.svgAnchor = 'start'; + } else if (this.options.align === 'middle') { + text.anchor.x = 0.5; + text.x = this.dimensions[0] / 2; + this.svgAnchor = 'middle'; + } else if (this.options.align === 'right') { + text.anchor.x = 1; + text.x = this.dimensions[0] - margin; + this.svgAnchor = 'end'; + } + } else { + if (this.options.align === 'right') { + text.anchor.x = 1; + text.scale.x *= -1; + this.svgAnchor = 'end'; + } else if (this.options.align === 'middle') { + text.anchor.x = 0.5; + text.scale.x *= -1; + text.x = this.dimensions[0] / 2; + this.svgAnchor = 'middle'; + } else if (this.options.align === 'left') { + text.anchor.x = 0; + text.scale.x *= -1; + text.x = this.dimensions[0] - margin; + this.svgAnchor = 'start'; + } + } + + this.svgX = text.x; + + this.pForeground.addChild(text); + } + + setDimensions(newDimensions: [number, number]) { + super.setDimensions(newDimensions); + // We have to rerender here, otherwise it does not fire at all sometimes + this.rerender(this.options, false); + } + + getMouseOverHtml() {} + + exportSVG() { + let track = null; + let base = null; + + base = document.createElement('g'); + track = base; + + const clipPathId = uuid(); + + const gClipPath = document.createElement('g'); + gClipPath.setAttribute('style', `clip-path:url(#${clipPathId});`); + + track.appendChild(gClipPath); + + // define the clipping area as a polygon defined by the track's + // dimensions on the canvas + const clipPath = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath'); + clipPath.setAttribute('id', clipPathId); + track.appendChild(clipPath); + + const clipPolygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); + clipPath.appendChild(clipPolygon); + + clipPolygon.setAttribute( + 'points', + `${this.position[0]},${this.position[1]} ` + + `${this.position[0] + this.dimensions[0]},${this.position[1]} ` + + `${this.position[0] + this.dimensions[0]},${this.position[1] + this.dimensions[1]} ` + + `${this.position[0]},${this.position[1] + this.dimensions[1]} ` + ); + + const output = document.createElement('g'); + + output.setAttribute('transform', `translate(${this.position[0]},${this.position[1]})`); + + gClipPath.appendChild(output); + + // Background + const gBackground = document.createElement('g'); + const rBackground = document.createElement('path'); + const dBackground = `M 0 0 H ${this.dimensions[0]} V ${this.dimensions[1]} H 0 Z`; + rBackground.setAttribute('d', dBackground); + rBackground.setAttribute('fill', this.options.backgroundColor); + rBackground.setAttribute('opacity', '1'); + gBackground.appendChild(rBackground); + output.appendChild(gBackground); + + // Text + const gText = document.createElement('g'); + const t = document.createElement('text'); + t.setAttribute('text-anchor', this.svgAnchor); + t.setAttribute('font-family', this.options.fontFamily); + t.setAttribute('font-size', `${this.fontSize}px`); + //t.setAttribute("alignment-baseline", "top"); + t.setAttribute('font-weight', this.options.fontWeight); + + gText.setAttribute('transform', `scale(1,1)`); + + t.setAttribute('fill', this.options.textColor); + t.innerHTML = this.options.text; + + const scalefactor = this.isTrackShownVertically ? -1 : 1; + + gText.appendChild(t); + gText.setAttribute( + 'transform', + `translate(${this.svgX},${this.options.offsetY + this.fontSize})scale(${scalefactor},1)` + ); + output.appendChild(gText); + + return [base, base] as [typeof base, typeof base]; + } +} From 12a136308b3b2b00a40272a434a7215d5342f721 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 16:21:19 -0400 Subject: [PATCH 015/139] feat: text track alias --- tsconfig.json | 1 + vite.config.js | 1 + 2 files changed, 2 insertions(+) diff --git a/tsconfig.json b/tsconfig.json index 72c87782d..a7e6c0ae7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,6 +31,7 @@ "@gosling-lang/gosling-genomic-axis": ["./src/tracks/gosling-genomic-axis/index.ts"], "@gosling-lang/gosling-brush": ["./src/tracks/gosling-brush/index.ts"], "@gosling-lang/dummy-track": ["./src/tracks/dummy-track/index.ts"], + "@gosling-lang/text-track": ["./src/tracks/text-track/index.ts"], "@pixi-manager": ["./src/pixi-manager/index.ts"], "@data-fetchers": ["./src/data-fetchers/index.ts"], "@higlass": ["./src/higlass"], diff --git a/vite.config.js b/vite.config.js index 738175519..6ecd4f950 100644 --- a/vite.config.js +++ b/vite.config.js @@ -73,6 +73,7 @@ const alias = { '@gosling-lang/gosling-genomic-axis': path.resolve(__dirname, './src/tracks/gosling-genomic-axis/index.ts'), '@gosling-lang/gosling-brush': path.resolve(__dirname, './src/tracks/gosling-brush/index.ts'), '@gosling-lang/dummy-track': path.resolve(__dirname, './src/tracks/dummy-track/index.ts'), + '@gosling-lang/text-track': path.resolve(__dirname, './src/tracks/text-track/index.ts'), '@pixi-manager': path.resolve(__dirname, './src/pixi-manager/index.ts'), '@data-fetchers': path.resolve(__dirname, './src/data-fetchers/index.ts'), '@higlass': path.resolve(__dirname, './src/higlass'), From 099c3573bf81c7cbc6bd5c4e96060f39c37a48a5 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 16:21:35 -0400 Subject: [PATCH 016/139] feat: text track --- src/tracks/text-track/index.ts | 1 + src/tracks/text-track/text-track-plot.ts | 32 ++++++++++++++++++++++++ src/tracks/text-track/text-track.ts | 2 +- 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 src/tracks/text-track/text-track-plot.ts diff --git a/src/tracks/text-track/index.ts b/src/tracks/text-track/index.ts index df98fe9eb..28aceef28 100644 --- a/src/tracks/text-track/index.ts +++ b/src/tracks/text-track/index.ts @@ -1,2 +1,3 @@ export { TextTrackClass } from './text-track'; export type { TextTrackOptions, TextTrackContext } from './text-track'; +export { TextTrack } from './text-track-plot'; diff --git a/src/tracks/text-track/text-track-plot.ts b/src/tracks/text-track/text-track-plot.ts new file mode 100644 index 000000000..7a4c92a6d --- /dev/null +++ b/src/tracks/text-track/text-track-plot.ts @@ -0,0 +1,32 @@ +import { TextTrackClass } from './text-track'; +import type { TextTrackOptions, TextTrackContext } from './text-track'; +import * as PIXI from 'pixi.js'; +import { fakePubSub } from '@higlass/utils'; + +export class TextTrack extends TextTrackClass { + constructor( + options: Partial, + containers: { + pixiContainer: PIXI.Container; + overlayDiv: HTMLElement; + } + ) { + const { pixiContainer, overlayDiv } = containers; + const height = overlayDiv.clientHeight; + const width = overlayDiv.clientWidth; + + // Setup the context object + const context: TextTrackContext = { + scene: pixiContainer, + id: 'test', + pubSub: fakePubSub, + getTheme: () => 'light' + }; + + super(context, options); + + // Need to set the dimensions and position of the track for it to render properly + this.setDimensions([width, height]); + this.setPosition([0, 0]); + } +} diff --git a/src/tracks/text-track/text-track.ts b/src/tracks/text-track/text-track.ts index 2e1a6aa14..be0097c65 100644 --- a/src/tracks/text-track/text-track.ts +++ b/src/tracks/text-track/text-track.ts @@ -1,8 +1,8 @@ import { PixiTrack } from '@higlass/tracks'; +import { type PixiTrackContext } from '@higlass/tracks'; import * as PIXI from 'pixi.js'; import { uuid } from '../../core/utils/uuid'; import colorToHex from '../../core/utils/color-to-hex'; -import { type PixiTrackContext } from '@higlass/tracks'; const defaultOptions = { backgroundColor: '#ededed', From e7a4980f0af1df700f8bd139b719b3d069344110 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 16:21:48 -0400 Subject: [PATCH 017/139] feat: higlass utils --- src/higlass/utils.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/higlass/utils.ts diff --git a/src/higlass/utils.ts b/src/higlass/utils.ts new file mode 100644 index 000000000..aa0e4520a --- /dev/null +++ b/src/higlass/utils.ts @@ -0,0 +1 @@ +export { fakePubSub } from './higlass-vendored'; From 06edb1f8bb16e08904f5753762340e162ad70b61 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 16:22:00 -0400 Subject: [PATCH 018/139] feat: text track example --- demo/App.tsx | 4 ++-- demo/examples/dummy-track.ts | 2 +- demo/examples/index.ts | 2 ++ demo/examples/text-track.ts | 18 ++++++++++++++++++ 4 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 demo/examples/index.ts create mode 100644 demo/examples/text-track.ts diff --git a/demo/App.tsx b/demo/App.tsx index 7d97d87cb..5f5b5ac5d 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { PixiManager } from '@pixi-manager'; -import { addDummyTrack } from './examples/dummy-track'; +import { addDummyTrack, addTextTrack } from './examples'; import './App.css'; @@ -13,7 +13,7 @@ function App() { plotElement.innerHTML = ''; // Initialize the PixiManager. This will be used to get containers and overlay divs for the plots const pixiManager = new PixiManager(1000, 600, plotElement, setFps); - // Add the dummy track + addTextTrack(pixiManager); addDummyTrack(pixiManager); }, []); diff --git a/demo/examples/dummy-track.ts b/demo/examples/dummy-track.ts index aed500be7..e90c38526 100644 --- a/demo/examples/dummy-track.ts +++ b/demo/examples/dummy-track.ts @@ -16,7 +16,7 @@ export function addDummyTrack(pixiManager: PixiManager) { }, pixiManager.makeContainer({ x: 10, - y: 10, + y: 30, width: 350, height: 130 }).overlayDiv diff --git a/demo/examples/index.ts b/demo/examples/index.ts new file mode 100644 index 000000000..c61bcbba2 --- /dev/null +++ b/demo/examples/index.ts @@ -0,0 +1,2 @@ +export { addDummyTrack } from './dummy-track'; +export { addTextTrack } from './text-track'; diff --git a/demo/examples/text-track.ts b/demo/examples/text-track.ts new file mode 100644 index 000000000..ccd87e774 --- /dev/null +++ b/demo/examples/text-track.ts @@ -0,0 +1,18 @@ +import { PixiManager } from '@pixi-manager'; +import { TextTrack } from '@gosling-lang/text-track'; + +export function addTextTrack(pixiManager: PixiManager) { + const titleOptions = { + backgroundColor: 'transparent', + textColor: 'black', + fontSize: 18, + fontWeight: 'bold', + fontFamily: 'Arial', + offsetY: 0, + align: 'left', + text: 'Single-cell Epigenomic Analysis' + }; + + const titlePos = { x: 10, y: 0, width: 400, height: 24 }; + new TextTrack(titleOptions, pixiManager.makeContainer(titlePos)); +} From b435c8353bf8e9b767e7dc534049affb762b85a7 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 16:35:26 -0400 Subject: [PATCH 019/139] feat: add signals --- package.json | 1 + pnpm-lock.yaml | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/package.json b/package.json index 266bba59c..160ae1f3d 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@gmod/gff": "^1.3.0", "@gmod/tabix": "^1.5.6", "@gmod/vcf": "^5.0.10", + "@preact/signals-core": "^1.6.1", "allotment": "^1.19.0", "bezier-js": "4.0.3", "buffer": "^6.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4066ed9b0..17d8e1f31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ dependencies: '@gmod/vcf': specifier: ^5.0.10 version: 5.0.10 + '@preact/signals-core': + specifier: ^1.6.1 + version: 1.6.1 allotment: specifier: ^1.19.0 version: 1.19.3(react-dom@18.2.0)(react@18.2.0) @@ -1377,6 +1380,10 @@ packages: - vite dev: true + /@preact/signals-core@1.6.1: + resolution: {integrity: sha512-KXEEmJoKDlo0Igju/cj9YvKIgyaWFDgnprShQjzimUd5VynAAdTWMshawEOjUVeKbsI0aR58V6WOQp+DNcKApw==} + dev: false + /@sinclair/typebox@0.27.8: resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} dev: true From 52f6648e16fb4275e348e399b4ae6e11179561c9 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 16:35:46 -0400 Subject: [PATCH 020/139] feat: circular brush --- .../circular-brush/circular-brush-plot.ts | 107 ++++++ src/tracks/circular-brush/circular-brush.ts | 334 +++++++++++++++++ src/tracks/circular-brush/index.ts | 2 + src/tracks/gosling-brush/brush-track.ts | 350 ------------------ src/tracks/gosling-brush/index.ts | 2 - src/tracks/gosling-track/gosling-track.ts | 2 +- .../linear-brush-model.ts | 0 src/tracks/utils.ts | 10 + tsconfig.json | 2 +- 9 files changed, 455 insertions(+), 354 deletions(-) create mode 100644 src/tracks/circular-brush/circular-brush-plot.ts create mode 100644 src/tracks/circular-brush/circular-brush.ts create mode 100644 src/tracks/circular-brush/index.ts delete mode 100644 src/tracks/gosling-brush/brush-track.ts delete mode 100644 src/tracks/gosling-brush/index.ts rename src/tracks/{gosling-brush => gosling-track}/linear-brush-model.ts (100%) create mode 100644 src/tracks/utils.ts diff --git a/src/tracks/circular-brush/circular-brush-plot.ts b/src/tracks/circular-brush/circular-brush-plot.ts new file mode 100644 index 000000000..615e3a1ad --- /dev/null +++ b/src/tracks/circular-brush/circular-brush-plot.ts @@ -0,0 +1,107 @@ +import { + CircularBrushTrackClass, + type CircularBrushTrackOptions, + type CircularBrushTrackContext +} from './circular-brush'; +import { scaleLinear } from 'd3-scale'; +import { ZoomTransform, type D3ZoomEvent, zoom } from 'd3-zoom'; +import { select } from 'd3-selection'; +import { type Signal, effect } from '@preact/signals-core'; +import { zoomWheelBehavior } from '../utils'; + +export class CircularBrushTrack extends CircularBrushTrackClass { + xDomain: Signal; + xBrushDomain: Signal; + zoomStartScale = scaleLinear(); // This is the scale that we use to store the domain when the user starts zooming + #element: HTMLElement; // This is the div that we're going to apply the zoom behavior to + + constructor( + options: CircularBrushTrackOptions, + xDomain: Signal<[number, number]>, + xBrushDomain: Signal<[number, number]>, + overlayDiv: HTMLElement + ) { + const height = overlayDiv.clientHeight; + const width = overlayDiv.clientWidth; + // Create a new svg element. The brush will be drawn on this element + const svgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svgElement.style.width = `${width}px`; + svgElement.style.height = `${height}px`; + // Add it to the overlay div + overlayDiv.appendChild(svgElement); + + // Setup the context object + const context: CircularBrushTrackContext = { + id: 'test', + svgElement: svgElement, + getTheme: () => 'light', + registerViewportChanged: () => {}, + removeViewportChanged: () => {}, + setDomainsCallback: (xDomain: [number, number]) => (xBrushDomain.value = xDomain), + projectionXDomain: xBrushDomain.value + }; + + super(context, options); + + this.xDomain = xDomain; + this.xBrushDomain = xBrushDomain; + this.#element = overlayDiv; + // Now we need to initialize all of the properties that would normally be set by HiGlassComponent + this.setDimensions([width, height]); + this.setPosition([0, 0]); + // Create some scales to pass in + const refXScale = scaleLinear().domain(xDomain.value).range([0, width]); + const refYScale = scaleLinear(); // This doesn't get used anywhere but we need to pass it in + // Set the scales + this.zoomed(refXScale, refYScale); + this.refScalesChanged(refXScale, refYScale); + // Draw and add the zoom behavior + this.draw(); + this.#addZoom(); + + // When the brush signal changes, we want to update the brush + effect(() => { + const newXDomain = scaleLinear().domain(this.xBrushDomain.value); + this.viewportChanged(newXDomain, scaleLinear()); + }); + } + + #addZoom(): void { + // This function will be called every time the user zooms + const zoomed = (event: D3ZoomEvent) => { + const newXDomain = event.transform.rescaleX(this.zoomStartScale).domain(); + this.xDomain.value = newXDomain; + }; + + // Create the zoom behavior + const zoomBehavior = zoom() + .wheelDelta(zoomWheelBehavior) + .filter(event => { + // We don't want to zoom if the user is dragging a brush + const isRect = event.target.tagName === 'rect'; + const isMousedown = event.type === 'mousedown'; + const isDraggingBrush = isRect && isMousedown; + // Here are the default filters + const defaultFilter = (!event.ctrlKey || event.type === 'wheel') && !event.button; + // Use the default filter and our custom filter + return defaultFilter && !isDraggingBrush; + }) + // @ts-expect-error We need to reset the transform when the user stops zooming + .on('end', () => (this.#element.__zoom = new ZoomTransform(1, 0, 0))) + .on('start', () => { + this.zoomStartScale.domain(this.xDomain.value).range([0, this.#element.clientWidth]); + }) + .on('zoom', zoomed.bind(this)); + + // Apply the zoom behavior to the overlay div + select(this.#element).call(zoomBehavior); + + // This scale will always have the same range, but the domain will change in the effect + const baseScale = scaleLinear().domain(this.xDomain.value).range([0, this.#element.clientWidth]); + // Every time the domain gets changed we want to update the zoom + effect(() => { + const newScale = baseScale.domain(this.xDomain.value); + this.zoomed(newScale, this._refYScale); + }); + } +} diff --git a/src/tracks/circular-brush/circular-brush.ts b/src/tracks/circular-brush/circular-brush.ts new file mode 100644 index 000000000..c4483f989 --- /dev/null +++ b/src/tracks/circular-brush/circular-brush.ts @@ -0,0 +1,334 @@ +import { arc as d3arc, type Arc, type DefaultArcObject } from 'd3-shape'; +import { type SubjectPosition, type D3DragEvent, drag as d3Drag } from 'd3-drag'; +import { RADIAN_GAP, valueToRadian } from '../../core/utils/polar'; +import { uuid } from '../../core/utils/uuid'; +import { SVGTrack, type ViewportTrackerHorizontalContext } from '@higlass/tracks'; +import { type Selection } from 'd3-selection'; + +type CircularBrushData = { + type: 'brush' | 'start' | 'end'; + startAngle: number; + endAngle: number; + cursor: string; +}; + +export type CircularBrushTrackContext = ViewportTrackerHorizontalContext; + +export interface CircularBrushTrackOptions { + innerRadius: number; + outerRadius: number; + startAngle: number; + endAngle: number; + axisPositionHorizontal: 'left' | 'right'; + projectionFillColor: string; + projectionStrokeColor: string; + projectionFillOpacity: number; + projectionStrokeOpacity: number; + strokeWidth: number; +} +const defaultOptions: CircularBrushTrackOptions = { + innerRadius: 100, + outerRadius: 200, + startAngle: 0, + endAngle: 360, + axisPositionHorizontal: 'left', + projectionFillColor: '#777', + projectionStrokeColor: '#777', + projectionFillOpacity: 0.3, + projectionStrokeOpacity: 0.7, + strokeWidth: 1 +}; +export class CircularBrushTrackClass extends SVGTrack { + circularBrushData: CircularBrushData[]; + prevExtent: [number, number]; + uid: string; + hasFromView: boolean; + removeViewportChanged: (uid: string) => void; + setDomainsCallback: (xDomain: [number, number], yDomain: [number, number]) => void; + viewportXDomain: [number, number] | null; + viewportYDomain: [number, number] | null; + RR: number; + brush: Arc; + gBrush: Selection; + startEvent: any; + + constructor(context: CircularBrushTrackContext, options: CircularBrushTrackOptions) { + super(context, options); // context, options + const { registerViewportChanged, removeViewportChanged, setDomainsCallback } = context; + + this.uid = uuid(); + this.options = { ...defaultOptions, ...options }; + + // Is there actually a linked from view? Or is this projection "independent"? + this.hasFromView = !context.projectionXDomain; + + this.removeViewportChanged = removeViewportChanged; + this.setDomainsCallback = setDomainsCallback; + + this.viewportXDomain = this.hasFromView ? null : context.projectionXDomain; + this.viewportYDomain = this.hasFromView ? null : [0, 0]; + + this.prevExtent = [0, 0]; + this.RR = 0.02; // radian angle of resizers on the both sides + + const extent: [number, number] = [0, Math.PI * 1.7]; + this.circularBrushData = this.getBrushData(extent); + + this.brush = d3arc() + .innerRadius(this.options.innerRadius ?? 100) + .outerRadius(this.options.outerRadius ?? 200); + + this.gBrush = this.gMain + .append('g') + .attr('id', `brush-${this.uid}`) + .selectAll('.brush') + .data(this.circularBrushData) + .enter() + .append('path') + .attr('class', 'brush') + .attr('d', this.brush) + .attr('fill', this.options.projectionFillColor) + .attr('stroke', this.options.projectionStrokeColor) + // Let's hide left and right resizer + .attr('fill-opacity', (d: CircularBrushData) => + d.type === 'brush' ? this.options.projectionFillOpacity : 0 + ) + .attr('stroke-opacity', (d: CircularBrushData) => + d.type === 'brush' ? this.options.projectionStrokeOpacity : 0 + ) + .attr('stroke-width', this.options.strokeWidth) + .style('pointer-events', 'all') + .style('cursor', (d: CircularBrushData) => d.cursor) + .call(this.dragged()); + + // the viewport will call this.viewportChanged immediately upon hearing registerViewportChanged + registerViewportChanged(this.uid, this.viewportChanged.bind(this)); + + this.draw(); + } + + /** + * Get information for circular brush for given extent of angle. + */ + getBrushData(extent: [number, number]): CircularBrushData[] { + return [ + { + type: 'brush', + startAngle: extent[0], + endAngle: extent[1], + cursor: 'grab' + }, + { + type: 'start', + startAngle: extent[0], + endAngle: extent[0] + this.RR, + cursor: 'move' + }, + { + type: 'end', + startAngle: extent[1] - this.RR, + endAngle: extent[1], + cursor: 'move' + } + ]; + } + + cropExtent(extent: [number, number]): [number, number] { + let [s, e] = extent; + + let round = 0; + while (s > Math.PI * 2 || e > Math.PI * 2 || s < 0 || e < 0) { + if (round++ > 10) { + // this shifting process should be done in a single round, so reaching here shouldn't happen. + break; + } + + if (s > Math.PI * 2 || e > Math.PI * 2) { + s -= Math.PI * 2; + e -= Math.PI * 2; + } else if (s < 0 || e < 0) { + s += Math.PI * 2; + e += Math.PI * 2; + } + } + return ([s, e] as number[]).sort((a, b) => a - b) as [number, number]; + } + + /** + * Update the position and size of brush. + */ + updateBrush(extent: [number, number]) { + let [s, e] = extent; + + if ((s <= RADIAN_GAP && e <= RADIAN_GAP) || (s >= Math.PI * 2 - RADIAN_GAP && e >= Math.PI * 2 - RADIAN_GAP)) { + // this means [s, e] is entirely out of the visible area, so simply hide the brush + this.gBrush.attr('visibility', 'hidden'); + return; + } + + // crop angles if they are out of the visible area + if (s < RADIAN_GAP) { + s = RADIAN_GAP; + } + if (s > Math.PI * 2 - RADIAN_GAP) { + s = Math.PI * 2 - RADIAN_GAP; + } + if (e < RADIAN_GAP) { + e = RADIAN_GAP; + } + if (e > Math.PI * 2 - RADIAN_GAP) { + e = Math.PI * 2 - RADIAN_GAP; + } + + this.circularBrushData = this.getBrushData(extent); + this.gBrush.data(this.circularBrushData).attr('d', this.brush).attr('visibility', 'visible'); + } + + /** + * Function to call upon hearing click event on the brush + */ + dragged() { + const start = (event: D3DragEvent) => { + this.startEvent = event.sourceEvent; + this.prevExtent = [this.circularBrushData[0].startAngle, this.circularBrushData[0].endAngle]; + }; + + const drag = (event: D3DragEvent, d: CircularBrushData) => { + const [x, y] = this.position; + const [w, h] = this.dimensions; + const endEvent = event.sourceEvent; + + // adjust the position + const startX = this.startEvent.layerX - x; + const startY = this.startEvent.layerY - y; + const endX = endEvent.layerX - x; + const endY = endEvent.layerY - y; + + // calculate the radian difference from the drag event + // rotate the origin +90 degree so that it is positioned on the 12 O'clock + const radDiff = + // radian of the start position + Math.atan2(startX - w / 2.0, startY - h / 2.0) - + // radian of the current position + Math.atan2(endX - w / 2.0, endY - h / 2.0); + + // previous extent of brush + let [s, e] = this.prevExtent; + + if (d.type === 'brush') { + s = s + radDiff; + e = e + radDiff; + + if (s < RADIAN_GAP || Math.PI * 2 - RADIAN_GAP < e) { + // This means [s, e] contains the origin, i.e., 12 O'clock + const sto = RADIAN_GAP - s; + const eto = e - (Math.PI * 2 - RADIAN_GAP); + + if (sto > eto) { + // Place the brush on the right side of the origin + e += sto; + s += sto; + } else { + // Place the brush on the left side of the origin + s -= eto; + e -= eto; + } + } + } else if (d.type === 'start') { + s = s + radDiff; + } else if (d.type === 'end') { + e = e + radDiff; + } + + [s, e] = this.cropExtent([s, e]); + + if (!this._xScale || !this._yScale) { + return; + } + + const scale = (this.options.endAngle - this.options.startAngle) / 360; + const offsetedS = s - (this.options.startAngle / 360) * Math.PI * 2; + const offsetedE = e - (this.options.startAngle / 360) * Math.PI * 2; + const xDomain = [ + this._xScale.invert(w - (w * offsetedE) / (Math.PI * 2 * scale)), + this._xScale.invert(w - (w * offsetedS) / (Math.PI * 2 * scale)) + ]; + + const yDomain = this.viewportYDomain; + + if (!this.hasFromView) { + this.viewportXDomain = xDomain; + } + + this.setDomainsCallback(xDomain, yDomain); + + this.updateBrush([s, e]); + }; + + return d3Drag().on('start', start).on('drag', drag); + } + + draw() { + if (!this._xScale || !this.yScale) { + return; + } + + if (!this.viewportXDomain || !this.viewportYDomain) { + return; + } + + const x0 = this._xScale(this.viewportXDomain[0]); + const x1 = this._xScale(this.viewportXDomain[1]); + + const [w] = this.dimensions; + let e = valueToRadian(x0, w, this.options.startAngle, this.options.endAngle) + Math.PI / 2.0; + let s = valueToRadian(x1, w, this.options.startAngle, this.options.endAngle) + Math.PI / 2.0; + + [s, e] = this.cropExtent([s, e]); + + this.updateBrush([s, e]); + } + + viewportChanged(viewportXScale: any, viewportYScale: any) { + const viewportXDomain = viewportXScale.domain(); + const viewportYDomain = viewportYScale.domain(); + + this.viewportXDomain = viewportXDomain; + this.viewportYDomain = viewportYDomain; + + this.draw(); + } + + remove() { + // remove the event handler that updates this viewport tracker + this.removeViewportChanged(this.uid); + + super.remove(); + } + + rerender() { + // !!! TODO: when does this called? + } + + zoomed(newXScale: any, newYScale: any) { + this.xScale(newXScale); + this.yScale(newYScale); + + this.draw(); + } + + setPosition(newPosition: any) { + super.setPosition(newPosition); + + this.draw(); + } + + setDimensions(newDimensions: any) { + super.setDimensions(newDimensions); + + // change the position + this.gBrush.attr('transform', `translate(${newDimensions[0] / 2.0},${newDimensions[1] / 2.0})`); + + this.draw(); + } +} diff --git a/src/tracks/circular-brush/index.ts b/src/tracks/circular-brush/index.ts new file mode 100644 index 000000000..061546e7b --- /dev/null +++ b/src/tracks/circular-brush/index.ts @@ -0,0 +1,2 @@ +export { CircularBrushTrack } from './circular-brush-plot'; +export type { CircularBrushTrackOptions, CircularBrushTrackContext } from './circular-brush'; diff --git a/src/tracks/gosling-brush/brush-track.ts b/src/tracks/gosling-brush/brush-track.ts deleted file mode 100644 index 728b38d30..000000000 --- a/src/tracks/gosling-brush/brush-track.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { arc as d3arc } from 'd3-shape'; -import type { SubjectPosition, D3DragEvent } from 'd3-drag'; -import { RADIAN_GAP, valueToRadian } from '../../core/utils/polar'; -import { uuid } from '../../core/utils/uuid'; - -type CircularBrushData = { - type: 'brush' | 'start' | 'end'; - startAngle: number; - endAngle: number; - cursor: string; -}; - -function BrushTrack(HGC: any, ...args: any[]): any { - if (!new.target) { - throw new Error('Uncaught TypeError: Class constructor cannot be invoked without "new"'); - } - - class BrushTrackClass extends HGC.tracks.SVGTrack { - public circularBrushData: CircularBrushData[]; - public prevExtent: [number, number]; - - constructor(params: any[]) { - super(...params); // context, options - - const [context, options] = params; - const { registerViewportChanged, removeViewportChanged, setDomainsCallback } = context; - - this.uid = uuid(); - this.options = options; - - // Is there actually a linked from view? Or is this projection "independent"? - this.hasFromView = !context.projectionXDomain; - - this.removeViewportChanged = removeViewportChanged; - this.setDomainsCallback = setDomainsCallback; - - this.viewportXDomain = this.hasFromView ? null : context.projectionXDomain; - this.viewportYDomain = this.hasFromView ? null : [0, 0]; - - this.prevExtent = [0, 0]; - this.RR = 0.02; // radian angle of resizers on the both sides - - const extent: [number, number] = [0, Math.PI * 1.7]; - this.circularBrushData = this.getBrushData(extent); - - this.brush = d3arc() - .innerRadius(this.options.innerRadius ?? 100) - .outerRadius(this.options.outerRadius ?? 200); - - this.gBrush = this.gMain - .append('g') - .attr('id', `brush-${this.uid}`) - .selectAll('.brush') - .data(this.circularBrushData) - .enter() - .append('path') - .attr('class', 'brush') - .attr('d', this.brush) - .attr('fill', this.options.projectionFillColor) - .attr('stroke', this.options.projectionStrokeColor) - // Let's hide left and right resizer - .attr('fill-opacity', (d: CircularBrushData) => - d.type === 'brush' ? this.options.projectionFillOpacity : 0 - ) - .attr('stroke-opacity', (d: CircularBrushData) => - d.type === 'brush' ? this.options.projectionStrokeOpacity : 0 - ) - .attr('stroke-width', this.options.strokeWidth) - .style('pointer-events', 'all') - .style('cursor', (d: CircularBrushData) => d.cursor) - .call(this.dragged()); - - // the viewport will call this.viewportChanged immediately upon hearing registerViewportChanged - registerViewportChanged(this.uid, this.viewportChanged.bind(this)); - - this.draw(); - } - - /** - * Get information for circular brush for given extent of angle. - */ - getBrushData(extent: [number, number]): CircularBrushData[] { - return [ - { - type: 'brush', - startAngle: extent[0], - endAngle: extent[1], - cursor: 'grab' - }, - { - type: 'start', - startAngle: extent[0], - endAngle: extent[0] + this.RR, - cursor: 'move' - }, - { - type: 'end', - startAngle: extent[1] - this.RR, - endAngle: extent[1], - cursor: 'move' - } - ]; - } - - cropExtent(extent: [number, number]): [number, number] { - let [s, e] = extent; - - let round = 0; - while (s > Math.PI * 2 || e > Math.PI * 2 || s < 0 || e < 0) { - if (round++ > 10) { - // this shifting process should be done in a single round, so reaching here shouldn't happen. - break; - } - - if (s > Math.PI * 2 || e > Math.PI * 2) { - s -= Math.PI * 2; - e -= Math.PI * 2; - } else if (s < 0 || e < 0) { - s += Math.PI * 2; - e += Math.PI * 2; - } - } - return ([s, e] as number[]).sort((a, b) => a - b) as [number, number]; - } - - /** - * Update the position and size of brush. - */ - updateBrush(extent: [number, number]) { - let [s, e] = extent; - - if ( - (s <= RADIAN_GAP && e <= RADIAN_GAP) || - (s >= Math.PI * 2 - RADIAN_GAP && e >= Math.PI * 2 - RADIAN_GAP) - ) { - // this means [s, e] is entirely out of the visible area, so simply hide the brush - this.gBrush.attr('visibility', 'hidden'); - return; - } - - // crop angles if they are out of the visible area - if (s < RADIAN_GAP) { - s = RADIAN_GAP; - } - if (s > Math.PI * 2 - RADIAN_GAP) { - s = Math.PI * 2 - RADIAN_GAP; - } - if (e < RADIAN_GAP) { - e = RADIAN_GAP; - } - if (e > Math.PI * 2 - RADIAN_GAP) { - e = Math.PI * 2 - RADIAN_GAP; - } - - this.circularBrushData = this.getBrushData(extent); - this.gBrush.data(this.circularBrushData).attr('d', this.brush).attr('visibility', 'visible'); - } - - /** - * Function to call upon hearing click event on the brush - */ - dragged() { - const start = (event: D3DragEvent) => { - this.startEvent = event.sourceEvent; - this.prevExtent = [this.circularBrushData[0].startAngle, this.circularBrushData[0].endAngle]; - }; - - const drag = (event: D3DragEvent, d: CircularBrushData) => { - const [x, y] = this.position; - const [w, h] = this.dimensions; - const endEvent = event.sourceEvent; - - // adjust the position - const startX = this.startEvent.layerX - x; - const startY = this.startEvent.layerY - y; - const endX = endEvent.layerX - x; - const endY = endEvent.layerY - y; - - // calculate the radian difference from the drag event - // rotate the origin +90 degree so that it is positioned on the 12 O'clock - const radDiff = - // radian of the start position - Math.atan2(startX - w / 2.0, startY - h / 2.0) - - // radian of the current position - Math.atan2(endX - w / 2.0, endY - h / 2.0); - - // previous extent of brush - let [s, e] = this.prevExtent; - - if (d.type === 'brush') { - s = s + radDiff; - e = e + radDiff; - - if (s < RADIAN_GAP || Math.PI * 2 - RADIAN_GAP < e) { - // This means [s, e] contains the origin, i.e., 12 O'clock - const sto = RADIAN_GAP - s; - const eto = e - (Math.PI * 2 - RADIAN_GAP); - - if (sto > eto) { - // Place the brush on the right side of the origin - e += sto; - s += sto; - } else { - // Place the brush on the left side of the origin - s -= eto; - e -= eto; - } - } - } else if (d.type === 'start') { - s = s + radDiff; - } else if (d.type === 'end') { - e = e + radDiff; - } - - [s, e] = this.cropExtent([s, e]); - - if (!this._xScale || !this._yScale) { - return; - } - - const scale = (this.options.endAngle - this.options.startAngle) / 360; - const offsetedS = s - (this.options.startAngle / 360) * Math.PI * 2; - const offsetedE = e - (this.options.startAngle / 360) * Math.PI * 2; - const xDomain = [ - this._xScale.invert(w - (w * offsetedE) / (Math.PI * 2 * scale)), - this._xScale.invert(w - (w * offsetedS) / (Math.PI * 2 * scale)) - ]; - - const yDomain = this.viewportYDomain; - - if (!this.hasFromView) { - this.viewportXDomain = xDomain; - } - - this.setDomainsCallback(xDomain, yDomain); - - this.updateBrush([s, e]); - }; - - return HGC.libraries.d3Drag.drag().on('start', start).on('drag', drag); - } - - draw() { - if (!this._xScale || !this.yScale) { - return; - } - - if (!this.viewportXDomain || !this.viewportYDomain) { - return; - } - - const x0 = this._xScale(this.viewportXDomain[0]); - const x1 = this._xScale(this.viewportXDomain[1]); - - const [w] = this.dimensions; - let e = valueToRadian(x0, w, this.options.startAngle, this.options.endAngle) + Math.PI / 2.0; - let s = valueToRadian(x1, w, this.options.startAngle, this.options.endAngle) + Math.PI / 2.0; - - [s, e] = this.cropExtent([s, e]); - - this.updateBrush([s, e]); - } - - viewportChanged(viewportXScale: any, viewportYScale: any) { - const viewportXDomain = viewportXScale.domain(); - const viewportYDomain = viewportYScale.domain(); - - this.viewportXDomain = viewportXDomain; - this.viewportYDomain = viewportYDomain; - - this.draw(); - } - - remove() { - // remove the event handler that updates this viewport tracker - this.removeViewportChanged(this.uid); - - super.remove(); - } - - rerender() { - // !!! TODO: when does this called? - } - - zoomed(newXScale: any, newYScale: any) { - this.xScale(newXScale); - this.yScale(newYScale); - - this.draw(); - } - - setPosition(newPosition: any) { - super.setPosition(newPosition); - - this.draw(); - } - - setDimensions(newDimensions: any) { - super.setDimensions(newDimensions); - - // change the position - this.gBrush.attr('transform', `translate(${newDimensions[0] / 2.0},${newDimensions[1] / 2.0})`); - - this.draw(); - } - } - - return new BrushTrackClass(args); -} - -// TODO: Change the icon -const icon = - ' '; - -// TODO: -// default -BrushTrack.config = { - type: 'brush-track', - datatype: ['projection'], - local: false, // TODO: - projection: true, - orientation: '2d', - thumbnail: new DOMParser().parseFromString(icon, 'text/xml').documentElement, - availableOptions: [ - 'innerRadius', - 'outerRadius', - 'startAngle', - 'endAngle', - 'axisPositionHorizontal', - 'projectionFillColor', - 'projectionStrokeColor', - 'projectionFillOpacity', - 'projectionStrokeOpacity', - 'strokeWidth' - ], - defaultOptions: { - innerRadius: 100, - outerRadius: 200, - startAngle: 0, - endAngle: 360, - axisPositionHorizontal: 'left', - projectionFillColor: '#777', - projectionStrokeColor: '#777', - projectionFillOpacity: 0.3, - projectionStrokeOpacity: 0.7, - strokeWidth: 1 - } -}; - -export default BrushTrack; diff --git a/src/tracks/gosling-brush/index.ts b/src/tracks/gosling-brush/index.ts deleted file mode 100644 index 87b68155f..000000000 --- a/src/tracks/gosling-brush/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as BrushTrack } from './brush-track'; -export { LinearBrushModel } from './linear-brush-model'; diff --git a/src/tracks/gosling-track/gosling-track.ts b/src/tracks/gosling-track/gosling-track.ts index aa361623a..00c766470 100644 --- a/src/tracks/gosling-track/gosling-track.ts +++ b/src/tracks/gosling-track/gosling-track.ts @@ -14,7 +14,7 @@ import type { import { type MouseEventData, isPointInsideDonutSlice } from '../gosling-track/gosling-mouse-event'; import { BamDataFetcher, type TabularDataFetcher } from '@data-fetchers'; import type { Tile as _Tile, TileData, TileDataBase } from '@higlass/services'; -import { LinearBrushModel } from '@gosling-lang/gosling-brush'; +import { LinearBrushModel } from './linear-brush-model'; import { getTheme } from '@gosling-lang/gosling-theme'; import { getTabularData } from './data-abstraction'; diff --git a/src/tracks/gosling-brush/linear-brush-model.ts b/src/tracks/gosling-track/linear-brush-model.ts similarity index 100% rename from src/tracks/gosling-brush/linear-brush-model.ts rename to src/tracks/gosling-track/linear-brush-model.ts diff --git a/src/tracks/utils.ts b/src/tracks/utils.ts new file mode 100644 index 000000000..746e45d59 --- /dev/null +++ b/src/tracks/utils.ts @@ -0,0 +1,10 @@ +// Default d3 zoom feels slow so we use this instead +// https://d3js.org/d3-zoom#zoom_wheelDelta +export function zoomWheelBehavior(event: WheelEvent) { + const defaultMultiplier = 5; + return ( + -event.deltaY * + (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002) * + (event.ctrlKey ? 10 : defaultMultiplier) + ); +} diff --git a/tsconfig.json b/tsconfig.json index a7e6c0ae7..548becaa0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,7 +29,7 @@ "@gosling-lang/gosling-theme": ["src/gosling-theme/index.ts"], "@gosling-lang/gosling-track": ["./src/tracks/gosling-track/index.ts"], "@gosling-lang/gosling-genomic-axis": ["./src/tracks/gosling-genomic-axis/index.ts"], - "@gosling-lang/gosling-brush": ["./src/tracks/gosling-brush/index.ts"], + "@gosling-lang/gosling-brush": ["src/tracks/circular-brush/index.ts"], "@gosling-lang/dummy-track": ["./src/tracks/dummy-track/index.ts"], "@gosling-lang/text-track": ["./src/tracks/text-track/index.ts"], "@pixi-manager": ["./src/pixi-manager/index.ts"], From 91398aa58725ffcf130f1b490e0d85560d920e3d Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 17:04:37 -0400 Subject: [PATCH 021/139] fix: alias name --- tsconfig.json | 2 +- vite.config.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index 548becaa0..adff330f8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,7 +29,7 @@ "@gosling-lang/gosling-theme": ["src/gosling-theme/index.ts"], "@gosling-lang/gosling-track": ["./src/tracks/gosling-track/index.ts"], "@gosling-lang/gosling-genomic-axis": ["./src/tracks/gosling-genomic-axis/index.ts"], - "@gosling-lang/gosling-brush": ["src/tracks/circular-brush/index.ts"], + "@gosling-lang/circular-brush": ["src/tracks/circular-brush/index.ts"], "@gosling-lang/dummy-track": ["./src/tracks/dummy-track/index.ts"], "@gosling-lang/text-track": ["./src/tracks/text-track/index.ts"], "@pixi-manager": ["./src/pixi-manager/index.ts"], diff --git a/vite.config.js b/vite.config.js index 6ecd4f950..3fb3d32b0 100644 --- a/vite.config.js +++ b/vite.config.js @@ -71,7 +71,7 @@ const alias = { '@gosling-lang/gosling-theme': path.resolve(__dirname, './src/gosling-theme/index.ts'), '@gosling-lang/gosling-track': path.resolve(__dirname, './src/tracks/gosling-track/index.ts'), '@gosling-lang/gosling-genomic-axis': path.resolve(__dirname, './src/tracks/gosling-genomic-axis/index.ts'), - '@gosling-lang/gosling-brush': path.resolve(__dirname, './src/tracks/gosling-brush/index.ts'), + '@gosling-lang/circular-brush': path.resolve(__dirname, './src/tracks/circular-brush/index.ts'), '@gosling-lang/dummy-track': path.resolve(__dirname, './src/tracks/dummy-track/index.ts'), '@gosling-lang/text-track': path.resolve(__dirname, './src/tracks/text-track/index.ts'), '@pixi-manager': path.resolve(__dirname, './src/pixi-manager/index.ts'), From 8a39ab2a03a3e5db364c0f7e82a230417b8a2b97 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 17:04:48 -0400 Subject: [PATCH 022/139] feat: circular brush example --- demo/App.tsx | 3 +- demo/examples/circular-brush-example.ts | 29 +++++++++++++++++++ ...{dummy-track.ts => dummy-track-example.ts} | 0 demo/examples/index.ts | 5 ++-- .../{text-track.ts => text-track-example.ts} | 0 5 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 demo/examples/circular-brush-example.ts rename demo/examples/{dummy-track.ts => dummy-track-example.ts} (100%) rename demo/examples/{text-track.ts => text-track-example.ts} (100%) diff --git a/demo/App.tsx b/demo/App.tsx index 5f5b5ac5d..d75670f3e 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { PixiManager } from '@pixi-manager'; -import { addDummyTrack, addTextTrack } from './examples'; +import { addDummyTrack, addTextTrack, addCircularBrush } from './examples'; import './App.css'; @@ -15,6 +15,7 @@ function App() { const pixiManager = new PixiManager(1000, 600, plotElement, setFps); addTextTrack(pixiManager); addDummyTrack(pixiManager); + addCircularBrush(pixiManager); }, []); return ( diff --git a/demo/examples/circular-brush-example.ts b/demo/examples/circular-brush-example.ts new file mode 100644 index 000000000..2851531fc --- /dev/null +++ b/demo/examples/circular-brush-example.ts @@ -0,0 +1,29 @@ +import { PixiManager } from '@pixi-manager'; +import { CircularBrushTrack } from '@gosling-lang/circular-brush'; +import { signal } from '@preact/signals-core'; + +export function addCircularBrush(pixiManager: PixiManager) { + const pos0 = { x: 10, y: 100, width: 250, height: 250 }; + const circularDomain = signal<[number, number]>([0, 248956422]); + const detailedDomain = signal<[number, number]>([160000000, 200000000]); + + const circularBrushTrackOptions = { + projectionFillColor: 'gray', + projectionStrokeColor: 'black', + projectionFillOpacity: 0.3, + projectionStrokeOpacity: 0.3, + strokeWidth: 1, + startAngle: 7.2, + endAngle: 352.8, + innerRadius: 50, + outerRadius: 125, + axisPositionHorizontal: 'left' + }; + + new CircularBrushTrack( + circularBrushTrackOptions, + circularDomain, + detailedDomain, + pixiManager.makeContainer(pos0).overlayDiv + ); +} diff --git a/demo/examples/dummy-track.ts b/demo/examples/dummy-track-example.ts similarity index 100% rename from demo/examples/dummy-track.ts rename to demo/examples/dummy-track-example.ts diff --git a/demo/examples/index.ts b/demo/examples/index.ts index c61bcbba2..d4f642815 100644 --- a/demo/examples/index.ts +++ b/demo/examples/index.ts @@ -1,2 +1,3 @@ -export { addDummyTrack } from './dummy-track'; -export { addTextTrack } from './text-track'; +export { addDummyTrack } from './dummy-track-example'; +export { addTextTrack } from './text-track-example'; +export { addCircularBrush } from './circular-brush-example'; diff --git a/demo/examples/text-track.ts b/demo/examples/text-track-example.ts similarity index 100% rename from demo/examples/text-track.ts rename to demo/examples/text-track-example.ts From 29bc2aa10e242d40c4434b1f681f8e20f5643719 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 17:55:12 -0400 Subject: [PATCH 023/139] feat: tile proxy --- src/higlass/services.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/higlass/services.ts diff --git a/src/higlass/services.ts b/src/higlass/services.ts new file mode 100644 index 000000000..2b195b95f --- /dev/null +++ b/src/higlass/services.ts @@ -0,0 +1 @@ +export { tileProxy } from './higlass-vendored'; From f1208862f265963bc5c4297e6eed755d2d36bc99 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 17:55:55 -0400 Subject: [PATCH 024/139] feat: basic gosling track --- src/tracks/gosling-track/gosling-track.ts | 2525 ++++++++++----------- 1 file changed, 1256 insertions(+), 1269 deletions(-) diff --git a/src/tracks/gosling-track/gosling-track.ts b/src/tracks/gosling-track/gosling-track.ts index 00c766470..ae0d1fafe 100644 --- a/src/tracks/gosling-track/gosling-track.ts +++ b/src/tracks/gosling-track/gosling-track.ts @@ -54,6 +54,13 @@ import { createPluginTrack, type PluginTrackFactory, type TrackConfig } from '.. import { uuid } from '../../core/utils/uuid'; import type { Scale, TilePosition } from '@higlass/tracks'; +// Additions +import { tileProxy } from '@higlass/services'; +import { TiledPixiTrack } from '@higlass/tracks'; +import { select, type Selection } from 'd3-selection'; +import { format } from 'd3-format'; +import { calculate1DVisibleTiles } from './utils'; + // Set `true` to print in what order each function is called export const PRINT_RENDERING_CYCLE = false; @@ -133,1177 +140,1267 @@ const config: TrackConfig = { } }; -const factory: PluginTrackFactory = (HGC, context, options) => { - // Services - const { tileProxy } = HGC.services; - const { TiledPixiTrack } = HGC.tracks; - - /* Custom loading label */ - const loadingTextStyle = getTextStyle({ color: 'black', size: 12 }); - - /** - * The main plugin track in Gosling. This is a versetile plugin track for HiGlass which relies on GoslingTrackModel - * to keep track of mouse event and channel scales. - */ - class GoslingTrackClass extends TiledPixiTrack { - /* * - * - * Properties - * - * */ - - tileSize: number; - mRangeBrush: LinearBrushModel; - #assembly?: Assembly; // Used to get the relative genomic position - #processedTileInfo: Record; - firstDraw = true; // False if draw has been called once already. Used with onNewTrack API. Public because used in draw() - // Used in mark/legend.ts - gLegend? = HGC.libraries.d3Selection.select(context.svgElement).append('g'); - displayedLegends: DisplayedLegend[] = []; // Store the color legends added so far so that we can avoid overlaps and redundancy - // Used in mark/text.ts - textGraphics: unknown[] = []; - textsBeingUsed = 0; - // Mouse fields - pMouseHover = new HGC.libraries.PIXI.Graphics(); - pMouseSelection = new HGC.libraries.PIXI.Graphics(); - #mouseDownX = 0; - #mouseDownY = 0; - #isRangeBrushActivated = false; - #gBrush = HGC.libraries.d3Selection.select(context.svgElement).append('g'); - #loadingTextStyleObj = new HGC.libraries.PIXI.TextStyle(loadingTextStyle); - #loadingTextBg = new HGC.libraries.PIXI.Graphics(); - #loadingText = new HGC.libraries.PIXI.Text('', loadingTextStyle); - prevVisibleAndFetchedTiles?: Tile[]; - resolvedTracks: SingleTrack[] | undefined; - // This is used to persist processed tile data across draw() calls. - #processedTileMap: WeakMap = new WeakMap(); - - /* * - * - * Constructor - * - * */ - - constructor() { - super(context, options); - const { isShowGlobalMousePosition } = context; - - context.dataFetcher.track = this; - this.#processedTileInfo = {}; - this.#assembly = this.options.spec.assembly; - - // Add unique IDs to each of the overlaid tracks that will be rendered independently. - if ('overlay' in this.options.spec) { - this.options.spec.overlay = (this.options.spec as OverlaidTrack)._overlay.map(o => { - return { ...o, _renderingId: uuid() }; - }); - } else { - this.options.spec._renderingId = uuid(); - } - - this.fetchedTiles = {}; - this.tileSize = this.tilesetInfo?.tile_size ?? 1024; - - const { valid, errorMessages } = validateTrack(this.options.spec); +/* Custom loading label */ +const loadingTextStyle = getTextStyle({ color: 'black', size: 12 }); + +class GoslingTrackClass extends TiledPixiTrack { + /* * + * + * Properties + * + * */ + + tileSize: number; + mRangeBrush: LinearBrushModel; + #assembly?: Assembly; // Used to get the relative genomic position + #processedTileInfo: Record; + firstDraw = true; // False if draw has been called once already. Used with onNewTrack API. Public because used in draw() + // Used in mark/legend.ts + gLegend? = HGC.libraries.d3Selection.select(context.svgElement).append('g'); + displayedLegends: DisplayedLegend[] = []; // Store the color legends added so far so that we can avoid overlaps and redundancy + // Used in mark/text.ts + textGraphics: unknown[] = []; + textsBeingUsed = 0; + // Mouse fields + pMouseHover = new HGC.libraries.PIXI.Graphics(); + pMouseSelection = new HGC.libraries.PIXI.Graphics(); + #mouseDownX = 0; + #mouseDownY = 0; + #isRangeBrushActivated = false; + #gBrush = HGC.libraries.d3Selection.select(context.svgElement).append('g'); + #loadingTextStyleObj = new HGC.libraries.PIXI.TextStyle(loadingTextStyle); + #loadingTextBg = new HGC.libraries.PIXI.Graphics(); + #loadingText = new HGC.libraries.PIXI.Text('', loadingTextStyle); + prevVisibleAndFetchedTiles?: Tile[]; + resolvedTracks: SingleTrack[] | undefined; + // This is used to persist processed tile data across draw() calls. + #processedTileMap: WeakMap = new WeakMap(); + + /* * + * + * Constructor + * + * */ + + constructor() { + super(context, options); + const { isShowGlobalMousePosition } = context; + + context.dataFetcher.track = this; + this.#processedTileInfo = {}; + this.#assembly = this.options.spec.assembly; + + // Add unique IDs to each of the overlaid tracks that will be rendered independently. + if ('overlay' in this.options.spec) { + this.options.spec.overlay = (this.options.spec as OverlaidTrack)._overlay.map(o => { + return { ...o, _renderingId: uuid() }; + }); + } else { + this.options.spec._renderingId = uuid(); + } - if (!valid) { - console.warn('The specification of the following track is invalid', errorMessages, this.options.spec); - } + this.fetchedTiles = {}; + this.tileSize = this.tilesetInfo?.tile_size ?? 1024; - // Graphics for highlighting visual elements under the cursor - this.pMain.addChild(this.pMouseHover); - this.pMain.addChild(this.pMouseSelection); + const { valid, errorMessages } = validateTrack(this.options.spec); - // Enable click event - this.pMask.interactive = true; - this.mRangeBrush = new LinearBrushModel(this.#gBrush, HGC.libraries, this.options.spec.style?.brush); - this.mRangeBrush.on('brush', this.#onRangeBrush.bind(this)); + if (!valid) { + console.warn('The specification of the following track is invalid', errorMessages, this.options.spec); + } - this.pMask.on('mousedown', (e: PIXI.InteractionEvent) => { - const { x, y } = e.data.getLocalPosition(this.pMain); - this.#onMouseDown(x, y, e.data.originalEvent.altKey); - }); - this.pMask.on('mouseup', (e: PIXI.InteractionEvent) => { - const { x, y } = e.data.getLocalPosition(this.pMain); - this.#onMouseUp(x, y); - }); - this.pMask.on('mousemove', (e: PIXI.InteractionEvent) => { - const { x } = e.data.getLocalPosition(this.pMain); - this.#onMouseMove(x); - }); - this.pMask.on('mouseout', this.#onMouseOut.bind(this)); - this.flipText = this.options.spec.orientation === 'vertical'; - - // Draw the mouse position - // See https://github.com/higlass/higlass/blob/38f0c4415f0595c3b9d685a754d6661dc9612f7c/app/scripts/utils/show-mouse-position.js#L28 - if (this.options?.showMousePosition && !this.hideMousePosition) { - this.hideMousePosition = HGC.utils.showMousePosition( - this, - Is2DTrack(this.getResolvedTracks()[0]), - isShowGlobalMousePosition() - ); - } + // Graphics for highlighting visual elements under the cursor + this.pMain.addChild(this.pMouseHover); + this.pMain.addChild(this.pMouseSelection); + + // Enable click event + this.pMask.interactive = true; + this.mRangeBrush = new LinearBrushModel(this.#gBrush, HGC.libraries, this.options.spec.style?.brush); + this.mRangeBrush.on('brush', this.#onRangeBrush.bind(this)); + + this.pMask.on('mousedown', (e: PIXI.InteractionEvent) => { + const { x, y } = e.data.getLocalPosition(this.pMain); + this.#onMouseDown(x, y, e.data.originalEvent.altKey); + }); + this.pMask.on('mouseup', (e: PIXI.InteractionEvent) => { + const { x, y } = e.data.getLocalPosition(this.pMain); + this.#onMouseUp(x, y); + }); + this.pMask.on('mousemove', (e: PIXI.InteractionEvent) => { + const { x } = e.data.getLocalPosition(this.pMain); + this.#onMouseMove(x); + }); + this.pMask.on('mouseout', this.#onMouseOut.bind(this)); + this.flipText = this.options.spec.orientation === 'vertical'; + + // Draw the mouse position + // See https://github.com/higlass/higlass/blob/38f0c4415f0595c3b9d685a754d6661dc9612f7c/app/scripts/utils/show-mouse-position.js#L28 + if (this.options?.showMousePosition && !this.hideMousePosition) { + this.hideMousePosition = HGC.utils.showMousePosition( + this, + Is2DTrack(this.getResolvedTracks()[0]), + isShowGlobalMousePosition() + ); + } - // We do not use HiGlass' trackNotFoundText - this.pLabel.removeChild(this.trackNotFoundText); + // We do not use HiGlass' trackNotFoundText + this.pLabel.removeChild(this.trackNotFoundText); - this.#loadingText.anchor.x = 1; - this.#loadingText.anchor.y = 1; - this.pLabel.addChild(this.#loadingTextBg); - this.pLabel.addChild(this.#loadingText); + this.#loadingText.anchor.x = 1; + this.#loadingText.anchor.y = 1; + this.pLabel.addChild(this.#loadingTextBg); + this.pLabel.addChild(this.#loadingText); - // This improves the arc/link rendering performance - HGC.libraries.PIXI.GRAPHICS_CURVES.adaptive = this.options.spec.style?.enableSmoothPath ?? false; - if (HGC.libraries.PIXI.GRAPHICS_CURVES.adaptive) { - HGC.libraries.PIXI.GRAPHICS_CURVES.maxLength = 1; - HGC.libraries.PIXI.GRAPHICS_CURVES.maxSegments = 2048 * 10; - } + // This improves the arc/link rendering performance + HGC.libraries.PIXI.GRAPHICS_CURVES.adaptive = this.options.spec.style?.enableSmoothPath ?? false; + if (HGC.libraries.PIXI.GRAPHICS_CURVES.adaptive) { + HGC.libraries.PIXI.GRAPHICS_CURVES.maxLength = 1; + HGC.libraries.PIXI.GRAPHICS_CURVES.maxSegments = 2048 * 10; } + } - /* * - * - * Rendering Cycle Methods - * - * */ + /* * + * + * Rendering Cycle Methods + * + * */ - /** - * Draw all tiles from the bottom. Called from TiledPixiTrack constructor, so all methods called must be - * public. https://github.com/higlass/higlass/blob/387a03e877dcfa4c2cfeabc0869375b58c0b362d/app/scripts/TiledPixiTrack.js#L216 - * Overrides draw() in BarTrack. - * This means some class properties can be still `undefined`. - */ - override draw() { - if (PRINT_RENDERING_CYCLE) console.warn('draw()'); - this.clearMouseEventData(); - this.textsBeingUsed = 0; - this.pMouseHover?.clear(); - - const processTilesAndDraw = () => { - // Should we force to process all tiles? - // For BAM, yes, since all tiles are stored in a single tile and visible tiles had been changed. - const isBamDataFetcher = this.dataFetcher instanceof BamDataFetcher; - - // Preprocess all tiles at once so that we can share scales across tiles. - this.processAllTiles(isBamDataFetcher); - - // This function calls `drawTile` on each tile. - super.draw(); - - // From BarTrack - Object.values(this.fetchedTiles).forEach(tile => { - if (!tile.drawnAtScale) return; - [tile.graphics.scale.x, tile.graphics.position.x] = this.getXScaleAndOffset(tile.drawnAtScale); - }); + /** + * Draw all tiles from the bottom. Called from TiledPixiTrack constructor, so all methods called must be + * public. https://github.com/higlass/higlass/blob/387a03e877dcfa4c2cfeabc0869375b58c0b362d/app/scripts/TiledPixiTrack.js#L216 + * Overrides draw() in BarTrack. + * This means some class properties can be still `undefined`. + */ + override draw() { + if (PRINT_RENDERING_CYCLE) console.warn('draw()'); + this.clearMouseEventData(); + this.textsBeingUsed = 0; + this.pMouseHover?.clear(); + + const processTilesAndDraw = () => { + // Should we force to process all tiles? + // For BAM, yes, since all tiles are stored in a single tile and visible tiles had been changed. + const isBamDataFetcher = this.dataFetcher instanceof BamDataFetcher; - // Record tiles so that we ignore loading same tiles again - this.prevVisibleAndFetchedTiles = this.visibleAndFetchedTiles(); - }; + // Preprocess all tiles at once so that we can share scales across tiles. + this.processAllTiles(isBamDataFetcher); - if ( - isTabularDataFetcher(this.dataFetcher) && - !isEqual(this.visibleAndFetchedTiles(), this.prevVisibleAndFetchedTiles) - ) { - this.updateTileAsync(this.dataFetcher as TabularDataFetcher, processTilesAndDraw); - } else { - processTilesAndDraw(); - } + // This function calls `drawTile` on each tile. + super.draw(); - // Based on the updated marks, update range selection - this.mRangeBrush?.drawBrush(true); - // Publish onNewTrack if this is the first draw - if (this.firstDraw) { - this.#publishOnNewTrack(); - this.firstDraw = false; - } - } + // From BarTrack + Object.values(this.fetchedTiles).forEach(tile => { + if (!tile.drawnAtScale) return; + [tile.graphics.scale.x, tile.graphics.position.x] = this.getXScaleAndOffset(tile.drawnAtScale); + }); - /** - * Copied from BarTrack - */ - getXScaleAndOffset(drawnAtScale: Scale) { - const dA = drawnAtScale.domain(); - const dB = this._xScale.domain(); - - // scaling between tiles - const tileK = (dA[1] - dA[0]) / (dB[1] - dB[0]); - const newRange = this._xScale.domain().map(drawnAtScale); - const posOffset = newRange[0]; - return [tileK, -posOffset * tileK]; - } + // Record tiles so that we ignore loading same tiles again + this.prevVisibleAndFetchedTiles = this.visibleAndFetchedTiles(); + }; - /* - * Do whatever is necessary before rendering a new tile. This function is called from `receivedTiles()`. - * Overrides initTile in BarTrack - * (Refer to https://github.com/higlass/higlass/blob/54f5aae61d3474f9e868621228270f0c90ef9343/app/scripts/HorizontalLine1DPixiTrack.js#L50) - */ - override initTile(tile: Tile) { - if (PRINT_RENDERING_CYCLE) console.warn('initTile(tile)'); - // Since `super.initTile(tile)` prints warning, we call `drawTile` ourselves without calling - // `super.initTile(tile)`. - this.drawTile(tile); + if ( + isTabularDataFetcher(this.dataFetcher) && + !isEqual(this.visibleAndFetchedTiles(), this.prevVisibleAndFetchedTiles) + ) { + this.updateTileAsync(this.dataFetcher as TabularDataFetcher, processTilesAndDraw); + } else { + processTilesAndDraw(); } - override updateTile(/* tile: Tile */) {} // Never mind about this function for the simplicity. - renderTile(/* tile: Tile */) {} // Never mind about this function for the simplicity. - - /** - * Display a tile upon receiving a new one or when explicitly called by a developer, e.g., calling - * `this.draw()`. Overrides drawTile in BarTrack - */ - override drawTile(tile: Tile) { - if (PRINT_RENDERING_CYCLE) console.warn('drawTile(tile)'); - - /** - * If we don't have info about the tile, we can't draw anything. - */ - const tileInfo = this.#processedTileInfo[tile.tileId]; - if (!tileInfo) { - // We do not have a track model prepared to visualize - return; - } - - /** - * Add a copy of the track scale to the tile. The tile needs its own scale because we will use it to - * determine how much the tile has been stretched (if we are stretching the graphics) - */ - if (!tile.drawnAtScale) { - // This is the first time this tile is being drawn - tile.drawnAtScale = this._xScale.copy(); - } + // Based on the updated marks, update range selection + this.mRangeBrush?.drawBrush(true); + // Publish onNewTrack if this is the first draw + if (this.firstDraw) { + this.#publishOnNewTrack(); + this.firstDraw = false; + } + } - /** - * For certain types of marks and layouts (linear), we can stretch the graphics to avoid redrawing - * This is much more performant than redrawing everything at every frame - */ - const [graphicsXScale, graphicsXPos] = this.getXScaleAndOffset(tile.drawnAtScale); - const isFirstRender = graphicsXScale === 1; // The graphicsXScale is 1 if first time the tile is being drawn - if (!this.#isTooStretched(graphicsXScale) && this.#hasStretchableGraphics() && !isFirstRender) { - // Stretch the graphics - tile.graphics.scale.x = graphicsXScale; - tile.graphics.position.x = graphicsXPos; - return; - } + /** + * Copied from BarTrack + */ + getXScaleAndOffset(drawnAtScale: Scale) { + const dA = drawnAtScale.domain(); + const dB = this._xScale.domain(); + + // scaling between tiles + const tileK = (dA[1] - dA[0]) / (dB[1] - dB[0]); + const newRange = this._xScale.domain().map(drawnAtScale); + const posOffset = newRange[0]; + return [tileK, -posOffset * tileK]; + } - /** - * If we can't stretch the graphics, we need to redraw everything! - */ + /* + * Do whatever is necessary before rendering a new tile. This function is called from `receivedTiles()`. + * Overrides initTile in BarTrack + * (Refer to https://github.com/higlass/higlass/blob/54f5aae61d3474f9e868621228270f0c90ef9343/app/scripts/HorizontalLine1DPixiTrack.js#L50) + */ + override initTile(tile: Tile) { + if (PRINT_RENDERING_CYCLE) console.warn('initTile(tile)'); + // Since `super.initTile(tile)` prints warning, we call `drawTile` ourselves without calling + // `super.initTile(tile)`. + this.drawTile(tile); + } - // We need the tile scale to match the scale of the track - tile.drawnAtScale = this._xScale.copy(); - // Clear the graphics and redraw everything - tile.graphics?.clear(); - tile.graphics?.removeChildren(); - - // This is only to render embellishments only once. - // TODO: Instead of rendering and removing for every tiles, render pBorder only once - this.pBackground.clear(); - this.pBackground.removeChildren(); - this.pBorder.clear(); - const children = this.pBorder.removeChildren(); - children.forEach(c => c.destroy()); - this.displayedLegends = []; - - // Because a single tile contains one track or multiple tracks overlaid, we draw marks and embellishments - // for each GoslingTrackModel - tileInfo.goslingModels.forEach((model: GoslingTrackModel) => { - // check visibility condition - const trackWidth = this.dimensions[0]; - const zoomLevel = this._xScale.invert(trackWidth) - this._xScale.invert(0); - - if (!model.trackVisibility({ zoomLevel })) { - return; - } - drawPreEmbellishment(HGC, this, tile, model, this.options.theme); - drawMark(HGC, this, tile, model); - drawPostEmbellishment(HGC, this, tile, model, this.options.theme); - }); + override updateTile(/* tile: Tile */) {} // Never mind about this function for the simplicity. + renderTile(/* tile: Tile */) {} // Never mind about this function for the simplicity. - this.forceDraw(); - } + /** + * Display a tile upon receiving a new one or when explicitly called by a developer, e.g., calling + * `this.draw()`. Overrides drawTile in BarTrack + */ + override drawTile(tile: Tile) { + if (PRINT_RENDERING_CYCLE) console.warn('drawTile(tile)'); /** - * Render this track again using a new option when a user changed the option. Overrides rerender in BarTrack. + * If we don't have info about the tile, we can't draw anything. */ - override rerender(newOptions: GoslingTrackOptions) { - if (PRINT_RENDERING_CYCLE) console.warn('rerender(options)'); - this.options = newOptions; - - if (this.options.spec.layout === 'circular') { - // TODO (May-27-2022): remove the following line when we support a circular brush. - // If the spec is changed to use the circular layout, we remove the current linear brush - // because circular brush is not supported. - this.mRangeBrush.remove(); - } - this.getResolvedTracks(true); // force update - this.clearMouseEventData(); - this.textsBeingUsed = 0; - // Without this, tracks with the same ID between specs will not be redrawn - this.#processedTileMap = new WeakMap(); - - this.processAllTiles(true); - this.draw(); - this.forceDraw(); + const tileInfo = this.#processedTileInfo[tile.tileId]; + if (!tileInfo) { + // We do not have a track model prepared to visualize + return; } + /** - * Clears MouseEventModel from each GoslingTrackModel. Must be a public method because it is called from draw() + * Add a copy of the track scale to the tile. The tile needs its own scale because we will use it to + * determine how much the tile has been stretched (if we are stretching the graphics) */ - clearMouseEventData() { - this.visibleAndFetchedGoslingModels().forEach(model => model.getMouseEventModel().clear()); + if (!tile.drawnAtScale) { + // This is the first time this tile is being drawn + tile.drawnAtScale = this._xScale.copy(); } + /** - * Collect all gosling models that correspond to the tiles that are both visible and fetched. + * For certain types of marks and layouts (linear), we can stretch the graphics to avoid redrawing + * This is much more performant than redrawing everything at every frame */ - visibleAndFetchedGoslingModels() { - return this.visibleAndFetchedTiles().flatMap( - tile => this.#processedTileInfo[tile.tileId]?.goslingModels ?? [] - ); + const [graphicsXScale, graphicsXPos] = this.getXScaleAndOffset(tile.drawnAtScale); + const isFirstRender = graphicsXScale === 1; // The graphicsXScale is 1 if first time the tile is being drawn + if (!this.#isTooStretched(graphicsXScale) && this.#hasStretchableGraphics() && !isFirstRender) { + // Stretch the graphics + tile.graphics.scale.x = graphicsXScale; + tile.graphics.position.x = graphicsXPos; + return; } /** - * End of the rendering cycle. This function is called when the track is removed entirely. + * If we can't stretch the graphics, we need to redraw everything! */ - override remove() { - super.remove(); - if (this.gLegend) { - this.gLegend.remove(); - this.gLegend = undefined; + // We need the tile scale to match the scale of the track + tile.drawnAtScale = this._xScale.copy(); + // Clear the graphics and redraw everything + tile.graphics?.clear(); + tile.graphics?.removeChildren(); + + // This is only to render embellishments only once. + // TODO: Instead of rendering and removing for every tiles, render pBorder only once + this.pBackground.clear(); + this.pBackground.removeChildren(); + this.pBorder.clear(); + const children = this.pBorder.removeChildren(); + children.forEach(c => c.destroy()); + this.displayedLegends = []; + + // Because a single tile contains one track or multiple tracks overlaid, we draw marks and embellishments + // for each GoslingTrackModel + tileInfo.goslingModels.forEach((model: GoslingTrackModel) => { + // check visibility condition + const trackWidth = this.dimensions[0]; + const zoomLevel = this._xScale.invert(trackWidth) - this._xScale.invert(0); + + if (!model.trackVisibility({ zoomLevel })) { + return; } + drawPreEmbellishment(HGC, this, tile, model, this.options.theme); + drawMark(HGC, this, tile, model); + drawPostEmbellishment(HGC, this, tile, model, this.options.theme); + }); + + this.forceDraw(); + } + + /** + * Render this track again using a new option when a user changed the option. Overrides rerender in BarTrack. + */ + override rerender(newOptions: GoslingTrackOptions) { + if (PRINT_RENDERING_CYCLE) console.warn('rerender(options)'); + this.options = newOptions; + + if (this.options.spec.layout === 'circular') { + // TODO (May-27-2022): remove the following line when we support a circular brush. + // If the spec is changed to use the circular layout, we remove the current linear brush + // because circular brush is not supported. this.mRangeBrush.remove(); } - /* - * Rerender all tiles when track size is changed. Overrides method in TiledPixiTrack - * (Refer to https://github.com/higlass/higlass/blob/54f5aae61d3474f9e868621228270f0c90ef9343/app/scripts/PixiTrack.js#L186). - */ - override setDimensions(newDimensions: [number, number]) { - if (PRINT_RENDERING_CYCLE) console.warn('setDimensions()'); + this.getResolvedTracks(true); // force update + this.clearMouseEventData(); + this.textsBeingUsed = 0; + // Without this, tracks with the same ID between specs will not be redrawn + this.#processedTileMap = new WeakMap(); + + this.processAllTiles(true); + this.draw(); + this.forceDraw(); + } + /** + * Clears MouseEventModel from each GoslingTrackModel. Must be a public method because it is called from draw() + */ + clearMouseEventData() { + this.visibleAndFetchedGoslingModels().forEach(model => model.getMouseEventModel().clear()); + } + /** + * Collect all gosling models that correspond to the tiles that are both visible and fetched. + */ + visibleAndFetchedGoslingModels() { + return this.visibleAndFetchedTiles().flatMap(tile => this.#processedTileInfo[tile.tileId]?.goslingModels ?? []); + } - super.setDimensions(newDimensions); // This simply updates `this._xScale` and `this._yScale` + /** + * End of the rendering cycle. This function is called when the track is removed entirely. + */ + override remove() { + super.remove(); - this.mRangeBrush.setSize(newDimensions[1]); + if (this.gLegend) { + this.gLegend.remove(); + this.gLegend = undefined; } + this.mRangeBrush.remove(); + } + /* + * Rerender all tiles when track size is changed. Overrides method in TiledPixiTrack + * (Refer to https://github.com/higlass/higlass/blob/54f5aae61d3474f9e868621228270f0c90ef9343/app/scripts/PixiTrack.js#L186). + */ + override setDimensions(newDimensions: [number, number]) { + if (PRINT_RENDERING_CYCLE) console.warn('setDimensions()'); - /** - * Record new position. - */ - override setPosition(newPosition: [number, number]) { - super.setPosition(newPosition); // This simply changes `this.position` - - [this.pMain.position.x, this.pMain.position.y] = this.position; - [this.pMouseOver.position.x, this.pMouseOver.position.y] = this.position; + super.setDimensions(newDimensions); // This simply updates `this._xScale` and `this._yScale` - this.mRangeBrush.setOffset(...newPosition); - } + this.mRangeBrush.setSize(newDimensions[1]); + } - /** - * A function to redraw this track. Typically called when an asynchronous event occurs (i.e. tiles loaded) - * (Refer to https://github.com/higlass/higlass/blob/54f5aae61d3474f9e868621228270f0c90ef9343/app/scripts/TiledPixiTrack.js#L71) - */ - forceDraw() { - this.animate(); - } + /** + * Record new position. + */ + override setPosition(newPosition: [number, number]) { + super.setPosition(newPosition); // This simply changes `this.position` - /** - * Called when location or zoom level has been changed by click-and-drag interaction - * (https://github.com/higlass/higlass/blob/54f5aae61d3474f9e868621228270f0c90ef9343/app/scripts/HorizontalLine1DPixiTrack.js#L215) - * For brushing, refer to https://github.com/higlass/higlass/blob/caf230b5ee41168ea491572618612ac0cc804e5a/app/scripts/HeatmapTiledPixiTrack.js#L1493 - */ - override zoomed(newXScale: ScaleLinear, newYScale: ScaleLinear) { - if (PRINT_RENDERING_CYCLE) console.warn('zoomed()'); + [this.pMain.position.x, this.pMain.position.y] = this.position; + [this.pMouseOver.position.x, this.pMouseOver.position.y] = this.position; - const range = this.mRangeBrush.getRange(); - this.mRangeBrush.updateRange( - range ? [newXScale(this._xScale.invert(range[0])), newXScale(this._xScale.invert(range[1]))] : null - ); + this.mRangeBrush.setOffset(...newPosition); + } - this.xScale(newXScale); - this.yScale(newYScale); + /** + * A function to redraw this track. Typically called when an asynchronous event occurs (i.e. tiles loaded) + * (Refer to https://github.com/higlass/higlass/blob/54f5aae61d3474f9e868621228270f0c90ef9343/app/scripts/TiledPixiTrack.js#L71) + */ + forceDraw() { + this.animate(); + } - this.refreshTiles(); - this.draw(); - this.forceDraw(); + /** + * Called when location or zoom level has been changed by click-and-drag interaction + * (https://github.com/higlass/higlass/blob/54f5aae61d3474f9e868621228270f0c90ef9343/app/scripts/HorizontalLine1DPixiTrack.js#L215) + * For brushing, refer to https://github.com/higlass/higlass/blob/caf230b5ee41168ea491572618612ac0cc804e5a/app/scripts/HeatmapTiledPixiTrack.js#L1493 + */ + override zoomed(newXScale: ScaleLinear, newYScale: ScaleLinear) { + if (PRINT_RENDERING_CYCLE) console.warn('zoomed()'); + + const range = this.mRangeBrush.getRange(); + this.mRangeBrush.updateRange( + range ? [newXScale(this._xScale.invert(range[0])), newXScale(this._xScale.invert(range[1]))] : null + ); + + this.xScale(newXScale); + this.yScale(newYScale); + + this.refreshTiles(); + this.draw(); + this.forceDraw(); + + // Publish the new genomic axis domain + const genomicRange = newXScale + .domain() + .map(absPos => getRelativeGenomicPosition(absPos, this.#assembly, true)) as [ + GenomicPosition, + GenomicPosition + ]; + publish('location', { + id: context.viewUid, + genomicRange: genomicRange + }); + } - // Publish the new genomic axis domain - const genomicRange = newXScale - .domain() - .map(absPos => getRelativeGenomicPosition(absPos, this.#assembly, true)) as [ - GenomicPosition, - GenomicPosition - ]; - publish('location', { - id: context.viewUid, - genomicRange: genomicRange - }); - } + /** + * This is how the mask gets drawn. Overrides method in PixiTrack. + * Compared to the method in PixiTrack, this method draws a circular mask when the layout is circular. + * @param position + * @param dimensions + */ + override setMask(position: [number, number], dimensions: [number, number]) { + this.pMask.clear(); + this.pMask.beginFill(); - /** - * This is how the mask gets drawn. Overrides method in PixiTrack. - * Compared to the method in PixiTrack, this method draws a circular mask when the layout is circular. - * @param position - * @param dimensions - */ - override setMask(position: [number, number], dimensions: [number, number]) { - this.pMask.clear(); - this.pMask.beginFill(); - - if (this.options.spec.layout === 'circular' && this.options.spec.overlayOnPreviousTrack) { - /** - * If the layout is circular and is overlaid on another track, the mask should be circular - * so outer tracks can still receive click events. - */ - const [x, y] = this.position; - const [width, height] = this.dimensions; - const cx = x + width / 2.0; - const cy = y + height / 2.0; - const outerRadius = this.options.spec.outerRadius!; - this.pMask.drawCircle(cx, cy, outerRadius); - } else { - // Normal rectangular mask. This is what is done in PixiTrack - this.pMask.drawRect(position[0], position[1], dimensions[0], dimensions[1]); - } - this.pMask.endFill(); + if (this.options.spec.layout === 'circular' && this.options.spec.overlayOnPreviousTrack) { + /** + * If the layout is circular and is overlaid on another track, the mask should be circular + * so outer tracks can still receive click events. + */ + const [x, y] = this.position; + const [width, height] = this.dimensions; + const cx = x + width / 2.0; + const cy = y + height / 2.0; + const outerRadius = this.options.spec.outerRadius!; + this.pMask.drawCircle(cx, cy, outerRadius); + } else { + // Normal rectangular mask. This is what is done in PixiTrack + this.pMask.drawRect(position[0], position[1], dimensions[0], dimensions[1]); } + this.pMask.endFill(); + } - /* * - * - * Tile and data processing methods - * - * */ - - /** - * Gets all tiles and generates tabular data and GoslingTrackModels for each tile. Called by this.draw(), so - * this method must be public. - * @param force if true then tabular data gets regenerated - */ - processAllTiles(force = false) { - this.tileSize = this.tilesetInfo?.tile_size ?? 1024; + /* * + * + * Tile and data processing methods + * + * */ - const tiles = this.visibleAndFetchedTiles(); - // If we have already processed all tiles, we don't need to do anything - // this.#processedTileMap contains all of data needed to draw - if (tiles.every(tile => this.#processedTileMap.get(tile) !== undefined)) { - return; - } + /** + * Gets all tiles and generates tabular data and GoslingTrackModels for each tile. Called by this.draw(), so + * this method must be public. + * @param force if true then tabular data gets regenerated + */ + processAllTiles(force = false) { + this.tileSize = this.tilesetInfo?.tile_size ?? 1024; - // generated tabular data - tiles.forEach(tile => this.#generateTabularData(tile, force)); + const tiles = this.visibleAndFetchedTiles(); + // If we have already processed all tiles, we don't need to do anything + // this.#processedTileMap contains all of data needed to draw + if (tiles.every(tile => this.#processedTileMap.get(tile) !== undefined)) { + return; + } - // combine tabular data to the first tile if needed - this.combineAllTilesIfNeeded(); + // generated tabular data + tiles.forEach(tile => this.#generateTabularData(tile, force)); - // apply data transforms to the tabular data and generate track models - const models = tiles.flatMap(tile => this.transformDataAndCreateModels(tile)); + // combine tabular data to the first tile if needed + this.combineAllTilesIfNeeded(); - shareScaleAcrossTracks(models); + // apply data transforms to the tabular data and generate track models + const models = tiles.flatMap(tile => this.transformDataAndCreateModels(tile)); - const flatTileData = ([] as Datum[]).concat(...models.map(d => d.data())); - if (flatTileData.length !== 0) { - this.options.siblingIds.forEach(id => publish('rawData', { id, data: flatTileData })); - } + shareScaleAcrossTracks(models); - // Record processed tiles so that we don't process them again - tiles.forEach(tile => { - this.#processedTileMap.set(tile, true); - }); + const flatTileData = ([] as Datum[]).concat(...models.map(d => d.data())); + if (flatTileData.length !== 0) { + this.options.siblingIds.forEach(id => publish('rawData', { id, data: flatTileData })); } - /** - * This is currently for testing the new way of rendering visual elements. Called by this.draw() - */ - async updateTileAsync(tabularDataFetcher: TabularDataFetcher, callback: () => void) { - if (!this.tilesetInfo) return; - - const tiles = this.visibleAndFetchedTiles(); - const tabularData = await tabularDataFetcher.getTabularData(Object.values(tiles).map(x => x.remoteId)); - const tilesetInfo = this.tilesetInfo; - tiles.forEach((tile, i) => { - if (i === 0) { - const [refTile] = HGC.utils.trackUtils.calculate1DVisibleTiles(tilesetInfo, this._xScale); - tile.tileData.zoomLevel = refTile[0]; - tile.tileData.tilePos = [refTile[1], refTile[1]]; - (tile.tileData as TabularTileData).tabularData = tabularData; - } else { - (tile.tileData as TabularTileData).tabularData = []; - } - }); + // Record processed tiles so that we don't process them again + tiles.forEach(tile => { + this.#processedTileMap.set(tile, true); + }); + } - callback(); - } + /** + * This is currently for testing the new way of rendering visual elements. Called by this.draw() + */ + async updateTileAsync(tabularDataFetcher: TabularDataFetcher, callback: () => void) { + if (!this.tilesetInfo) return; + + const tiles = this.visibleAndFetchedTiles(); + const tabularData = await tabularDataFetcher.getTabularData(Object.values(tiles).map(x => x.remoteId)); + const tilesetInfo = this.tilesetInfo; + tiles.forEach((tile, i) => { + if (i === 0) { + const [refTile] = HGC.utils.trackUtils.calculate1DVisibleTiles(tilesetInfo, this._xScale); + tile.tileData.zoomLevel = refTile[0]; + tile.tileData.tilePos = [refTile[1], refTile[1]]; + (tile.tileData as TabularTileData).tabularData = tabularData; + } else { + (tile.tileData as TabularTileData).tabularData = []; + } + }); - /** - * This method is called in the TiledPixiTrack constructor `super(context, options)`. - * So be aware to use defined variables. - */ - calculateVisibleTiles() { - if (!this.tilesetInfo) return; - if (isTabularDataFetcher(this.dataFetcher)) { - const tiles = HGC.utils.trackUtils.calculate1DVisibleTiles(this.tilesetInfo, this._xScale); - const maxTileWith = - this.tilesetInfo.max_tile_width ?? this.dataFetcher.MAX_TILE_WIDTH ?? Number.MAX_SAFE_INTEGER; - - for (const tile of tiles) { - const { tileWidth } = this.getTilePosAndDimensions(tile[0], [tile[1], tile[1]]); - this.forceDraw(); - if (tileWidth > maxTileWith) { - return; - } - } + callback(); + } - this.setVisibleTiles(tiles); - } else { - if (!this.tilesetInfo) { - // if we don't know anything about this dataset, no point in trying to get tiles + /** + * This method is called in the TiledPixiTrack constructor `super(context, options)`. + * So be aware to use defined variables. + */ + calculateVisibleTiles() { + if (!this.tilesetInfo) return; + if (isTabularDataFetcher(this.dataFetcher)) { + const tiles = HGC.utils.trackUtils.calculate1DVisibleTiles(this.tilesetInfo, this._xScale); + const maxTileWith = + this.tilesetInfo.max_tile_width ?? this.dataFetcher.MAX_TILE_WIDTH ?? Number.MAX_SAFE_INTEGER; + + for (const tile of tiles) { + const { tileWidth } = this.getTilePosAndDimensions(tile[0], [tile[1], tile[1]]); + this.forceDraw(); + if (tileWidth > maxTileWith) { return; } + } - // calculate the zoom level given the scales and the data bounds - const zoomLevel = this.calculateZoomLevel(); + this.setVisibleTiles(tiles); + } else { + if (!this.tilesetInfo) { + // if we don't know anything about this dataset, no point in trying to get tiles + return; + } + + // calculate the zoom level given the scales and the data bounds + const zoomLevel = this.calculateZoomLevel(); + + if ('resolutions' in this.tilesetInfo) { + const sortedResolutions = this.tilesetInfo.resolutions + .map((x: number) => +x) + .sort((a: number, b: number) => b - a); - if ('resolutions' in this.tilesetInfo) { - const sortedResolutions = this.tilesetInfo.resolutions - .map((x: number) => +x) - .sort((a: number, b: number) => b - a); + const xTiles = tileProxy.calculateTilesFromResolution( + sortedResolutions[zoomLevel], + this._xScale, + this.tilesetInfo.min_pos[0], + this.tilesetInfo.max_pos[0] + ); - const xTiles = tileProxy.calculateTilesFromResolution( + let yTiles: number[] | undefined; + if (Is2DTrack(this.getResolvedTracks()[0])) { + // it makes sense only when the y-axis is being used for a genomic field + yTiles = tileProxy.calculateTilesFromResolution( sortedResolutions[zoomLevel], - this._xScale, + this._yScale, this.tilesetInfo.min_pos[0], this.tilesetInfo.max_pos[0] ); + } - let yTiles: number[] | undefined; - if (Is2DTrack(this.getResolvedTracks()[0])) { - // it makes sense only when the y-axis is being used for a genomic field - yTiles = tileProxy.calculateTilesFromResolution( - sortedResolutions[zoomLevel], - this._yScale, - this.tilesetInfo.min_pos[0], - this.tilesetInfo.max_pos[0] - ); - } + const tiles = GoslingTrackClass.#tilesToId(xTiles, yTiles, zoomLevel); - const tiles = GoslingTrackClass.#tilesToId(xTiles, yTiles, zoomLevel); + this.setVisibleTiles(tiles); + } else { + const xTiles = tileProxy.calculateTiles( + zoomLevel, + this._xScale, + this.tilesetInfo.min_pos[0], + this.tilesetInfo.max_pos[0], + this.tilesetInfo.max_zoom, + this.tilesetInfo.max_width + ); - this.setVisibleTiles(tiles); - } else { - const xTiles = tileProxy.calculateTiles( + let yTiles: number[] | undefined; + if (Is2DTrack(this.getResolvedTracks()[0])) { + // it makes sense only when the y-axis is being used for a genomic field + yTiles = tileProxy.calculateTiles( zoomLevel, - this._xScale, - this.tilesetInfo.min_pos[0], - this.tilesetInfo.max_pos[0], + this._yScale, + this.tilesetInfo.min_pos[1], + this.tilesetInfo.max_pos[1], this.tilesetInfo.max_zoom, - this.tilesetInfo.max_width + // @ts-expect-error what is max_width1? + this.tilesetInfo.max_width1 ?? this.tilesetInfo.max_width ); - - let yTiles: number[] | undefined; - if (Is2DTrack(this.getResolvedTracks()[0])) { - // it makes sense only when the y-axis is being used for a genomic field - yTiles = tileProxy.calculateTiles( - zoomLevel, - this._yScale, - this.tilesetInfo.min_pos[1], - this.tilesetInfo.max_pos[1], - this.tilesetInfo.max_zoom, - // @ts-expect-error what is max_width1? - this.tilesetInfo.max_width1 ?? this.tilesetInfo.max_width - ); - } - - const tiles = GoslingTrackClass.#tilesToId(xTiles, yTiles, zoomLevel); - this.setVisibleTiles(tiles); } - } - } - /** - * Copied from HorizontalTiled1DPixiTrack - */ - calculateZoomLevel() { - if (!this.tilesetInfo) { - throw Error('tilesetInfo not parsed'); - } - // offset by 2 because 1D tiles are more dense than 2D tiles - // 1024 points per tile vs 256 for 2D tiles - if ('resolutions' in this.tilesetInfo) { - const zoomIndexX = tileProxy.calculateZoomLevelFromResolutions( - this.tilesetInfo.resolutions, - this._xScale - ); - return zoomIndexX; + const tiles = GoslingTrackClass.#tilesToId(xTiles, yTiles, zoomLevel); + this.setVisibleTiles(tiles); } - // the tileProxy calculateZoomLevel function only cares about the - // difference between the minimum and maximum position - const xZoomLevel = tileProxy.calculateZoomLevel( - this._xScale, - this.tilesetInfo.min_pos[0], - this.tilesetInfo.max_pos[0], - this.tilesetInfo.bins_per_dimension || this.tilesetInfo.tile_size - ); - - let zoomLevel = Math.min(xZoomLevel, this.maxZoom); - zoomLevel = Math.max(zoomLevel, 0); + } + } + /** + * Copied from HorizontalTiled1DPixiTrack + */ + calculateZoomLevel() { + if (!this.tilesetInfo) { + throw Error('tilesetInfo not parsed'); + } + // offset by 2 because 1D tiles are more dense than 2D tiles + // 1024 points per tile vs 256 for 2D tiles + if ('resolutions' in this.tilesetInfo) { + const zoomIndexX = tileProxy.calculateZoomLevelFromResolutions(this.tilesetInfo.resolutions, this._xScale); - return zoomLevel; + return zoomIndexX; } - /** - * Convert tile positions to tile IDs - */ - static #tilesToId( - xTiles: number[], - yTiles: number[] | undefined, - zoomLevel: number - ): [number, number][] | [number, number, number][] { - if (!yTiles) { - // this means only the `x` axis is being used - return xTiles.map(x => [zoomLevel, x]); - } - // this means both `x` and `y` axes are being used together - const tiles: [number, number, number][] = []; - xTiles.forEach(x => yTiles.forEach(y => tiles.push([zoomLevel, x, y]))); - return tiles; + // the tileProxy calculateZoomLevel function only cares about the + // difference between the minimum and maximum position + const xZoomLevel = tileProxy.calculateZoomLevel( + this._xScale, + this.tilesetInfo.min_pos[0], + this.tilesetInfo.max_pos[0], + this.tilesetInfo.bins_per_dimension || this.tilesetInfo.tile_size + ); + + let zoomLevel = Math.min(xZoomLevel, this.maxZoom); + zoomLevel = Math.max(zoomLevel, 0); + + return zoomLevel; + } + /** + * Convert tile positions to tile IDs + */ + static #tilesToId( + xTiles: number[], + yTiles: number[] | undefined, + zoomLevel: number + ): [number, number][] | [number, number, number][] { + if (!yTiles) { + // this means only the `x` axis is being used + return xTiles.map(x => [zoomLevel, x]); + } + // this means both `x` and `y` axes are being used together + const tiles: [number, number, number][] = []; + xTiles.forEach(x => yTiles.forEach(y => tiles.push([zoomLevel, x, y]))); + return tiles; + } + /** + * Get the tile's position in its coordinate system. Based on method in Tiled1DPixiTrack + */ + getTilePosAndDimensions(zoomLevel: number, tilePos: [number, number]) { + if (!this.tilesetInfo) { + throw Error('tilesetInfo not parsed'); } - /** - * Get the tile's position in its coordinate system. Based on method in Tiled1DPixiTrack - */ - getTilePosAndDimensions(zoomLevel: number, tilePos: [number, number]) { - if (!this.tilesetInfo) { - throw Error('tilesetInfo not parsed'); - } - if ('resolutions' in this.tilesetInfo) { - const sortedResolutions = this.tilesetInfo.resolutions - .map((x: number) => +x) - .sort((a: number, b: number) => b - a); + if ('resolutions' in this.tilesetInfo) { + const sortedResolutions = this.tilesetInfo.resolutions + .map((x: number) => +x) + .sort((a: number, b: number) => b - a); - // A resolution specifies the number of BP per bin - const chosenResolution = sortedResolutions[zoomLevel]; + // A resolution specifies the number of BP per bin + const chosenResolution = sortedResolutions[zoomLevel]; - const [xTilePos, yTilePos] = tilePos; + const [xTilePos, yTilePos] = tilePos; - const tileWidth = chosenResolution * this.#binsPerTile; - const tileHeight = tileWidth; + const tileWidth = chosenResolution * this.#binsPerTile; + const tileHeight = tileWidth; - const tileX = tileWidth * xTilePos; - const tileY = tileHeight * yTilePos; + const tileX = tileWidth * xTilePos; + const tileY = tileHeight * yTilePos; - return { - tileX, - tileY, - tileWidth, - tileHeight - }; - } else { - const [xTilePos, yTilePos] = tilePos; + return { + tileX, + tileY, + tileWidth, + tileHeight + }; + } else { + const [xTilePos, yTilePos] = tilePos; - const minX = this.tilesetInfo.min_pos[0]; + const minX = this.tilesetInfo.min_pos[0]; - const minY = this.tilesetInfo.min_pos[1]; + const minY = this.tilesetInfo.min_pos[1]; - const tileWidth = this.tilesetInfo.max_width / 2 ** zoomLevel; - const tileHeight = this.tilesetInfo.max_width / 2 ** zoomLevel; + const tileWidth = this.tilesetInfo.max_width / 2 ** zoomLevel; + const tileHeight = this.tilesetInfo.max_width / 2 ** zoomLevel; - const tileX = minX + xTilePos * tileWidth; - const tileY = minY + yTilePos * tileHeight; + const tileX = minX + xTilePos * tileWidth; + const tileY = minY + yTilePos * tileHeight; - return { - tileX, - tileY, - tileWidth, - tileHeight - }; - } + return { + tileX, + tileY, + tileWidth, + tileHeight + }; } - get #binsPerTile() { - let maybeValue: number | undefined; - if (this.tilesetInfo) { - maybeValue = - 'bins_per_dimension' in this.tilesetInfo - ? this.tilesetInfo.bins_per_dimension - : this.tilesetInfo.tile_size; - } - return maybeValue ?? 256; + } + get #binsPerTile() { + let maybeValue: number | undefined; + if (this.tilesetInfo) { + maybeValue = + 'bins_per_dimension' in this.tilesetInfo + ? this.tilesetInfo.bins_per_dimension + : this.tilesetInfo.tile_size; } - /** - * Gets the indices of the visible data a tile. Based on method in Tiled1DPixiTrack - */ - getIndicesOfVisibleDataInTile(tile: Tile): [number, number] { - const visible = this._xScale.range(); + return maybeValue ?? 256; + } + /** + * Gets the indices of the visible data a tile. Based on method in Tiled1DPixiTrack + */ + getIndicesOfVisibleDataInTile(tile: Tile): [number, number] { + const visible = this._xScale.range(); - if (!this.tilesetInfo || !tile.tileData.tilePos || !('dense' in tile.tileData)) { - return [0, 0]; - } + if (!this.tilesetInfo || !tile.tileData.tilePos || !('dense' in tile.tileData)) { + return [0, 0]; + } - const { tileX, tileWidth } = this.getTilePosAndDimensions(tile.tileData.zoomLevel, tile.tileData.tilePos); + const { tileX, tileWidth } = this.getTilePosAndDimensions(tile.tileData.zoomLevel, tile.tileData.tilePos); - const tileXScale = HGC.libraries.d3Scale - .scaleLinear() - .domain([0, this.#binsPerTile]) - .range([tileX, tileX + tileWidth]); + const tileXScale = HGC.libraries.d3Scale + .scaleLinear() + .domain([0, this.#binsPerTile]) + .range([tileX, tileX + tileWidth]); - const start = Math.max(0, Math.round(tileXScale.invert(this._xScale.invert(visible[0])))); - const end = Math.min( - tile.tileData.dense.length, - Math.round(tileXScale.invert(this._xScale.invert(visible[1]))) - ); + const start = Math.max(0, Math.round(tileXScale.invert(this._xScale.invert(visible[0])))); + const end = Math.min( + tile.tileData.dense.length, + Math.round(tileXScale.invert(this._xScale.invert(visible[1]))) + ); - return [start, end]; - } + return [start, end]; + } - /** - * Overrides method in TiledPixiTrack - */ - override receivedTiles(loadedTiles: Record) { - // https://github.com/higlass/higlass/blob/38f0c4415f0595c3b9d685a754d6661dc9612f7c/app/scripts/TiledPixiTrack.js#L637 - super.receivedTiles(loadedTiles); - // some items in this.fetching are removed - isTabularDataFetcher(this.dataFetcher) && this.drawLoadingCue(); - } + /** + * Overrides method in TiledPixiTrack + */ + override receivedTiles(loadedTiles: Record) { + // https://github.com/higlass/higlass/blob/38f0c4415f0595c3b9d685a754d6661dc9612f7c/app/scripts/TiledPixiTrack.js#L637 + super.receivedTiles(loadedTiles); + // some items in this.fetching are removed + isTabularDataFetcher(this.dataFetcher) && this.drawLoadingCue(); + } - /** - * Overrides method in TiledPixiTrack - */ - override removeOldTiles() { - super.removeOldTiles(); // some items are added to this.fetching - isTabularDataFetcher(this.dataFetcher) && this.drawLoadingCue(); - } + /** + * Overrides method in TiledPixiTrack + */ + override removeOldTiles() { + super.removeOldTiles(); // some items are added to this.fetching + isTabularDataFetcher(this.dataFetcher) && this.drawLoadingCue(); + } - /** - * Combile multiple tiles into the last tile. - * This is sometimes necessary, for example, when applying a displacement algorithm to all tiles at once. - * Called by this.processAllTiles() so this method needs to be public. - */ - combineAllTilesIfNeeded() { - if (!this.shouldCombineTiles()) return; + /** + * Combile multiple tiles into the last tile. + * This is sometimes necessary, for example, when applying a displacement algorithm to all tiles at once. + * Called by this.processAllTiles() so this method needs to be public. + */ + combineAllTilesIfNeeded() { + if (!this.shouldCombineTiles()) return; - const tiles = this.visibleAndFetchedTiles(); + const tiles = this.visibleAndFetchedTiles(); - if (!tiles || tiles.length <= 1) { - // Does not make sense to combine tiles - return; - } + if (!tiles || tiles.length <= 1) { + // Does not make sense to combine tiles + return; + } - // Increase the size of tiles by length - this.tileSize = (this.tilesetInfo?.tile_size ?? 1024) * tiles.length; + // Increase the size of tiles by length + this.tileSize = (this.tilesetInfo?.tile_size ?? 1024) * tiles.length; - let merged: Datum[] = []; + let merged: Datum[] = []; - tiles.forEach((tile, i) => { - const tileInfo = this.#processedTileInfo[tile.tileId]; - if (tileInfo) { - // Combine data - merged = [...merged, ...tileInfo.tabularData]; + tiles.forEach((tile, i) => { + const tileInfo = this.#processedTileInfo[tile.tileId]; + if (tileInfo) { + // Combine data + merged = [...merged, ...tileInfo.tabularData]; - // Since we merge the data to the first one, skip rendering the rest - tileInfo.skipRendering = i !== 0; - } - }); + // Since we merge the data to the first one, skip rendering the rest + tileInfo.skipRendering = i !== 0; + } + }); - const firstTileInfo = this.#processedTileInfo[tiles[0].tileId]; - firstTileInfo.tabularData = merged; + const firstTileInfo = this.#processedTileInfo[tiles[0].tileId]; + firstTileInfo.tabularData = merged; - // Remove duplicated if any. Sparse tiles can have duplications. - if (firstTileInfo.tabularData[0]?.uid) { - firstTileInfo.tabularData = uniqBy(firstTileInfo.tabularData, 'uid'); - } + // Remove duplicated if any. Sparse tiles can have duplications. + if (firstTileInfo.tabularData[0]?.uid) { + firstTileInfo.tabularData = uniqBy(firstTileInfo.tabularData, 'uid'); } - /** - * Check whether tiles should be merged. Needs to be public since called by combineAllTilesIfNeeded() - */ - shouldCombineTiles() { - const includesDisplaceTransform = hasDataTransform(this.options.spec, 'displace'); - // we do not need to combine dense tiles (from multivec, vector, matrix) - const hasDenseTiles = () => { - const tiles = this.visibleAndFetchedTiles(); - return tiles.length >= 1 && 'dense' in tiles[0].tileData; - }; - // BAM data fetcher already combines the datasets; - const isBamDataFetcher = this.dataFetcher instanceof BamDataFetcher; - return includesDisplaceTransform && !hasDenseTiles() && !isBamDataFetcher; + } + /** + * Check whether tiles should be merged. Needs to be public since called by combineAllTilesIfNeeded() + */ + shouldCombineTiles() { + const includesDisplaceTransform = hasDataTransform(this.options.spec, 'displace'); + // we do not need to combine dense tiles (from multivec, vector, matrix) + const hasDenseTiles = () => { + const tiles = this.visibleAndFetchedTiles(); + return tiles.length >= 1 && 'dense' in tiles[0].tileData; + }; + // BAM data fetcher already combines the datasets; + const isBamDataFetcher = this.dataFetcher instanceof BamDataFetcher; + return includesDisplaceTransform && !hasDenseTiles() && !isBamDataFetcher; + } + + /** + * Copied from Tiled1DPixiTrack. The ID of the local tile + */ + tileToLocalId(tile: TilePosition) { + return `${tile.join('.')}`; + } + /** + * Copied from Tiled1DPixiTrack. The ID of the tile on the server. + */ + tileToRemoteId(tile: TilePosition) { + return `${tile.join('.')}`; + } + /** + * Creates an array of SingleTracks if there are overlaid tracks. + * This method cannot be private because it is called by functions which are called by super.draw(); + */ + getResolvedTracks(forceUpdate = false) { + if (forceUpdate || !this.resolvedTracks) { + const copy = structuredClone(this.options.spec); + const tracks = resolveSuperposedTracks(copy).filter(t => t.mark !== 'brush'); + // We will never need to access the values field in the data spec. It can be quite large which can degrade performance so we remove it. + tracks.forEach(track => { + if ('values' in track.data) { + track.data.values = []; + } + }); + this.resolvedTracks = tracks; } + // Brushes are drawn by another plugin track. - /** - * Copied from Tiled1DPixiTrack. The ID of the local tile - */ - tileToLocalId(tile: TilePosition) { - return `${tile.join('.')}`; + return this.resolvedTracks; + } + + /** + * Construct tabular data from a higlass tileset and a gosling track model. + */ + #generateTabularData(tile: Tile, force = false) { + if (this.#processedTileInfo[tile.tileId] && !force) { + // we do not need to re-construct tabular data + return; } - /** - * Copied from Tiled1DPixiTrack. The ID of the tile on the server. - */ - tileToRemoteId(tile: TilePosition) { - return `${tile.join('.')}`; + + if (!tile.tileData.tilePos) { + // we do not have this information ready yet, i.e., cannot calculate `tileX` + return; } - /** - * Creates an array of SingleTracks if there are overlaid tracks. - * This method cannot be private because it is called by functions which are called by super.draw(); - */ - getResolvedTracks(forceUpdate = false) { - if (forceUpdate || !this.resolvedTracks) { - const copy = structuredClone(this.options.spec); - const tracks = resolveSuperposedTracks(copy).filter(t => t.mark !== 'brush'); - // We will never need to access the values field in the data spec. It can be quite large which can degrade performance so we remove it. - tracks.forEach(track => { - if ('values' in track.data) { - track.data.values = []; - } - }); - this.resolvedTracks = tracks; - } - // Brushes are drawn by another plugin track. - return this.resolvedTracks; + const tileInfo = initProcessedTileInfo(); + const resolvedTracks = this.getResolvedTracks(); + + if (resolvedTracks.length === 0) { + // we do not have enough track to display + return []; } - /** - * Construct tabular data from a higlass tileset and a gosling track model. - */ - #generateTabularData(tile: Tile, force = false) { - if (this.#processedTileInfo[tile.tileId] && !force) { - // we do not need to re-construct tabular data - return; - } + /* Create tabular data */ + // The data spec is identical in all overlaid tracks, so we only need the first one. + const firstResolvedTrack = resolvedTracks[0]; + + if ('tabularData' in tile.tileData) { + // some data fetchers directly generates `tabularData` + tileInfo.tabularData = tile.tileData.tabularData; + } else { + // generate tabular data + const { tileX, tileY, tileWidth, tileHeight } = this.getTilePosAndDimensions( + tile.tileData.zoomLevel, + tile.tileData.tilePos + ); - if (!tile.tileData.tilePos) { - // we do not have this information ready yet, i.e., cannot calculate `tileX` - return; - } + const sparse = 'length' in tile.tileData ? Array.from(tile.tileData) : []; - const tileInfo = initProcessedTileInfo(); - const resolvedTracks = this.getResolvedTracks(); + const extendedTileData = Object.assign({}, tile.tileData, { + sparse, + tileX, + tileY, + tileWidth, + tileHeight, + tileSize: this.tileSize + }); - if (resolvedTracks.length === 0) { - // we do not have enough track to display - return []; + const tabularData = getTabularData(firstResolvedTrack, extendedTileData); + if (tabularData) { + tileInfo.tabularData = tabularData; } + } - /* Create tabular data */ - // The data spec is identical in all overlaid tracks, so we only need the first one. - const firstResolvedTrack = resolvedTracks[0]; + this.#processedTileInfo[tile.tileId] = tileInfo; + } - if ('tabularData' in tile.tileData) { - // some data fetchers directly generates `tabularData` - tileInfo.tabularData = tile.tileData.tabularData; - } else { - // generate tabular data - const { tileX, tileY, tileWidth, tileHeight } = this.getTilePosAndDimensions( - tile.tileData.zoomLevel, - tile.tileData.tilePos - ); + /** + * Apply data transformation to each of the overlaid tracks and generate GoslingTrackModels. + */ + transformDataAndCreateModels(tile: Tile) { + const tileInfo = this.#processedTileInfo[tile.tileId]; - const sparse = 'length' in tile.tileData ? Array.from(tile.tileData) : []; + if (!tileInfo || tileInfo.skipRendering) { + // this probably means the tile data has been merged to another tile + // so, no need to create track models + return []; + } - const extendedTileData = Object.assign({}, tile.tileData, { - sparse, - tileX, - tileY, - tileWidth, - tileHeight, - tileSize: this.tileSize - }); + // clear the array first + tileInfo.goslingModels = []; + + const resolvedTracks = this.getResolvedTracks(); + resolvedTracks.forEach(resolvedSpec => { + let tabularDataTransformed = Array.from(tileInfo.tabularData); + resolvedSpec.dataTransform?.forEach(t => { + switch (t.type) { + case 'filter': + tabularDataTransformed = filterData(t, tabularDataTransformed); + break; + case 'concat': + tabularDataTransformed = concatString(t, tabularDataTransformed); + break; + case 'replace': + tabularDataTransformed = replaceString(t, tabularDataTransformed); + break; + case 'log': + tabularDataTransformed = calculateData(t, tabularDataTransformed); + break; + case 'exonSplit': + tabularDataTransformed = splitExon(t, tabularDataTransformed, resolvedSpec.assembly); + break; + case 'genomicLength': + tabularDataTransformed = calculateGenomicLength(t, tabularDataTransformed); + break; + case 'svType': + tabularDataTransformed = inferSvType(t, tabularDataTransformed); + break; + case 'coverage': + tabularDataTransformed = aggregateCoverage(t, tabularDataTransformed, this._xScale.copy()); + break; + case 'subjson': + tabularDataTransformed = parseSubJSON(t, tabularDataTransformed); + break; + case 'displace': + tabularDataTransformed = displace(t, tabularDataTransformed, this._xScale.copy()); + break; + } + }); - const tabularData = getTabularData(firstResolvedTrack, extendedTileData); - if (tabularData) { - tileInfo.tabularData = tabularData; + // TODO: Remove the following block entirely and use the `rawData` API in the Editor (June-02-2022) + // Send data preview to the editor so that it can be shown to users. + try { + if (PubSub) { + const NUM_OF_ROWS_IN_PREVIEW = 100; + const numOrRows = tabularDataTransformed.length; + PubSub.publish('data-preview', { + id: context.viewUid, + dataConfig: JSON.stringify({ data: resolvedSpec.data }), + data: + NUM_OF_ROWS_IN_PREVIEW > numOrRows + ? tabularDataTransformed + : sampleSize(tabularDataTransformed, NUM_OF_ROWS_IN_PREVIEW) + // ... + }); } + } catch (e) { + // .. } - this.#processedTileInfo[tile.tileId] = tileInfo; - } - - /** - * Apply data transformation to each of the overlaid tracks and generate GoslingTrackModels. - */ - transformDataAndCreateModels(tile: Tile) { - const tileInfo = this.#processedTileInfo[tile.tileId]; - - if (!tileInfo || tileInfo.skipRendering) { - // this probably means the tile data has been merged to another tile - // so, no need to create track models - return []; + // Replace width and height information with the actual values for responsive encoding + const [trackWidth, trackHeight] = this.dimensions; // actual size of a track + const axisSize = IsXAxis(resolvedSpec) && this.options.spec.layout === 'linear' ? HIGLASS_AXIS_SIZE : 0; // Why the axis size must be added here? + const [w, h] = [trackWidth, trackHeight + axisSize]; + const circularFactor = Math.min(w, h) / Math.min(resolvedSpec.width!, resolvedSpec.height!); + if (resolvedSpec.innerRadius) { + resolvedSpec.innerRadius = resolvedSpec.innerRadius * circularFactor; + } + if (resolvedSpec.outerRadius) { + resolvedSpec.outerRadius = resolvedSpec.outerRadius * circularFactor; } + resolvedSpec.width = w; + resolvedSpec.height = h; - // clear the array first - tileInfo.goslingModels = []; - - const resolvedTracks = this.getResolvedTracks(); - resolvedTracks.forEach(resolvedSpec => { - let tabularDataTransformed = Array.from(tileInfo.tabularData); - resolvedSpec.dataTransform?.forEach(t => { - switch (t.type) { - case 'filter': - tabularDataTransformed = filterData(t, tabularDataTransformed); - break; - case 'concat': - tabularDataTransformed = concatString(t, tabularDataTransformed); - break; - case 'replace': - tabularDataTransformed = replaceString(t, tabularDataTransformed); - break; - case 'log': - tabularDataTransformed = calculateData(t, tabularDataTransformed); - break; - case 'exonSplit': - tabularDataTransformed = splitExon(t, tabularDataTransformed, resolvedSpec.assembly); - break; - case 'genomicLength': - tabularDataTransformed = calculateGenomicLength(t, tabularDataTransformed); - break; - case 'svType': - tabularDataTransformed = inferSvType(t, tabularDataTransformed); - break; - case 'coverage': - tabularDataTransformed = aggregateCoverage(t, tabularDataTransformed, this._xScale.copy()); - break; - case 'subjson': - tabularDataTransformed = parseSubJSON(t, tabularDataTransformed); - break; - case 'displace': - tabularDataTransformed = displace(t, tabularDataTransformed, this._xScale.copy()); - break; - } - }); + // Construct separate gosling models for individual tiles + const model = new GoslingTrackModel(resolvedSpec, tabularDataTransformed, this.options.theme); - // TODO: Remove the following block entirely and use the `rawData` API in the Editor (June-02-2022) - // Send data preview to the editor so that it can be shown to users. - try { - if (PubSub) { - const NUM_OF_ROWS_IN_PREVIEW = 100; - const numOrRows = tabularDataTransformed.length; - PubSub.publish('data-preview', { - id: context.viewUid, - dataConfig: JSON.stringify({ data: resolvedSpec.data }), - data: - NUM_OF_ROWS_IN_PREVIEW > numOrRows - ? tabularDataTransformed - : sampleSize(tabularDataTransformed, NUM_OF_ROWS_IN_PREVIEW) - // ... - }); - } - } catch (e) { - // .. - } + // Add a track model to the tile object + tileInfo.goslingModels.push(model); + }); - // Replace width and height information with the actual values for responsive encoding - const [trackWidth, trackHeight] = this.dimensions; // actual size of a track - const axisSize = IsXAxis(resolvedSpec) && this.options.spec.layout === 'linear' ? HIGLASS_AXIS_SIZE : 0; // Why the axis size must be added here? - const [w, h] = [trackWidth, trackHeight + axisSize]; - const circularFactor = Math.min(w, h) / Math.min(resolvedSpec.width!, resolvedSpec.height!); - if (resolvedSpec.innerRadius) { - resolvedSpec.innerRadius = resolvedSpec.innerRadius * circularFactor; - } - if (resolvedSpec.outerRadius) { - resolvedSpec.outerRadius = resolvedSpec.outerRadius * circularFactor; - } - resolvedSpec.width = w; - resolvedSpec.height = h; + return tileInfo.goslingModels; + } - // Construct separate gosling models for individual tiles - const model = new GoslingTrackModel(resolvedSpec, tabularDataTransformed, this.options.theme); + /* * + * + * Mouse methods + * + * */ - // Add a track model to the tile object - tileInfo.goslingModels.push(model); - }); + /** + * This is for the HiGlass mouseMoveZoom event. However, GoslingTrack has its own way of handling mouse events. + */ + mouseMoveZoomHandler() { + return; + } - return tileInfo.goslingModels; - } + #onMouseDown(mouseX: number, mouseY: number, isAltPressed: boolean) { + // Record these so that we do not triger click event when dragged. + this.#mouseDownX = mouseX; + this.#mouseDownY = mouseY; - /* * - * - * Mouse methods - * - * */ + // Determine whether to activate a range brush + const mouseEvents = this.options.spec.mouseEvents; + const rangeSelectEnabled = !!mouseEvents || (IsMouseEventsDeep(mouseEvents) && !!mouseEvents.rangeSelect); + this.#isRangeBrushActivated = rangeSelectEnabled && isAltPressed; - /** - * This is for the HiGlass mouseMoveZoom event. However, GoslingTrack has its own way of handling mouse events. - */ - mouseMoveZoomHandler() { + this.pMouseHover.clear(); + } + + #onMouseMove(mouseX: number) { + if (this.options.spec.layout === 'circular') { + // TODO: We do not yet support range selection on circular tracks return; } - #onMouseDown(mouseX: number, mouseY: number, isAltPressed: boolean) { - // Record these so that we do not triger click event when dragged. - this.#mouseDownX = mouseX; - this.#mouseDownY = mouseY; + if (this.#isRangeBrushActivated) { + this.mRangeBrush.updateRange([mouseX, this.#mouseDownX]).drawBrush().visible().disable(); + } + } - // Determine whether to activate a range brush - const mouseEvents = this.options.spec.mouseEvents; - const rangeSelectEnabled = !!mouseEvents || (IsMouseEventsDeep(mouseEvents) && !!mouseEvents.rangeSelect); - this.#isRangeBrushActivated = rangeSelectEnabled && isAltPressed; + #onMouseUp(mouseX: number, mouseY: number) { + // `trackClick` API + this.#publishTrackEvents('trackClick', mouseX, mouseY); + + const mouseEvents = this.options.spec.mouseEvents; + const clickEnabled = !!mouseEvents || (IsMouseEventsDeep(mouseEvents) && !!mouseEvents.click); + const isDrag = Math.sqrt((this.#mouseDownX - mouseX) ** 2 + (this.#mouseDownY - mouseY) ** 2) > 1; - this.pMouseHover.clear(); + if (!this.#isRangeBrushActivated && !isDrag) { + // Clicking outside the brush should remove the brush and the selection. + this.mRangeBrush.clear(); + this.pMouseSelection.clear(); + } else { + // Dragging ended, so enable adjusting the range brush + this.mRangeBrush.enable(); } - #onMouseMove(mouseX: number) { - if (this.options.spec.layout === 'circular') { - // TODO: We do not yet support range selection on circular tracks - return; - } + this.#isRangeBrushActivated = false; - if (this.#isRangeBrushActivated) { - this.mRangeBrush.updateRange([mouseX, this.#mouseDownX]).drawBrush().visible().disable(); - } + if (!this.tilesetInfo) { + // Do not have enough information + return; } - #onMouseUp(mouseX: number, mouseY: number) { - // `trackClick` API - this.#publishTrackEvents('trackClick', mouseX, mouseY); + // `click` API + if (!isDrag && clickEnabled) { + // Identify the current position + const genomicPosition = getRelativeGenomicPosition(Math.floor(this._xScale.invert(mouseX)), this.#assembly); - const mouseEvents = this.options.spec.mouseEvents; - const clickEnabled = !!mouseEvents || (IsMouseEventsDeep(mouseEvents) && !!mouseEvents.click); - const isDrag = Math.sqrt((this.#mouseDownX - mouseX) ** 2 + (this.#mouseDownY - mouseY) ** 2) > 1; + // Get elements within mouse + const capturedElements = this.#getElementsWithinMouse(mouseX, mouseY); - if (!this.#isRangeBrushActivated && !isDrag) { - // Clicking outside the brush should remove the brush and the selection. - this.mRangeBrush.clear(); - this.pMouseSelection.clear(); - } else { - // Dragging ended, so enable adjusting the range brush - this.mRangeBrush.enable(); + if (capturedElements.length !== 0) { + this.options.siblingIds.forEach(id => + publish('click', { + id, + genomicPosition, + data: capturedElements.map(d => d.value) + }) + ); } + } + } - this.#isRangeBrushActivated = false; - - if (!this.tilesetInfo) { - // Do not have enough information - return; - } + #onMouseOut() { + this.#isRangeBrushActivated = false; + document.body.style.cursor = 'default'; + this.pMouseHover.clear(); + } + /** + * From all tiles and overlaid tracks, collect element(s) that are withing a mouse position. + */ + #getElementsWithinMouse(mouseX: number, mouseY: number) { + const models = this.visibleAndFetchedGoslingModels(); + + // TODO: `Omit` this properties in the schema of individual overlaid tracks. + // These should be defined only once for a group of overlaid traks (09-May-2022) + // See https://github.com/gosling-lang/gosling.js/issues/677 + const mouseEvents = this.options.spec.mouseEvents; + const multiHovering = IsMouseEventsDeep(mouseEvents) && mouseEvents.enableMouseOverOnMultipleMarks; + const idField = IsMouseEventsDeep(mouseEvents) && mouseEvents.groupMarksByField; + + // Collect all mouse event data from tiles and overlaid tracks + const mergedCapturedElements: MouseEventData[] = models + .map(model => model.getMouseEventModel().findAll(mouseX, mouseY, true)) + .flat(); + + if (!multiHovering) { + // Select only one on the top of a cursor + mergedCapturedElements.splice(1, mergedCapturedElements.length - 1); + } - // `click` API - if (!isDrag && clickEnabled) { - // Identify the current position - const genomicPosition = getRelativeGenomicPosition( - Math.floor(this._xScale.invert(mouseX)), - this.#assembly - ); + // Iterate again to select sibling marks (e.g., entire glyphs) + if (mergedCapturedElements.length !== 0 && idField) { + const source = Array.from(mergedCapturedElements); + models.forEach(model => { + const siblings = model.getMouseEventModel().getSiblings(source, idField); + mergedCapturedElements.push(...siblings); + }); + } - // Get elements within mouse - const capturedElements = this.#getElementsWithinMouse(mouseX, mouseY); + return mergedCapturedElements; + } - if (capturedElements.length !== 0) { - this.options.siblingIds.forEach(id => - publish('click', { - id, - genomicPosition, - data: capturedElements.map(d => d.value) - }) - ); - } + /** + * Call track events (e.g., `trackClick` or `trackMouseOver`) based on a mouse position and the track display area. + */ + #publishTrackEvents(eventType: 'trackClick' | 'trackMouseOver', mouseX: number, mouseY: number) { + const [x, y] = this.position; + const [width, height] = this.dimensions; + if (this.options.spec.layout === 'circular') { + const cx = x + width / 2.0; + const cy = y + height / 2.0; + const innerRadius = this.options.spec.innerRadius!; + const outerRadius = this.options.spec.outerRadius!; + const startAngle = this.options.spec.startAngle!; + const endAngle = this.options.spec.endAngle!; + // Call the API function only when the mouse is positioned directly on the track display area + if ( + isPointInsideDonutSlice( + [mouseX, mouseY], + [width / 2.0, height / 2.0], + [innerRadius, outerRadius], + [startAngle, endAngle] + ) + ) { + publish(eventType, { + id: context.viewUid, + spec: structuredClone(this.options.spec), + shape: { x, y, width, height, cx, cy, innerRadius, outerRadius, startAngle, endAngle } + }); } + } else { + publish(eventType, { + id: context.viewUid, + spec: structuredClone(this.options.spec), + shape: { x, y, width, height } + }); } + } - #onMouseOut() { - this.#isRangeBrushActivated = false; - document.body.style.cursor = 'default'; - this.pMouseHover.clear(); + #onRangeBrush(range: [number, number] | null, skipApiTrigger = false) { + this.pMouseSelection.clear(); + + if (range === null) { + // brush just removed + if (!skipApiTrigger) { + publish('rangeSelect', { id: context.viewUid, genomicRange: null, data: [] }); + } + return; } - /** - * From all tiles and overlaid tracks, collect element(s) that are withing a mouse position. - */ - #getElementsWithinMouse(mouseX: number, mouseY: number) { - const models = this.visibleAndFetchedGoslingModels(); - // TODO: `Omit` this properties in the schema of individual overlaid tracks. - // These should be defined only once for a group of overlaid traks (09-May-2022) - // See https://github.com/gosling-lang/gosling.js/issues/677 - const mouseEvents = this.options.spec.mouseEvents; - const multiHovering = IsMouseEventsDeep(mouseEvents) && mouseEvents.enableMouseOverOnMultipleMarks; - const idField = IsMouseEventsDeep(mouseEvents) && mouseEvents.groupMarksByField; + const models = this.visibleAndFetchedGoslingModels(); + const [startX, endX] = range; + + // Collect all mouse event data from tiles and overlaid tracks + let capturedElements: MouseEventData[] = models + .map(model => model.getMouseEventModel().findAllWithinRange(startX, endX, true)) + .flat(); + + // Deselect marks if their siblings are not selected. + // e.g., if only one exon is selected in a gene, we do not select it. + const mouseEvents = this.options.spec.mouseEvents; + const idField = IsMouseEventsDeep(mouseEvents) && mouseEvents.groupMarksByField; + if (capturedElements.length !== 0 && idField) { + models.forEach(model => { + const siblings = model.getMouseEventModel().getSiblings(capturedElements, idField); + const siblingIds = Array.from(new Set(siblings.map(d => d.value[idField]))); + capturedElements = capturedElements.filter(d => siblingIds.indexOf(d.value[idField]) === -1); + }); + } - // Collect all mouse event data from tiles and overlaid tracks - const mergedCapturedElements: MouseEventData[] = models - .map(model => model.getMouseEventModel().findAll(mouseX, mouseY, true)) - .flat(); + if (capturedElements.length !== 0) { + // selection effect graphics + const g = this.pMouseSelection; - if (!multiHovering) { - // Select only one on the top of a cursor - mergedCapturedElements.splice(1, mergedCapturedElements.length - 1); + if (this.options.spec.style?.select?.arrange !== 'behind') { + // place on the top + this.pMain.removeChild(g); + this.pMain.addChild(g); } - // Iterate again to select sibling marks (e.g., entire glyphs) - if (mergedCapturedElements.length !== 0 && idField) { - const source = Array.from(mergedCapturedElements); - models.forEach(model => { - const siblings = model.getMouseEventModel().getSiblings(source, idField); - mergedCapturedElements.push(...siblings); - }); - } + this.#highlightMarks( + g, + capturedElements, + Object.assign({}, DEFAULT_MOUSE_EVENT_STYLE, this.options.spec.style?.select) + ); + } + + /* API call */ + if (!skipApiTrigger) { + const genomicRange: [GenomicPosition, GenomicPosition] = [ + getRelativeGenomicPosition(Math.floor(this._xScale.invert(startX)), this.#assembly), + getRelativeGenomicPosition(Math.floor(this._xScale.invert(endX)), this.#assembly) + ]; - return mergedCapturedElements; + publish('rangeSelect', { + id: context.viewUid, + genomicRange, + data: capturedElements.map(d => d.value) + }); } - /** - * Call track events (e.g., `trackClick` or `trackMouseOver`) based on a mouse position and the track display area. - */ - #publishTrackEvents(eventType: 'trackClick' | 'trackMouseOver', mouseX: number, mouseY: number) { - const [x, y] = this.position; - const [width, height] = this.dimensions; - if (this.options.spec.layout === 'circular') { - const cx = x + width / 2.0; - const cy = y + height / 2.0; - const innerRadius = this.options.spec.innerRadius!; - const outerRadius = this.options.spec.outerRadius!; - const startAngle = this.options.spec.startAngle!; - const endAngle = this.options.spec.endAngle!; - // Call the API function only when the mouse is positioned directly on the track display area - if ( - isPointInsideDonutSlice( - [mouseX, mouseY], - [width / 2.0, height / 2.0], - [innerRadius, outerRadius], - [startAngle, endAngle] - ) - ) { - publish(eventType, { - id: context.viewUid, - spec: structuredClone(this.options.spec), - shape: { x, y, width, height, cx, cy, innerRadius, outerRadius, startAngle, endAngle } - }); - } + this.forceDraw(); + } + + /** + * Highlight marks that are either mouse overed or selected. + */ + #highlightMarks( + g: PIXI.Graphics, + marks: MouseEventData[], + style: { + stroke: string; + strokeWidth: number; + strokeOpacity: number; + color: string; + opacity: number; + } + ) { + g.lineStyle( + style.strokeWidth, + colorToHex(style.stroke), + style.strokeOpacity, // alpha + 0.5 // alignment of the line to draw, (0 = inner, 0.5 = middle, 1 = outter) + ); + g.beginFill(colorToHex(style.color), style.color === 'none' ? 0 : style.opacity); + + marks.forEach(d => { + if (d.type === 'point') { + const [x, y, r = 3] = d.polygon; + g.drawCircle(x, y, r); + } else if (d.type === 'line') { + g.moveTo(d.polygon[0], d.polygon[1]); + flatArrayToPairArray(d.polygon).map(d => g.lineTo(d[0], d[1])); } else { - publish(eventType, { - id: context.viewUid, - spec: structuredClone(this.options.spec), - shape: { x, y, width, height } - }); + g.drawPolygon(d.polygon); } + }); + } + + hideMousePosition?: () => void; // set in HorizontalTiled1DPixiTrack + + /** + * Called by showHoverMenu() in HiGlassComponent + */ + getMouseOverHtml(mouseX: number, mouseY: number) { + // `trackMouseOver` API + this.#publishTrackEvents('trackMouseOver', mouseX, mouseY); + + if (this.#isRangeBrushActivated) { + // In the middle of drawing range brush. + return ''; } - #onRangeBrush(range: [number, number] | null, skipApiTrigger = false) { - this.pMouseSelection.clear(); + if (!this.tilesetInfo) { + // Do not have enough information + return ''; + } - if (range === null) { - // brush just removed - if (!skipApiTrigger) { - publish('rangeSelect', { id: context.viewUid, genomicRange: null, data: [] }); - } - return; - } + this.pMouseHover.clear(); - const models = this.visibleAndFetchedGoslingModels(); - const [startX, endX] = range; + // Current position + const genomicPosition = getRelativeGenomicPosition(Math.floor(this._xScale.invert(mouseX)), this.#assembly); - // Collect all mouse event data from tiles and overlaid tracks - let capturedElements: MouseEventData[] = models - .map(model => model.getMouseEventModel().findAllWithinRange(startX, endX, true)) - .flat(); + // Get elements within mouse + const capturedElements = this.#getElementsWithinMouse(mouseX, mouseY); - // Deselect marks if their siblings are not selected. - // e.g., if only one exon is selected in a gene, we do not select it. - const mouseEvents = this.options.spec.mouseEvents; - const idField = IsMouseEventsDeep(mouseEvents) && mouseEvents.groupMarksByField; - if (capturedElements.length !== 0 && idField) { - models.forEach(model => { - const siblings = model.getMouseEventModel().getSiblings(capturedElements, idField); - const siblingIds = Array.from(new Set(siblings.map(d => d.value[idField]))); - capturedElements = capturedElements.filter(d => siblingIds.indexOf(d.value[idField]) === -1); - }); - } + // Change cursor + // https://developer.mozilla.org/en-US/docs/Web/CSS/cursor + if (capturedElements.length !== 0) { + document.body.style.cursor = 'pointer'; + } else { + document.body.style.cursor = 'default'; + } - if (capturedElements.length !== 0) { - // selection effect graphics - const g = this.pMouseSelection; + if (capturedElements.length !== 0) { + const mouseEvents = this.options.spec.mouseEvents; + const mouseOverEnabled = !!mouseEvents || (IsMouseEventsDeep(mouseEvents) && !!mouseEvents.mouseOver); + if (mouseOverEnabled) { + // Display mouse over effects + const g = this.pMouseHover; - if (this.options.spec.style?.select?.arrange !== 'behind') { + if (this.options.spec.style?.mouseOver?.arrange !== 'behind') { // place on the top this.pMain.removeChild(g); this.pMain.addChild(g); @@ -1312,308 +1409,198 @@ const factory: PluginTrackFactory = (HGC, context, op this.#highlightMarks( g, capturedElements, - Object.assign({}, DEFAULT_MOUSE_EVENT_STYLE, this.options.spec.style?.select) + Object.assign({}, DEFAULT_MOUSE_EVENT_STYLE, this.options.spec.style?.mouseOver) ); - } - /* API call */ - if (!skipApiTrigger) { - const genomicRange: [GenomicPosition, GenomicPosition] = [ - getRelativeGenomicPosition(Math.floor(this._xScale.invert(startX)), this.#assembly), - getRelativeGenomicPosition(Math.floor(this._xScale.invert(endX)), this.#assembly) - ]; - - publish('rangeSelect', { + // API call + publish('mouseOver', { id: context.viewUid, - genomicRange, + genomicPosition, data: capturedElements.map(d => d.value) }); } - this.forceDraw(); - } - - /** - * Highlight marks that are either mouse overed or selected. - */ - #highlightMarks( - g: PIXI.Graphics, - marks: MouseEventData[], - style: { - stroke: string; - strokeWidth: number; - strokeOpacity: number; - color: string; - opacity: number; - } - ) { - g.lineStyle( - style.strokeWidth, - colorToHex(style.stroke), - style.strokeOpacity, // alpha - 0.5 // alignment of the line to draw, (0 = inner, 0.5 = middle, 1 = outter) - ); - g.beginFill(colorToHex(style.color), style.color === 'none' ? 0 : style.opacity); - - marks.forEach(d => { - if (d.type === 'point') { - const [x, y, r = 3] = d.polygon; - g.drawCircle(x, y, r); - } else if (d.type === 'line') { - g.moveTo(d.polygon[0], d.polygon[1]); - flatArrayToPairArray(d.polygon).map(d => g.lineTo(d[0], d[1])); - } else { - g.drawPolygon(d.polygon); - } - }); - } - - hideMousePosition?: () => void; // set in HorizontalTiled1DPixiTrack - - /** - * Called by showHoverMenu() in HiGlassComponent - */ - getMouseOverHtml(mouseX: number, mouseY: number) { - // `trackMouseOver` API - this.#publishTrackEvents('trackMouseOver', mouseX, mouseY); - - if (this.#isRangeBrushActivated) { - // In the middle of drawing range brush. - return ''; - } - - if (!this.tilesetInfo) { - // Do not have enough information - return ''; - } - - this.pMouseHover.clear(); - - // Current position - const genomicPosition = getRelativeGenomicPosition(Math.floor(this._xScale.invert(mouseX)), this.#assembly); - - // Get elements within mouse - const capturedElements = this.#getElementsWithinMouse(mouseX, mouseY); - - // Change cursor - // https://developer.mozilla.org/en-US/docs/Web/CSS/cursor - if (capturedElements.length !== 0) { - document.body.style.cursor = 'pointer'; - } else { - document.body.style.cursor = 'default'; - } - - if (capturedElements.length !== 0) { - const mouseEvents = this.options.spec.mouseEvents; - const mouseOverEnabled = !!mouseEvents || (IsMouseEventsDeep(mouseEvents) && !!mouseEvents.mouseOver); - if (mouseOverEnabled) { - // Display mouse over effects - const g = this.pMouseHover; - - if (this.options.spec.style?.mouseOver?.arrange !== 'behind') { - // place on the top - this.pMain.removeChild(g); - this.pMain.addChild(g); - } - - this.#highlightMarks( - g, - capturedElements, - Object.assign({}, DEFAULT_MOUSE_EVENT_STYLE, this.options.spec.style?.mouseOver) - ); - - // API call - publish('mouseOver', { - id: context.viewUid, - genomicPosition, - data: capturedElements.map(d => d.value) - }); - } + // Display a tooltip + const models = this.visibleAndFetchedGoslingModels(); - // Display a tooltip - const models = this.visibleAndFetchedGoslingModels(); - - const firstTooltipSpec = models - .find(m => m.spec().tooltip && m.spec().tooltip?.length !== 0) - ?.spec().tooltip; - - if (firstTooltipSpec) { - let content = firstTooltipSpec - .map(d => { - const rawValue = capturedElements[0].value[d.field]; - let value = rawValue; - if (d.type === 'quantitative' && d.format) { - value = HGC.libraries.d3Format.format(d.format)(+rawValue); - } else if (d.type === 'genomic') { - // e.g., chr1:204,133 - const { chromosome, position } = getRelativeGenomicPosition(+rawValue, this.#assembly); - value = `${chromosome}:${HGC.libraries.d3Format.format(',')(position)}`; - } - - return ( - '' + - `${d.alt ?? d.field}` + - `${value}` + - '' - ); - }) - .join(''); - - content = `${content}
`; - if (capturedElements.length > 1) { - content += - `
` + - `${capturedElements.length - 1} Additional Selections...` + - '
'; - } - return `
${content}
`; + const firstTooltipSpec = models + .find(m => m.spec().tooltip && m.spec().tooltip?.length !== 0) + ?.spec().tooltip; + + if (firstTooltipSpec) { + let content = firstTooltipSpec + .map(d => { + const rawValue = capturedElements[0].value[d.field]; + let value = rawValue; + if (d.type === 'quantitative' && d.format) { + value = HGC.libraries.d3Format.format(d.format)(+rawValue); + } else if (d.type === 'genomic') { + // e.g., chr1:204,133 + const { chromosome, position } = getRelativeGenomicPosition(+rawValue, this.#assembly); + value = `${chromosome}:${HGC.libraries.d3Format.format(',')(position)}`; + } + + return ( + '' + + `${d.alt ?? d.field}` + + `${value}` + + '' + ); + }) + .join(''); + + content = `${content}
`; + if (capturedElements.length > 1) { + content += + `
` + + `${capturedElements.length - 1} Additional Selections...` + + '
'; } + return `
${content}
`; } - return ''; } + return ''; + } - /** - * Javscript subscription API methods (besides for mouse) - */ + /** + * Javscript subscription API methods (besides for mouse) + */ - /** - * Publishes track information. Triggered when track gets created - */ - #publishOnNewTrack() { - publish('onNewTrack', { - id: context.viewUid - }); - } + /** + * Publishes track information. Triggered when track gets created + */ + #publishOnNewTrack() { + publish('onNewTrack', { + id: context.viewUid + }); + } - /* * - * - * Other misc methods and overrides - * - * */ + /* * + * + * Other misc methods and overrides + * + * */ - /** - * Returns the minimum in the visible area (not visible tiles). - * Overrides method in Tiled1DPixiTrack - */ - override minVisibleValue() { - return 0; - } + /** + * Returns the minimum in the visible area (not visible tiles). + * Overrides method in Tiled1DPixiTrack + */ + override minVisibleValue() { + return 0; + } - /** - * Returns the maximum in the visible area (not visible tiles). - * Overrides method in Tiled1DPixiTrack. - */ - override maxVisibleValue() { - return 0; - } - /** - * Overrides method in PixiTrack. SVG export is not supported. - */ - override exportSVG(): never { - throw new Error('exportSVG() not supported for gosling-track'); - } - /** - * Show visual cue during waiting for visualizations being rendered. Also called by data fetchers - */ - drawLoadingCue() { - if (this.fetching.size) { - const margin = 6; - - const text = `Fetching... ${Array.from(this.fetching).join(' ')}`; - this.#loadingText.text = text; - this.#loadingText.x = this.position[0] + this.dimensions[0] - margin / 2.0; - this.#loadingText.y = this.position[1] + this.dimensions[1] - margin / 2.0; - - // Show background - const metric = HGC.libraries.PIXI.TextMetrics.measureText(text, this.#loadingTextStyleObj); - const { width: w, height: h } = metric; - - this.#loadingTextBg.clear(); - this.#loadingTextBg.lineStyle(1, colorToHex('grey'), 1, 0.5); - this.#loadingTextBg.beginFill(colorToHex('white'), 0.8); - this.#loadingTextBg.drawRect( - this.position[0] + this.dimensions[0] - w - margin - 1, - this.position[1] + this.dimensions[1] - h - margin - 1, - w + margin, - h + margin - ); + /** + * Returns the maximum in the visible area (not visible tiles). + * Overrides method in Tiled1DPixiTrack. + */ + override maxVisibleValue() { + return 0; + } + /** + * Overrides method in PixiTrack. SVG export is not supported. + */ + override exportSVG(): never { + throw new Error('exportSVG() not supported for gosling-track'); + } + /** + * Show visual cue during waiting for visualizations being rendered. Also called by data fetchers + */ + drawLoadingCue() { + if (this.fetching.size) { + const margin = 6; + + const text = `Fetching... ${Array.from(this.fetching).join(' ')}`; + this.#loadingText.text = text; + this.#loadingText.x = this.position[0] + this.dimensions[0] - margin / 2.0; + this.#loadingText.y = this.position[1] + this.dimensions[1] - margin / 2.0; + + // Show background + const metric = HGC.libraries.PIXI.TextMetrics.measureText(text, this.#loadingTextStyleObj); + const { width: w, height: h } = metric; + + this.#loadingTextBg.clear(); + this.#loadingTextBg.lineStyle(1, colorToHex('grey'), 1, 0.5); + this.#loadingTextBg.beginFill(colorToHex('white'), 0.8); + this.#loadingTextBg.drawRect( + this.position[0] + this.dimensions[0] - w - margin - 1, + this.position[1] + this.dimensions[1] - h - margin - 1, + w + margin, + h + margin + ); - this.#loadingText.visible = true; - this.#loadingTextBg.visible = true; - } else { - this.#loadingText.visible = false; - this.#loadingTextBg.visible = false; - } - } - /** - * Called in legend.ts - */ - updateScaleOffsetFromOriginalSpec( - _renderingId: string, - scaleOffset: [number, number], - channelKey: 'color' | 'stroke' - ) { - this.getResolvedTracks().map(spec => { - if (spec._renderingId === _renderingId) { - const channel = spec[channelKey]; - if (IsChannelDeep(channel)) { - channel.scaleOffset = scaleOffset; - } - } - }); + this.#loadingText.visible = true; + this.#loadingTextBg.visible = true; + } else { + this.#loadingText.visible = false; + this.#loadingTextBg.visible = false; } - /** - * Called in legend.ts - */ - shareScaleOffsetAcrossTracksAndTiles(scaleOffset: [number, number], channelKey: 'color' | 'stroke') { - const models = this.visibleAndFetchedGoslingModels(); - models.forEach(d => { - const channel = d.spec()[channelKey]; + } + /** + * Called in legend.ts + */ + updateScaleOffsetFromOriginalSpec( + _renderingId: string, + scaleOffset: [number, number], + channelKey: 'color' | 'stroke' + ) { + this.getResolvedTracks().map(spec => { + if (spec._renderingId === _renderingId) { + const channel = spec[channelKey]; if (IsChannelDeep(channel)) { channel.scaleOffset = scaleOffset; } - const channelOriginal = d.originalSpec()[channelKey]; - if (IsChannelDeep(channelOriginal)) { - channelOriginal.scaleOffset = scaleOffset; - } - }); - } - /** - * Used in drawTile() - * Checks if the track has marks which are stretchable. Stretching - * is not supported for circular layouts or 2D tracks - */ - #hasStretchableGraphics() { - const hasStretchOption = this.options.spec.experimental?.stretchGraphics; - if (hasStretchOption === true) { - return true; - } else if (hasStretchOption === false) { - return false; } - // The default behavior is that we stretch when stretching looks acceptable - const isFirstTrack1D = !Is2DTrack(this.getResolvedTracks()[0]); - const isNotCircularLayout = this.options.spec.layout !== 'circular'; - const stretchableMarks = ['bar', 'line', 'rect', 'area']; - const hasStretchableMark = this.getResolvedTracks().reduce( - (acc, spec) => acc && stretchableMarks.includes(spec.mark), - true - ); - const noMouseInteractions = !this.options.spec.mouseEvents; - - return isFirstTrack1D && isNotCircularLayout && hasStretchableMark && noMouseInteractions; - } - /** - * Used in drawTile(). Checks if the tile Graphic is too stretched. If so, it returns true. - * @param stretchFactor The factor by which the tile is stretched - * @returns True if the tile is too stretched, false otherwise - */ - #isTooStretched(stretchFactor: number) { - const defaultThreshold = 1.5; - const threshold = this.options.spec.experimental?.stretchGraphicsThreshold ?? defaultThreshold; - return stretchFactor > threshold || stretchFactor < 1 / threshold; + }); + } + /** + * Called in legend.ts + */ + shareScaleOffsetAcrossTracksAndTiles(scaleOffset: [number, number], channelKey: 'color' | 'stroke') { + const models = this.visibleAndFetchedGoslingModels(); + models.forEach(d => { + const channel = d.spec()[channelKey]; + if (IsChannelDeep(channel)) { + channel.scaleOffset = scaleOffset; + } + const channelOriginal = d.originalSpec()[channelKey]; + if (IsChannelDeep(channelOriginal)) { + channelOriginal.scaleOffset = scaleOffset; + } + }); + } + /** + * Used in drawTile() + * Checks if the track has marks which are stretchable. Stretching + * is not supported for circular layouts or 2D tracks + */ + #hasStretchableGraphics() { + const hasStretchOption = this.options.spec.experimental?.stretchGraphics; + if (hasStretchOption === true) { + return true; + } else if (hasStretchOption === false) { + return false; } + // The default behavior is that we stretch when stretching looks acceptable + const isFirstTrack1D = !Is2DTrack(this.getResolvedTracks()[0]); + const isNotCircularLayout = this.options.spec.layout !== 'circular'; + const stretchableMarks = ['bar', 'line', 'rect', 'area']; + const hasStretchableMark = this.getResolvedTracks().reduce( + (acc, spec) => acc && stretchableMarks.includes(spec.mark), + true + ); + const noMouseInteractions = !this.options.spec.mouseEvents; + + return isFirstTrack1D && isNotCircularLayout && hasStretchableMark && noMouseInteractions; } - return new GoslingTrackClass(); -}; - + /** + * Used in drawTile(). Checks if the tile Graphic is too stretched. If so, it returns true. + * @param stretchFactor The factor by which the tile is stretched + * @returns True if the tile is too stretched, false otherwise + */ + #isTooStretched(stretchFactor: number) { + const defaultThreshold = 1.5; + const threshold = this.options.spec.experimental?.stretchGraphicsThreshold ?? defaultThreshold; + return stretchFactor > threshold || stretchFactor < 1 / threshold; + } +} export default createPluginTrack(config, factory); From deaa172f5cd3dcf569bd94824e0673d89a41778d Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 18:12:47 -0400 Subject: [PATCH 025/139] feat: make GoslingTrackClass standalone --- src/tracks/gosling-track/gosling-track.ts | 133 ++++++++++------------ src/tracks/gosling-track/utils.ts | 86 ++++++++++++++ 2 files changed, 149 insertions(+), 70 deletions(-) create mode 100644 src/tracks/gosling-track/utils.ts diff --git a/src/tracks/gosling-track/gosling-track.ts b/src/tracks/gosling-track/gosling-track.ts index ae0d1fafe..33e6e3af8 100644 --- a/src/tracks/gosling-track/gosling-track.ts +++ b/src/tracks/gosling-track/gosling-track.ts @@ -1,6 +1,7 @@ -import type * as PIXI from 'pixi.js'; +import * as PIXI from 'pixi.js'; import { isEqual, sampleSize, uniqBy } from 'lodash-es'; import type { ScaleLinear } from 'd3-scale'; +import { scaleLinear } from 'd3-scale'; import type { SingleTrack, OverlaidTrack, @@ -15,7 +16,6 @@ import { type MouseEventData, isPointInsideDonutSlice } from '../gosling-track/g import { BamDataFetcher, type TabularDataFetcher } from '@data-fetchers'; import type { Tile as _Tile, TileData, TileDataBase } from '@higlass/services'; import { LinearBrushModel } from './linear-brush-model'; -import { getTheme } from '@gosling-lang/gosling-theme'; import { getTabularData } from './data-abstraction'; import type { CompleteThemeDeep } from '../../core/utils/theme'; @@ -50,9 +50,8 @@ import { } from '@gosling-lang/gosling-schema'; import { HIGLASS_AXIS_SIZE } from '../../compiler/higlass-model'; import { flatArrayToPairArray } from '../../core/utils/array'; -import { createPluginTrack, type PluginTrackFactory, type TrackConfig } from '../../core/utils/define-plugin-track'; import { uuid } from '../../core/utils/uuid'; -import type { Scale, TilePosition } from '@higlass/tracks'; +import type { Context, Scale, TilePosition } from '@higlass/tracks'; // Additions import { tileProxy } from '@higlass/services'; @@ -78,7 +77,7 @@ const DEFAULT_MOUSE_EVENT_STYLE: Required = { arrange: 'front' }; -interface GoslingTrackOptions { +export interface GoslingTrackOptions { /** * Track ID specified by users */ @@ -89,9 +88,10 @@ interface GoslingTrackOptions { siblingIds: string[]; spec: SingleTrack | OverlaidTrack; theme: CompleteThemeDeep; - showMousePosition?: boolean; } +export type GoslingTrackContext = Context; + /** Tile data used in Gosling data fetchers */ interface TabularTileData extends TileDataBase { tabularData: Datum[]; @@ -120,30 +120,14 @@ function initProcessedTileInfo(): ProcessedTileInfo { return { goslingModels: [], tabularData: [], skipRendering: false }; } -const config: TrackConfig = { - type: 'gosling-track', - datatype: ['multivec', 'epilogos'], - orientation: '1d-horizontal', - // @ts-expect-error missing default spec - defaultOptions: { - // TODO: Are any of these used? - // labelPosition: 'none', - // labelColor: 'black', - // labelTextOpacity: 0.4, - // trackBorderWidth: 0, - // trackBorderColor: 'black', - // backgroundColor: 'white', - // barBorder: false, - // sortLargestOnTop: true, - // axisPositionHorizontal: 'left', - theme: getTheme('light') - } -}; - /* Custom loading label */ const loadingTextStyle = getTextStyle({ color: 'black', size: 12 }); -class GoslingTrackClass extends TiledPixiTrack { +/** + * The main plugin track in Gosling. This is a versetile plugin track for HiGlass which relies on GoslingTrackModel + * to keep track of mouse event and channel scales. + */ +export class GoslingTrackClass extends TiledPixiTrack { /* * * * Properties @@ -154,23 +138,24 @@ class GoslingTrackClass extends TiledPixiTrack { mRangeBrush: LinearBrushModel; #assembly?: Assembly; // Used to get the relative genomic position #processedTileInfo: Record; + #viewUid: string; firstDraw = true; // False if draw has been called once already. Used with onNewTrack API. Public because used in draw() // Used in mark/legend.ts - gLegend? = HGC.libraries.d3Selection.select(context.svgElement).append('g'); + gLegend?: Selection; displayedLegends: DisplayedLegend[] = []; // Store the color legends added so far so that we can avoid overlaps and redundancy // Used in mark/text.ts - textGraphics: unknown[] = []; + textGraphics: PIXI.Text[] = []; textsBeingUsed = 0; // Mouse fields - pMouseHover = new HGC.libraries.PIXI.Graphics(); - pMouseSelection = new HGC.libraries.PIXI.Graphics(); + pMouseHover = new PIXI.Graphics(); + pMouseSelection = new PIXI.Graphics(); #mouseDownX = 0; #mouseDownY = 0; #isRangeBrushActivated = false; - #gBrush = HGC.libraries.d3Selection.select(context.svgElement).append('g'); - #loadingTextStyleObj = new HGC.libraries.PIXI.TextStyle(loadingTextStyle); - #loadingTextBg = new HGC.libraries.PIXI.Graphics(); - #loadingText = new HGC.libraries.PIXI.Text('', loadingTextStyle); + #gBrush: Selection; + #loadingTextStyleObj = new PIXI.TextStyle(loadingTextStyle); + #loadingTextBg = new PIXI.Graphics(); + #loadingText = new PIXI.Text('', loadingTextStyle); prevVisibleAndFetchedTiles?: Tile[]; resolvedTracks: SingleTrack[] | undefined; // This is used to persist processed tile data across draw() calls. @@ -182,13 +167,18 @@ class GoslingTrackClass extends TiledPixiTrack { * * */ - constructor() { + constructor(context: GoslingTrackContext, options: GoslingTrackOptions) { super(context, options); - const { isShowGlobalMousePosition } = context; + this.#viewUid = context.viewUid; + + if (context.dataFetcher) { + context.dataFetcher.track = this; + } - context.dataFetcher.track = this; this.#processedTileInfo = {}; this.#assembly = this.options.spec.assembly; + this.gLegend = select(context.svgElement).append('g'); + this.#gBrush = select(context.svgElement).append('g'); // Add unique IDs to each of the overlaid tracks that will be rendered independently. if ('overlay' in this.options.spec) { @@ -214,7 +204,7 @@ class GoslingTrackClass extends TiledPixiTrack { // Enable click event this.pMask.interactive = true; - this.mRangeBrush = new LinearBrushModel(this.#gBrush, HGC.libraries, this.options.spec.style?.brush); + this.mRangeBrush = new LinearBrushModel(this.#gBrush, this.options.spec.style?.brush); this.mRangeBrush.on('brush', this.#onRangeBrush.bind(this)); this.pMask.on('mousedown', (e: PIXI.InteractionEvent) => { @@ -232,16 +222,6 @@ class GoslingTrackClass extends TiledPixiTrack { this.pMask.on('mouseout', this.#onMouseOut.bind(this)); this.flipText = this.options.spec.orientation === 'vertical'; - // Draw the mouse position - // See https://github.com/higlass/higlass/blob/38f0c4415f0595c3b9d685a754d6661dc9612f7c/app/scripts/utils/show-mouse-position.js#L28 - if (this.options?.showMousePosition && !this.hideMousePosition) { - this.hideMousePosition = HGC.utils.showMousePosition( - this, - Is2DTrack(this.getResolvedTracks()[0]), - isShowGlobalMousePosition() - ); - } - // We do not use HiGlass' trackNotFoundText this.pLabel.removeChild(this.trackNotFoundText); @@ -251,10 +231,10 @@ class GoslingTrackClass extends TiledPixiTrack { this.pLabel.addChild(this.#loadingText); // This improves the arc/link rendering performance - HGC.libraries.PIXI.GRAPHICS_CURVES.adaptive = this.options.spec.style?.enableSmoothPath ?? false; - if (HGC.libraries.PIXI.GRAPHICS_CURVES.adaptive) { - HGC.libraries.PIXI.GRAPHICS_CURVES.maxLength = 1; - HGC.libraries.PIXI.GRAPHICS_CURVES.maxSegments = 2048 * 10; + PIXI.GRAPHICS_CURVES.adaptive = this.options.spec.style?.enableSmoothPath ?? false; + if (PIXI.GRAPHICS_CURVES.adaptive) { + PIXI.GRAPHICS_CURVES.maxLength = 1; + PIXI.GRAPHICS_CURVES.maxSegments = 2048 * 10; } } @@ -527,7 +507,7 @@ class GoslingTrackClass extends TiledPixiTrack { GenomicPosition ]; publish('location', { - id: context.viewUid, + id: this.#viewUid, genomicRange: genomicRange }); } @@ -614,7 +594,7 @@ class GoslingTrackClass extends TiledPixiTrack { const tilesetInfo = this.tilesetInfo; tiles.forEach((tile, i) => { if (i === 0) { - const [refTile] = HGC.utils.trackUtils.calculate1DVisibleTiles(tilesetInfo, this._xScale); + const [refTile] = calculate1DVisibleTiles(tilesetInfo, this._xScale); tile.tileData.zoomLevel = refTile[0]; tile.tileData.tilePos = [refTile[1], refTile[1]]; (tile.tileData as TabularTileData).tabularData = tabularData; @@ -633,7 +613,7 @@ class GoslingTrackClass extends TiledPixiTrack { calculateVisibleTiles() { if (!this.tilesetInfo) return; if (isTabularDataFetcher(this.dataFetcher)) { - const tiles = HGC.utils.trackUtils.calculate1DVisibleTiles(this.tilesetInfo, this._xScale); + const tiles = calculate1DVisibleTiles(this.tilesetInfo, this._xScale); const maxTileWith = this.tilesetInfo.max_tile_width ?? this.dataFetcher.MAX_TILE_WIDTH ?? Number.MAX_SAFE_INTEGER; @@ -828,8 +808,7 @@ class GoslingTrackClass extends TiledPixiTrack { const { tileX, tileWidth } = this.getTilePosAndDimensions(tile.tileData.zoomLevel, tile.tileData.tilePos); - const tileXScale = HGC.libraries.d3Scale - .scaleLinear() + const tileXScale = scaleLinear() .domain([0, this.#binsPerTile]) .range([tileX, tileX + tileWidth]); @@ -1063,7 +1042,7 @@ class GoslingTrackClass extends TiledPixiTrack { const NUM_OF_ROWS_IN_PREVIEW = 100; const numOrRows = tabularDataTransformed.length; PubSub.publish('data-preview', { - id: context.viewUid, + id: this.#viewUid, dataConfig: JSON.stringify({ data: resolvedSpec.data }), data: NUM_OF_ROWS_IN_PREVIEW > numOrRows @@ -1244,14 +1223,25 @@ class GoslingTrackClass extends TiledPixiTrack { ) ) { publish(eventType, { - id: context.viewUid, + id: this.#viewUid, spec: structuredClone(this.options.spec), - shape: { x, y, width, height, cx, cy, innerRadius, outerRadius, startAngle, endAngle } + shape: { + x, + y, + width, + height, + cx, + cy, + innerRadius, + outerRadius, + startAngle, + endAngle + } }); } } else { publish(eventType, { - id: context.viewUid, + id: this.#viewUid, spec: structuredClone(this.options.spec), shape: { x, y, width, height } }); @@ -1264,7 +1254,11 @@ class GoslingTrackClass extends TiledPixiTrack { if (range === null) { // brush just removed if (!skipApiTrigger) { - publish('rangeSelect', { id: context.viewUid, genomicRange: null, data: [] }); + publish('rangeSelect', { + id: this.#viewUid, + genomicRange: null, + data: [] + }); } return; } @@ -1314,7 +1308,7 @@ class GoslingTrackClass extends TiledPixiTrack { ]; publish('rangeSelect', { - id: context.viewUid, + id: this.#viewUid, genomicRange, data: capturedElements.map(d => d.value) }); @@ -1414,7 +1408,7 @@ class GoslingTrackClass extends TiledPixiTrack { // API call publish('mouseOver', { - id: context.viewUid, + id: this.#viewUid, genomicPosition, data: capturedElements.map(d => d.value) }); @@ -1433,11 +1427,11 @@ class GoslingTrackClass extends TiledPixiTrack { const rawValue = capturedElements[0].value[d.field]; let value = rawValue; if (d.type === 'quantitative' && d.format) { - value = HGC.libraries.d3Format.format(d.format)(+rawValue); + value = format(d.format)(+rawValue); } else if (d.type === 'genomic') { // e.g., chr1:204,133 const { chromosome, position } = getRelativeGenomicPosition(+rawValue, this.#assembly); - value = `${chromosome}:${HGC.libraries.d3Format.format(',')(position)}`; + value = `${chromosome}:${format(',')(position)}`; } return ( @@ -1471,7 +1465,7 @@ class GoslingTrackClass extends TiledPixiTrack { */ #publishOnNewTrack() { publish('onNewTrack', { - id: context.viewUid + id: this.#viewUid }); } @@ -1515,7 +1509,7 @@ class GoslingTrackClass extends TiledPixiTrack { this.#loadingText.y = this.position[1] + this.dimensions[1] - margin / 2.0; // Show background - const metric = HGC.libraries.PIXI.TextMetrics.measureText(text, this.#loadingTextStyleObj); + const metric = PIXI.TextMetrics.measureText(text, this.#loadingTextStyleObj); const { width: w, height: h } = metric; this.#loadingTextBg.clear(); @@ -1603,4 +1597,3 @@ class GoslingTrackClass extends TiledPixiTrack { return stretchFactor > threshold || stretchFactor < 1 / threshold; } } -export default createPluginTrack(config, factory); diff --git a/src/tracks/gosling-track/utils.ts b/src/tracks/gosling-track/utils.ts new file mode 100644 index 000000000..c898ea51a --- /dev/null +++ b/src/tracks/gosling-track/utils.ts @@ -0,0 +1,86 @@ +import { tileProxy } from '@higlass/services'; + +/** + * Calculate the current zoom level for a 1D track + * + * @param {object} tilesetInfo The tileset info for the track. Should contain + * min_pos and max_pos arrays, each of which has one + * value which stores the minimum and maximum data + * positions respectively. + * @param {function} xScale The current D3 scale function for the track. + * @param {number} maxZoom The maximum zoom level allowed by the track. + * @return {number} The current zoom level of the track. + */ +const calculate1DZoomLevel = (tilesetInfo, xScale, maxZoom: number) => { + if (typeof maxZoom === 'undefined') { + maxZoom = Number.MAX_SAFE_INTEGER; + } + // offset by 2 because 1D tiles are more dense than 2D tiles + // 1024 points per tile vs 256 for 2D tiles + if (tilesetInfo.resolutions) { + const zoomIndexX = tileProxy.calculateZoomLevelFromResolutions(tilesetInfo.resolutions, xScale); + + return zoomIndexX; + } + + // the tileProxy calculateZoomLevel function only cares about the + // difference between the minimum and maximum position + const xZoomLevel = tileProxy.calculateZoomLevel( + xScale, + tilesetInfo.min_pos[0], + tilesetInfo.max_pos[0], + tilesetInfo.bins_per_dimension || tilesetInfo.tile_size + ); + + const zoomLevel = Math.min(xZoomLevel, maxZoom); + return Math.max(zoomLevel, 0); +}; + +/** + * Calculate which tiles should be visible given a track's + * scale. + * + * @param {object} tilesetInfo The track's tileset info, containing either the `resolutions` + * list or min_pos and max_pos arrays + * @param {function} scale The track's D3 scale function. + * @return {array} A list of visible tiles (e.g. [[1,0],[1,1]]) + */ +export const calculate1DVisibleTiles = (tilesetInfo, scale) => { + // if we don't know anything about this dataset, no point + // in trying to get tiles + if (!tilesetInfo) { + return []; + } + + // calculate the zoom level given the scales and the data bounds + const zoomLevel = calculate1DZoomLevel(tilesetInfo, scale, tilesetInfo.max_zoom); + + if (tilesetInfo.resolutions) { + const sortedResolutions = tilesetInfo.resolutions.map(x => +x).sort((a, b) => b - a); + + const xTiles = tileProxy.calculateTilesFromResolution( + sortedResolutions[zoomLevel], + scale, + tilesetInfo.min_pos[0], + tilesetInfo.max_pos[0] + ); + + const tiles = xTiles.map(x => [zoomLevel, x]); + + return tiles; + } + + // x doesn't necessary mean 'x' axis, it just refers to the relevant axis + // (x if horizontal, y if vertical) + const xTiles = tileProxy.calculateTiles( + zoomLevel, + scale, + tilesetInfo.min_pos[0], + tilesetInfo.max_pos[0], + tilesetInfo.max_zoom, + tilesetInfo.max_width + ); + + const tiles = xTiles.map(x => [zoomLevel, x]); + return tiles; +}; From 901686a15f11adfc3e97854480d7771f7ca5c5af Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 18:19:56 -0400 Subject: [PATCH 026/139] feat: remove HGC from mark drawing --- src/core/mark/area.ts | 2 +- src/core/mark/axis.ts | 15 ++++--- src/core/mark/index.ts | 28 ++++++------- src/core/mark/legend.ts | 50 ++++++++++------------- src/core/mark/rect.ts | 2 +- src/core/mark/rule.ts | 2 +- src/core/mark/text.ts | 29 ++++++------- src/core/mark/title.ts | 13 +++--- src/tracks/gosling-track/gosling-track.ts | 6 +-- 9 files changed, 69 insertions(+), 78 deletions(-) diff --git a/src/core/mark/area.ts b/src/core/mark/area.ts index e60a95ead..23d442a48 100644 --- a/src/core/mark/area.ts +++ b/src/core/mark/area.ts @@ -9,7 +9,7 @@ import colorToHex from '../utils/color-to-hex'; /** * Draw area marks */ -export function drawArea(HGC: import('@higlass/types').HGC, track: any, tile: Tile, model: GoslingTrackModel) { +export function drawArea(track: any, tile: Tile, model: GoslingTrackModel) { /* track spec */ const spec = model.spec(); diff --git a/src/core/mark/axis.ts b/src/core/mark/axis.ts index 4980eb158..0d9d0ca53 100644 --- a/src/core/mark/axis.ts +++ b/src/core/mark/axis.ts @@ -7,6 +7,7 @@ import type { CompleteThemeDeep } from '../utils/theme'; import { cartesianToPolar, valueToRadian } from '../utils/polar'; import { isNumberArray, isStringArray } from '../utils/array'; import { getTextStyle } from '../utils/text-style'; +import * as PIXI from 'pixi.js'; const EXTENT_TICK_SIZE = 8; const TICK_SIZE = 6; @@ -15,7 +16,6 @@ const TICK_SIZE = 6; * Draw linear scale Y axis */ export function drawLinearYAxis( - HGC: { libraries: { PIXI: typeof import('pixi.js') } }, trackInfo: any, _tile: unknown, model: GoslingTrackModel, @@ -130,7 +130,7 @@ export function drawLinearYAxis( const y = yScale(t); tickEnd = isLeft ? dx + TICK_SIZE * 2 : dx - TICK_SIZE * 2; - const textGraphic = new HGC.libraries.PIXI.Text(t, styleConfig); + const textGraphic = new PIXI.Text(t, styleConfig); textGraphic.anchor.x = isLeft ? 0 : 1; textGraphic.anchor.y = y === 0 ? 0.9 : 0.5; textGraphic.position.x = tickEnd; @@ -150,7 +150,6 @@ export function drawLinearYAxis( * Draw linear scale Y axis */ export function drawCircularYAxis( - HGC: import('@higlass/types').HGC, trackInfo: any, tile: Tile, model: GoslingTrackModel, @@ -319,15 +318,15 @@ export function drawCircularYAxis( fontFamily: theme.axis.labelFontFamily, fontWeight: theme.axis.labelFontWeight }); - const textGraphic = new HGC.libraries.PIXI.Text(t, styleConfig); + const textGraphic = new PIXI.Text(t, styleConfig); textGraphic.anchor.x = isLeft ? 1 : 0; textGraphic.anchor.y = 0.5; textGraphic.position.x = pos.x; textGraphic.position.y = pos.y; textGraphic.resolution = 4; - const txtStyle = new HGC.libraries.PIXI.TextStyle(styleConfig); - const metric = HGC.libraries.PIXI.TextMetrics.measureText(textGraphic.text, txtStyle); + const txtStyle = new PIXI.TextStyle(styleConfig); + const metric = PIXI.TextMetrics.measureText(textGraphic.text, txtStyle); // Scale the width of text label so that its width is the same when converted into circular form const txtWidth = ((metric.width / (2 * currentR * Math.PI)) * tw * 360) / (endAngle - startAngle); @@ -340,13 +339,13 @@ export function drawCircularYAxis( const ropePoints: import('pixi.js').Point[] = []; for (let i = scaledEndX; i >= scaledStartX; i -= txtWidth / 10.0) { const p = cartesianToPolar(i, tw, currentR, cx, cy, startAngle, endAngle); - ropePoints.push(new HGC.libraries.PIXI.Point(p.x, p.y)); + ropePoints.push(new PIXI.Point(p.x, p.y)); } // Render a label // @ts-expect-error missing argument in updateText? textGraphic.updateText(); - const rope = new HGC.libraries.PIXI.SimpleRope(textGraphic.texture, ropePoints); + const rope = new PIXI.SimpleRope(textGraphic.texture, ropePoints); graphics.addChild(rope); }); }); diff --git a/src/core/mark/index.ts b/src/core/mark/index.ts index dcddb4b3d..0cebcb3e7 100644 --- a/src/core/mark/index.ts +++ b/src/core/mark/index.ts @@ -50,8 +50,8 @@ export const RESOLUTION = 4; /** * Draw a track based on the track specification in a Gosling grammar. */ -export function drawMark(HGC: import('@higlass/types').HGC, trackInfo: any, tile: Tile, model: GoslingTrackModel) { - if (!HGC || !trackInfo || !tile) { +export function drawMark(trackInfo: any, tile: Tile, model: GoslingTrackModel) { + if (!trackInfo || !tile) { // We did not receive parameters correctly. return; } @@ -91,10 +91,10 @@ export function drawMark(HGC: import('@higlass/types').HGC, trackInfo: any, tile drawLine(tile.graphics, model, trackWidth, trackHeight); break; case 'area': - drawArea(HGC, trackInfo, tile, model); + drawArea(trackInfo, tile, model); break; case 'rect': - drawRect(HGC, trackInfo, tile, model); + drawRect(trackInfo, tile, model); break; case 'triangleLeft': case 'triangleRight': @@ -102,10 +102,10 @@ export function drawMark(HGC: import('@higlass/types').HGC, trackInfo: any, tile drawTriangle(tile.graphics, model, trackWidth, trackHeight); break; case 'text': - drawText(HGC, trackInfo, tile, model); + drawText(trackInfo, tile, model); break; case 'rule': - drawRule(HGC, trackInfo, tile, model); + drawRule(trackInfo, tile, model); break; case 'betweenLink': drawBetweenLink(tile.graphics, trackInfo, model); @@ -123,13 +123,12 @@ export function drawMark(HGC: import('@higlass/types').HGC, trackInfo: any, tile * Draw chart embellishments before rendering marks. */ export function drawPreEmbellishment( - HGC: import('@higlass/types').HGC, trackInfo: any, tile: Tile, model: GoslingTrackModel, theme: Required ) { - if (!HGC || !trackInfo || !tile) { + if (!trackInfo || !tile) { // We did not receive parameters correctly. return; } @@ -158,13 +157,12 @@ export function drawPreEmbellishment( * Draw chart embellishments after rendering marks. */ export function drawPostEmbellishment( - HGC: import('@higlass/types').HGC, trackInfo: any, tile: Tile, model: GoslingTrackModel, theme: Required ) { - if (!HGC || !trackInfo || !tile) { + if (!trackInfo || !tile) { // We did not receive parameters correctly. return; } @@ -181,11 +179,11 @@ export function drawPostEmbellishment( const isCircular = model.spec().layout === 'circular'; if (isCircular) { - drawCircularYAxis(HGC, trackInfo, tile, model, theme); - drawCircularTitle(HGC, trackInfo, tile, model, theme); + drawCircularYAxis(trackInfo, tile, model, theme); + drawCircularTitle(trackInfo, tile, model, theme); } else { - drawLinearYAxis(HGC, trackInfo, tile, model, theme); - drawRowLegend(HGC, trackInfo, tile, model, theme); + drawLinearYAxis(trackInfo, tile, model, theme); + drawRowLegend(trackInfo, tile, model, theme); } - drawColorLegend(HGC, trackInfo, tile, model, theme); + drawColorLegend(trackInfo, tile, model, theme); } diff --git a/src/core/mark/legend.ts b/src/core/mark/legend.ts index a718b14f7..caa5e2acf 100644 --- a/src/core/mark/legend.ts +++ b/src/core/mark/legend.ts @@ -7,14 +7,12 @@ import type { Dimension } from '../utils/position'; import { scaleLinear, type ScaleLinear } from 'd3-scale'; import { getTextStyle } from '../utils/text-style'; import type { SubjectPosition, D3DragEvent } from 'd3-drag'; - -// Just the libraries necesssary fro this module -type Libraries = Pick; +import * as PIXI from 'pixi.js'; +import { drag as d3Drag } from 'd3-drag'; type LegendOffset = { offsetRight: number }; export function drawColorLegend( - HGC: { libraries: Libraries }, trackInfo: any, _tile: unknown, model: GoslingTrackModel, @@ -33,10 +31,10 @@ export function drawColorLegend( if (IsChannelDeep(spec.color) && spec.color.legend) { switch (spec.color.type) { case 'nominal': - drawColorLegendCategories(HGC, trackInfo, _tile, model, theme); + drawColorLegendCategories(trackInfo, _tile, model, theme); break; case 'quantitative': - drawColorLegendQuantitative(HGC, trackInfo, _tile, model, theme, 'color', offset); + drawColorLegendQuantitative(trackInfo, _tile, model, theme, 'color', offset); break; } } @@ -44,14 +42,13 @@ export function drawColorLegend( if (IsChannelDeep(spec.stroke) && spec.stroke.legend) { switch (spec.stroke.type) { case 'quantitative': - drawColorLegendQuantitative(HGC, trackInfo, _tile, model, theme, 'stroke', offset); + drawColorLegendQuantitative(trackInfo, _tile, model, theme, 'stroke', offset); break; } } } export function drawColorLegendQuantitative( - HGC: { libraries: Libraries }, trackInfo: any, _tile: unknown, model: GoslingTrackModel, @@ -118,7 +115,7 @@ export function drawColorLegendQuantitative( fontFamily: theme.legend.labelFontFamily }); - const textGraphic = new HGC.libraries.PIXI.Text(titleStr, { + const textGraphic = new PIXI.Text(titleStr, { ...labelTextStyle, fontWeight: 'bold' }); @@ -127,8 +124,8 @@ export function drawColorLegendQuantitative( textGraphic.position.x = legendX + 10; textGraphic.position.y = legendY + 10; - const textStyleObj = new HGC.libraries.PIXI.TextStyle({ ...labelTextStyle, fontWeight: 'bold' }); - const textMetrics = HGC.libraries.PIXI.TextMetrics.measureText(titleStr, textStyleObj); + const textStyleObj = new PIXI.TextStyle({ ...labelTextStyle, fontWeight: 'bold' }); + const textMetrics = PIXI.TextMetrics.measureText(titleStr, textStyleObj); graphics.addChild(textGraphic); @@ -208,8 +205,7 @@ export function drawColorLegendQuantitative( .attr('stroke', 'black') .attr('stroke-width', '0.5px') .call( - HGC.libraries.d3Drag - .drag() + d3Drag() .on('start', (event: D3DragEvent) => { trackInfo.startEvent = event.sourceEvent; }) @@ -277,7 +273,7 @@ export function drawColorLegendQuantitative( graphics.lineTo(tickEnd, y); // labels - const textGraphic = new HGC.libraries.PIXI.Text(value, labelTextStyle); + const textGraphic = new PIXI.Text(value, labelTextStyle); textGraphic.anchor.x = 1; textGraphic.anchor.y = 0.5; textGraphic.position.x = tickEnd - 6; @@ -291,7 +287,6 @@ export function drawColorLegendQuantitative( } export function drawColorLegendCategories( - HGC: { libraries: Libraries }, track: any, _tile: unknown, tm: GoslingTrackModel, @@ -357,7 +352,7 @@ export function drawColorLegendCategories( } const color = tm.encodedValue('color', category); - const textGraphic = new HGC.libraries.PIXI.Text(category, labelTextStyle); + const textGraphic = new PIXI.Text(category, labelTextStyle); textGraphic.anchor.x = 1; textGraphic.anchor.y = 0; textGraphic.position.x = track.position[0] + track.dimensions[0] - maxWidth - paddingX; @@ -365,8 +360,8 @@ export function drawColorLegendCategories( graphics.addChild(textGraphic); - const textStyleObj = new HGC.libraries.PIXI.TextStyle(labelTextStyle); - const textMetrics = HGC.libraries.PIXI.TextMetrics.measureText(category, textStyleObj); + const textStyleObj = new PIXI.TextStyle(labelTextStyle); + const textMetrics = PIXI.TextMetrics.measureText(category, textStyleObj); if (cumY < textMetrics.height + paddingY * 3) { cumY = textMetrics.height + paddingY * 3; @@ -384,7 +379,7 @@ export function drawColorLegendCategories( // Show legend vertically if (spec.style?.legendTitle) { - const textGraphic = new HGC.libraries.PIXI.Text(spec.style?.legendTitle, { + const textGraphic = new PIXI.Text(spec.style?.legendTitle, { ...labelTextStyle, fontWeight: 'bold' }); @@ -393,8 +388,8 @@ export function drawColorLegendCategories( textGraphic.position.x = track.position[0] + track.dimensions[0] - paddingX; textGraphic.position.y = track.position[1] + cumY; - const textStyleObj = new HGC.libraries.PIXI.TextStyle({ ...labelTextStyle, fontWeight: 'bold' }); - const textMetrics = HGC.libraries.PIXI.TextMetrics.measureText(spec.style?.legendTitle, textStyleObj); + const textStyleObj = new PIXI.TextStyle({ ...labelTextStyle, fontWeight: 'bold' }); + const textMetrics = PIXI.TextMetrics.measureText(spec.style?.legendTitle, textStyleObj); graphics.addChild(textGraphic); @@ -409,7 +404,7 @@ export function drawColorLegendCategories( const color = tm.encodedValue('color', category); - const textGraphic = new HGC.libraries.PIXI.Text(category, labelTextStyle); + const textGraphic = new PIXI.Text(category, labelTextStyle); textGraphic.anchor.x = 1; textGraphic.anchor.y = 0; textGraphic.position.x = track.position[0] + track.dimensions[0] - paddingX; @@ -417,8 +412,8 @@ export function drawColorLegendCategories( graphics.addChild(textGraphic); - const textStyleObj = new HGC.libraries.PIXI.TextStyle(labelTextStyle); - const textMetrics = HGC.libraries.PIXI.TextMetrics.measureText(category, textStyleObj); + const textStyleObj = new PIXI.TextStyle(labelTextStyle); + const textMetrics = PIXI.TextMetrics.measureText(category, textStyleObj); if (maxWidth < textMetrics.width + paddingX * 3) { maxWidth = textMetrics.width + paddingX * 3; @@ -461,7 +456,6 @@ export function drawColorLegendCategories( } export function drawRowLegend( - HGC: { libraries: Libraries }, trackInfo: any, _tile: unknown, tm: GoslingTrackModel, @@ -507,7 +501,7 @@ export function drawRowLegend( rowCategories.forEach(category => { const rowPosition = tm.encodedValue('row', category); - const textGraphic = new HGC.libraries.PIXI.Text(category, labelTextStyle); + const textGraphic = new PIXI.Text(category, labelTextStyle); textGraphic.anchor.x = 0; textGraphic.anchor.y = 0; textGraphic.position.x = trackInfo.position[0] + paddingX; @@ -515,8 +509,8 @@ export function drawRowLegend( graphics.addChild(textGraphic); - const textStyleObj = new HGC.libraries.PIXI.TextStyle(labelTextStyle); - const textMetrics = HGC.libraries.PIXI.TextMetrics.measureText(category, textStyleObj); + const textStyleObj = new PIXI.TextStyle(labelTextStyle); + const textMetrics = PIXI.TextMetrics.measureText(category, textStyleObj); graphics.beginFill(colorToHex(theme.legend.background), theme.legend.backgroundOpacity); graphics.lineStyle( diff --git a/src/core/mark/rect.ts b/src/core/mark/rect.ts index fedffcde5..f635ddc8c 100644 --- a/src/core/mark/rect.ts +++ b/src/core/mark/rect.ts @@ -5,7 +5,7 @@ import type { PIXIVisualProperty } from '../visual-property.schema'; import colorToHex from '../utils/color-to-hex'; import { IsChannelDeep } from '@gosling-lang/gosling-schema'; -export function drawRect(HGC: import('@higlass/types').HGC, track: any, tile: Tile, model: GoslingTrackModel) { +export function drawRect(track: any, tile: Tile, model: GoslingTrackModel) { /* track spec */ const spec = model.spec(); diff --git a/src/core/mark/rule.ts b/src/core/mark/rule.ts index a47f5bd22..dde6ae444 100644 --- a/src/core/mark/rule.ts +++ b/src/core/mark/rule.ts @@ -5,7 +5,7 @@ import { getValueUsingChannel } from '@gosling-lang/gosling-schema'; import { cartesianToPolar, valueToRadian } from '../utils/polar'; import colorToHex from '../utils/color-to-hex'; -export function drawRule(HGC: import('@higlass/types').HGC, trackInfo: any, tile: Tile, model: GoslingTrackModel) { +export function drawRule(trackInfo: any, tile: Tile, model: GoslingTrackModel) { /* track spec */ const spec = model.spec(); diff --git a/src/core/mark/text.ts b/src/core/mark/text.ts index 99abc256f..d311fe8fe 100644 --- a/src/core/mark/text.ts +++ b/src/core/mark/text.ts @@ -4,6 +4,7 @@ import type { GoslingTrackModel } from '../../tracks/gosling-track/gosling-track import { group } from 'd3-array'; import { getValueUsingChannel, IsStackedMark } from '@gosling-lang/gosling-schema'; import { cartesianToPolar } from '../utils/polar'; +import * as PIXI from 'pixi.js'; // Merge with the one in the `utils/text-style.ts` export const TEXT_STYLE_GLOBAL = { @@ -17,7 +18,7 @@ export const TEXT_STYLE_GLOBAL = { strokeThickness: 0 } as const; -export function drawText(HGC: import('@higlass/types').HGC, trackInfo: any, tile: Tile, model: GoslingTrackModel) { +export function drawText(trackInfo: any, tile: Tile, model: GoslingTrackModel) { /* track spec */ const spec = model.spec(); @@ -53,7 +54,7 @@ export function drawText(HGC: import('@higlass/types').HGC, trackInfo: any, tile return; } - const rowGraphics = tile.graphics; // new HGC.libraries.PIXI.Graphics(); // only one row for stacked marks + const rowGraphics = tile.graphics; // new PIXI.Graphics(); // only one row for stacked marks const genomicChannel = model.getGenomicChannel(); if (!genomicChannel || !genomicChannel.field) { @@ -98,7 +99,7 @@ export function drawText(HGC: import('@higlass/types').HGC, trackInfo: any, tile strokeThickness: strokeWidth ?? spec.style?.textStrokeWidth ?? TEXT_STYLE_GLOBAL.strokeThickness, fontWeight: spec.style?.textFontWeight ?? TEXT_STYLE_GLOBAL.fontWeight }; - const textStyleObj = new HGC.libraries.PIXI.TextStyle(localTextStyle); + const textStyleObj = new PIXI.TextStyle(localTextStyle); let textGraphic; if (trackInfo.textGraphics.length > trackInfo.textsBeingUsed) { @@ -108,14 +109,14 @@ export function drawText(HGC: import('@higlass/types').HGC, trackInfo: any, tile textGraphic.text = text; textGraphic.alpha = 1; } else { - textGraphic = new HGC.libraries.PIXI.Text(text, { + textGraphic = new PIXI.Text(text, { ...localTextStyle, fill: color }); trackInfo.textGraphics.push(textGraphic); } - const metric = HGC.libraries.PIXI.TextMetrics.measureText(text, textStyleObj); + const metric = PIXI.TextMetrics.measureText(text, textStyleObj); trackInfo.textsBeingUsed++; const alphaTransition = model.markVisibility(d, { @@ -135,9 +136,9 @@ export function drawText(HGC: import('@higlass/types').HGC, trackInfo: any, tile textGraphic.resolution = 8; textGraphic.updateText(); - textGraphic.texture.baseTexture.scaleMode = HGC.libraries.PIXI.SCALE_MODES.LINEAR; // or .NEAREST + textGraphic.texture.baseTexture.scaleMode = PIXI.SCALE_MODES.LINEAR; // or .NEAREST - const sprite = new HGC.libraries.PIXI.Sprite(textGraphic.texture); + const sprite = new PIXI.Sprite(textGraphic.texture); sprite.x = x; sprite.y = rowHeight - y - prevYEnd; sprite.width = xe - x; @@ -188,7 +189,7 @@ export function drawText(HGC: import('@higlass/types').HGC, trackInfo: any, tile strokeThickness: strokeWidth ?? spec.style?.textStrokeWidth ?? TEXT_STYLE_GLOBAL.strokeThickness, fontWeight: spec.style?.textFontWeight ?? TEXT_STYLE_GLOBAL.fontWeight }; - const textStyleObj = new HGC.libraries.PIXI.TextStyle(localTextStyle); + const textStyleObj = new PIXI.TextStyle(localTextStyle); let textGraphic; if (trackInfo.textGraphics.length > trackInfo.textsBeingUsed) { @@ -198,14 +199,14 @@ export function drawText(HGC: import('@higlass/types').HGC, trackInfo: any, tile textGraphic.text = text; textGraphic.alpha = 1; } else { - textGraphic = new HGC.libraries.PIXI.Text(text, { + textGraphic = new PIXI.Text(text, { ...localTextStyle, fill: color }); trackInfo.textGraphics.push(textGraphic); } - const metric = HGC.libraries.PIXI.TextMetrics.measureText(text, textStyleObj); + const metric = PIXI.TextMetrics.measureText(text, textStyleObj); trackInfo.textsBeingUsed++; const alphaTransition = model.markVisibility(d, { @@ -233,8 +234,8 @@ export function drawText(HGC: import('@higlass/types').HGC, trackInfo: any, tile textGraphic.y = centerPos.y; textGraphic.resolution = 4; - // const txtStyle = new HGC.libraries.PIXI.TextStyle(textStyleObj); - // const metric = HGC.libraries.PIXI.TextMetrics.measureText(textGraphic.text, txtStyle); + // const txtStyle = new PIXI.TextStyle(textStyleObj); + // const metric = PIXI.TextMetrics.measureText(textGraphic.text, txtStyle); // scale the width of text label so that its width is the same when converted into circular form const tw = (metric.width / (2 * r * Math.PI)) * trackWidth; @@ -256,7 +257,7 @@ export function drawText(HGC: import('@higlass/types').HGC, trackInfo: any, tile const eventPointsNear: number[] = []; for (let i = maxX; i >= minX; i -= tw / 10.0) { const p = cartesianToPolar(i, trackWidth, r, tcx, tcy, startAngle, endAngle); - ropePoints.push(new HGC.libraries.PIXI.Point(p.x, p.y)); + ropePoints.push(new PIXI.Point(p.x, p.y)); const pFar = cartesianToPolar( i, @@ -284,7 +285,7 @@ export function drawText(HGC: import('@higlass/types').HGC, trackInfo: any, tile } textGraphic.updateText(); - const rope = new HGC.libraries.PIXI.SimpleRope(textGraphic.texture, ropePoints); + const rope = new PIXI.SimpleRope(textGraphic.texture, ropePoints); rope.alpha = actualOpacity; rowGraphics.addChild(rope); diff --git a/src/core/mark/title.ts b/src/core/mark/title.ts index 7d11861b8..288c2364d 100644 --- a/src/core/mark/title.ts +++ b/src/core/mark/title.ts @@ -1,4 +1,4 @@ -import type * as PIXI from 'pixi.js'; +import * as PIXI from 'pixi.js'; import type { Tile } from '@gosling-lang/gosling-track'; import type { GoslingTrackModel } from '../../tracks/gosling-track/gosling-track-model'; import { cartesianToPolar, valueToRadian } from '../utils/polar'; @@ -7,7 +7,6 @@ import type { CompleteThemeDeep } from '../utils/theme'; import { getTextStyle } from '../utils/text-style'; export function drawCircularTitle( - HGC: import('@higlass/types').HGC, trackInfo: any, tile: Tile, model: GoslingTrackModel, @@ -53,15 +52,15 @@ export function drawCircularTitle( fontFamily: theme.axis.labelFontFamily, // TODO: support fontWeight: theme.axis.labelFontWeight // TODO: support }); - const textGraphic = new HGC.libraries.PIXI.Text(title, styleConfig); + const textGraphic = new PIXI.Text(title, styleConfig); textGraphic.anchor.x = 1; textGraphic.anchor.y = 0.5; textGraphic.position.x = pos.x; textGraphic.position.y = pos.y; textGraphic.resolution = 4; - const txtStyle = new HGC.libraries.PIXI.TextStyle(styleConfig); - const metric = HGC.libraries.PIXI.TextMetrics.measureText(textGraphic.text, txtStyle); + const txtStyle = new PIXI.TextStyle(styleConfig); + const metric = PIXI.TextMetrics.measureText(textGraphic.text, txtStyle); // Scale the width of text label so that its width is the same when converted into circular form const txtWidth = ((metric.width / (2 * titleR * Math.PI)) * tw * 360) / (endAngle - startAngle); @@ -72,7 +71,7 @@ export function drawCircularTitle( const ropePoints: PIXI.Point[] = []; for (let i = scaledEndX; i >= scaledStartX; i -= txtWidth / 10.0) { const p = cartesianToPolar(i, tw, titleR - metric.height / 2.0, cx, cy, startAngle, endAngle); - ropePoints.push(new HGC.libraries.PIXI.Point(p.x, p.y)); + ropePoints.push(new PIXI.Point(p.x, p.y)); } /* Background */ @@ -89,6 +88,6 @@ export function drawCircularTitle( // Render a label // @ts-expect-error, missing argument to updateText textGraphic.updateText(); - const rope = new HGC.libraries.PIXI.SimpleRope(textGraphic.texture, ropePoints); + const rope = new PIXI.SimpleRope(textGraphic.texture, ropePoints); g.addChild(rope); } diff --git a/src/tracks/gosling-track/gosling-track.ts b/src/tracks/gosling-track/gosling-track.ts index 33e6e3af8..d9e5cc3b2 100644 --- a/src/tracks/gosling-track/gosling-track.ts +++ b/src/tracks/gosling-track/gosling-track.ts @@ -391,9 +391,9 @@ export class GoslingTrackClass extends TiledPixiTrack if (!model.trackVisibility({ zoomLevel })) { return; } - drawPreEmbellishment(HGC, this, tile, model, this.options.theme); - drawMark(HGC, this, tile, model); - drawPostEmbellishment(HGC, this, tile, model, this.options.theme); + drawPreEmbellishment(this, tile, model, this.options.theme); + drawMark(this, tile, model); + drawPostEmbellishment(this, tile, model, this.options.theme); }); this.forceDraw(); From 3835cb07359bb30d1a20a09b7fcd8bf9d5509e0c Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 12 Jun 2024 18:21:26 -0400 Subject: [PATCH 027/139] feat: export TiledPixiTrack --- src/higlass/tracks.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/higlass/tracks.ts b/src/higlass/tracks.ts index 306a4ea84..38a044776 100644 --- a/src/higlass/tracks.ts +++ b/src/higlass/tracks.ts @@ -1,2 +1,3 @@ export { SVGTrack } from './higlass-vendored'; export { PixiTrack } from './higlass-vendored'; +export { TiledPixiTrack } from './higlass-vendored'; From 952828a9142d17b9fc55126d9f0a5d95decb4988 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Thu, 13 Jun 2024 12:56:35 -0400 Subject: [PATCH 028/139] feat: add interactors --- src/interactors/cursor.ts | 37 ++++++++++++++++++++++++++++ src/interactors/index.ts | 2 ++ src/interactors/panZoom.ts | 49 ++++++++++++++++++++++++++++++++++++++ tsconfig.json | 1 + vite.config.js | 1 + 5 files changed, 90 insertions(+) create mode 100644 src/interactors/cursor.ts create mode 100644 src/interactors/index.ts create mode 100644 src/interactors/panZoom.ts diff --git a/src/interactors/cursor.ts b/src/interactors/cursor.ts new file mode 100644 index 000000000..c5d1f085f --- /dev/null +++ b/src/interactors/cursor.ts @@ -0,0 +1,37 @@ +import * as PIXI from 'pixi.js'; +import { scaleLinear } from 'd3-scale'; +import { effect, Signal } from '@preact/signals-core'; +import { type Plot } from '../tracks/utils'; + +/** + * This interactor shows a cursor that follows the mouse + */ +export function cursor(plot: Plot & { pMain: PIXI.Container }, cursorPos: Signal) { + const baseScale = scaleLinear().domain(plot.xDomain.value).range([0, plot.domOverlay.clientWidth]); + + const cursor = new PIXI.Graphics(); + cursor.lineStyle(1, 'black', 1); + cursor.moveTo(0, 0); + cursor.lineTo(0, plot.domOverlay.clientHeight); + plot.pMain.addChild(cursor); + + // This function will be called every time the user moves the mouse + const moveCursor = (event: MouseEvent) => { + // Move the cursor to the mouse position + cursor.position.x = event.offsetX; + // Calculate the genomic position of the cursor + const newScale = baseScale.domain(plot.xDomain.value); + const genomicPos = newScale.invert(event.offsetX); + cursorPos.value = genomicPos; + }; + plot.domOverlay.addEventListener('mousemove', moveCursor); + plot.domOverlay.addEventListener('mouseleave', () => { + cursorPos.value = -10; // TODO: set cursor visibility to false instead + }); + + // Every time the domain gets changed we want to update the cursor + effect(() => { + const newScale = baseScale.domain(plot.xDomain.value); + cursor.position.x = newScale(cursorPos.value); + }); +} diff --git a/src/interactors/index.ts b/src/interactors/index.ts new file mode 100644 index 000000000..f8f5260e4 --- /dev/null +++ b/src/interactors/index.ts @@ -0,0 +1,2 @@ +export { cursor } from './cursor'; +export { panZoom } from './panZoom'; diff --git a/src/interactors/panZoom.ts b/src/interactors/panZoom.ts new file mode 100644 index 000000000..4684ac8e2 --- /dev/null +++ b/src/interactors/panZoom.ts @@ -0,0 +1,49 @@ +import { type Signal, effect } from '@preact/signals-core'; +import { scaleLinear } from 'd3-scale'; +import { ZoomTransform, type D3ZoomEvent, zoom } from 'd3-zoom'; +import { select } from 'd3-selection'; +import { zoomWheelBehavior, type Plot } from '../tracks/utils'; + +/** + * This interactor allows the user to pan and zoom the plot + */ + +export function panZoom(plot: Plot, xDomain: Signal<[number, number]>) { + plot.xDomain = xDomain; // Update the xDomain with the signal + const baseScale = scaleLinear().range([0, plot.domOverlay.clientWidth]); + // This will store the xDomain when the user starts zooming + const zoomStartScale = scaleLinear(); + // This function will be called every time the user zooms + const zoomed = (event: D3ZoomEvent) => { + const newXDomain = event.transform.rescaleX(zoomStartScale).domain(); + xDomain.value = newXDomain as [number, number]; + }; + // Create the zoom behavior + const zoomBehavior = zoom() + .wheelDelta(zoomWheelBehavior) + .filter(event => { + // We don't want to zoom if the user is dragging a brush + const isRect = event.target.tagName === 'rect'; + const isMousedown = event.type === 'mousedown'; + const isDraggingBrush = isRect && isMousedown; + // Here are the default filters + const defaultFilter = (!event.ctrlKey || event.type === 'wheel') && !event.button; + // Use the default filter and our custom filter + return defaultFilter && !isDraggingBrush; + }) + // @ts-expect-error We need to reset the transform when the user stops zooming + .on('end', () => (plot.domOverlay.__zoom = new ZoomTransform(1, 0, 0))) + .on('start', () => { + zoomStartScale.domain(xDomain.value).range([0, plot.domOverlay.clientWidth]); + }) + .on('zoom', zoomed); + + // Apply the zoom behavior to the overlay div + select(plot.domOverlay).call(zoomBehavior); + + // Every time the domain gets changed we want to update the zoom + effect(() => { + const newScale = baseScale.domain(plot.xDomain.value); + plot.zoomed(newScale, scaleLinear()); + }); +} diff --git a/tsconfig.json b/tsconfig.json index adff330f8..5f2170a94 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,6 +32,7 @@ "@gosling-lang/circular-brush": ["src/tracks/circular-brush/index.ts"], "@gosling-lang/dummy-track": ["./src/tracks/dummy-track/index.ts"], "@gosling-lang/text-track": ["./src/tracks/text-track/index.ts"], + "@gosling-lang/interactors": ["./src/interactors/index.ts"], "@pixi-manager": ["./src/pixi-manager/index.ts"], "@data-fetchers": ["./src/data-fetchers/index.ts"], "@higlass": ["./src/higlass"], diff --git a/vite.config.js b/vite.config.js index 3fb3d32b0..b399a120c 100644 --- a/vite.config.js +++ b/vite.config.js @@ -74,6 +74,7 @@ const alias = { '@gosling-lang/circular-brush': path.resolve(__dirname, './src/tracks/circular-brush/index.ts'), '@gosling-lang/dummy-track': path.resolve(__dirname, './src/tracks/dummy-track/index.ts'), '@gosling-lang/text-track': path.resolve(__dirname, './src/tracks/text-track/index.ts'), + '@gosling-lang/interactors': path.resolve(__dirname, './src/interactors/index.ts'), '@pixi-manager': path.resolve(__dirname, './src/pixi-manager/index.ts'), '@data-fetchers': path.resolve(__dirname, './src/data-fetchers/index.ts'), '@higlass': path.resolve(__dirname, './src/higlass'), From 0f7f84b0e764ec5e72d2e99d325fd938ffebbc74 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Thu, 13 Jun 2024 12:57:06 -0400 Subject: [PATCH 029/139] feat: export hg datafetcher --- src/higlass/datafetcher.ts | 1 + src/missing-types.d.ts | 1 + 2 files changed, 2 insertions(+) create mode 100644 src/higlass/datafetcher.ts diff --git a/src/higlass/datafetcher.ts b/src/higlass/datafetcher.ts new file mode 100644 index 000000000..53e841f61 --- /dev/null +++ b/src/higlass/datafetcher.ts @@ -0,0 +1 @@ +export { DataFetcher } from './higlass-vendored'; diff --git a/src/missing-types.d.ts b/src/missing-types.d.ts index 66397e786..8f0efa34a 100644 --- a/src/missing-types.d.ts +++ b/src/missing-types.d.ts @@ -729,5 +729,6 @@ declare module '@higlass/datafetcher' { tilesetInfo(finished: (info: TilesetInfo) => void): void; fetchTilesDebounced(receivedTiles: (tiles: Record) => void, tileIds: string[]): void; track?: any; + constructor(dataConfig, pubSub); } } From 63ac37abdd3d7eeaed65a0d914865c5d4d4587a2 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Thu, 13 Jun 2024 12:57:28 -0400 Subject: [PATCH 030/139] fix: linear brush model --- .../gosling-track/linear-brush-model.ts | 23 +++---------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/src/tracks/gosling-track/linear-brush-model.ts b/src/tracks/gosling-track/linear-brush-model.ts index 65a4f6de0..54e074701 100644 --- a/src/tracks/gosling-track/linear-brush-model.ts +++ b/src/tracks/gosling-track/linear-brush-model.ts @@ -2,6 +2,7 @@ import { createNanoEvents, type Emitter } from 'nanoevents'; import type * as D3Selection from 'd3-selection'; import type * as D3Drag from 'd3-drag'; import type { EventStyle } from '@gosling-lang/gosling-schema'; +import { drag as d3Drag } from 'd3-drag'; const HIDDEN_BRUSH_EDGE_SIZE = 3; @@ -49,19 +50,9 @@ export class LinearBrushModel { private offset: [number, number]; private size: number; // fixed size of one-dimension of a brush (e.g., height) - /* External libraries that we re-use from HiGlass */ - private externals: { - d3Selection: typeof D3Selection; - d3Drag: typeof D3Drag; - }; - private emitter: Emitter; - constructor( - selection: D3Selection.Selection, - hgLibraries: any, - style: EventStyle = {} - ) { + constructor(selection: D3Selection.Selection, style: EventStyle = {}) { this.emitter = createNanoEvents(); this.range = null; this.prevExtent = [0, 0]; @@ -70,11 +61,6 @@ export class LinearBrushModel { this.offset = [0, 0]; this.size = 0; - this.externals = { - d3Selection: hgLibraries.d3Selection, - d3Drag: hgLibraries.d3Drag - }; - this.style = Object.assign({}, BRUSH_STYLE_DEFAULT, style); this.brushSelection = selection @@ -223,10 +209,7 @@ export class LinearBrushModel { this.updateRange([s, e]).drawBrush(); }; - return this.externals.d3Drag - .drag() - .on('start', started) - .on('drag', dragged); + return d3Drag().on('start', started).on('drag', dragged); } on(event: E, callback: LinearBrushEvents[E]) { From 41c5bfd9c1a2e46e38d0b77cbabb22656a61d2f9 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Thu, 13 Jun 2024 12:57:50 -0400 Subject: [PATCH 031/139] feat: GoslingTrack plot --- .../gosling-track/gosling-track-plot.ts | 71 +++++++++++++++++++ src/tracks/gosling-track/index.ts | 3 +- src/tracks/utils.ts | 13 ++++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 src/tracks/gosling-track/gosling-track-plot.ts diff --git a/src/tracks/gosling-track/gosling-track-plot.ts b/src/tracks/gosling-track/gosling-track-plot.ts new file mode 100644 index 000000000..53e4e4992 --- /dev/null +++ b/src/tracks/gosling-track/gosling-track-plot.ts @@ -0,0 +1,71 @@ +import { GoslingTrackClass, type GoslingTrackOptions, type GoslingTrackContext } from './gosling-track'; +import * as PIXI from 'pixi.js'; +import { fakePubSub } from '@higlass/utils'; +import { scaleLinear } from 'd3-scale'; +import { type Signal } from '@preact/signals-core'; +import { DataFetcher } from '@higlass/datafetcher'; + +import { type Plot } from '../utils'; +import { signal } from '@preact/signals-core'; + +export class GoslingTrack extends GoslingTrackClass implements Plot { + xDomain: Signal<[number, number]>; // This has to be a signal because it will potentially be updated by interactors + zoomStartScale = scaleLinear(); + domOverlay: HTMLElement; + + constructor( + options: GoslingTrackOptions, + dataFetcher: DataFetcher, + containers: { + pixiContainer: PIXI.Container; + overlayDiv: HTMLElement; + }, + xDomain = signal<[number, number]>([0, 3088269832]) + ) { + const { pixiContainer, overlayDiv } = containers; + const height = overlayDiv.clientHeight; + const width = overlayDiv.clientWidth; + // The colorbar svg element isn't quite working yet + const colorbarDiv = document.createElement('svg'); + overlayDiv.appendChild(colorbarDiv); + + // Setup the context object + const context: GoslingTrackContext = { + scene: pixiContainer, + id: 'test', + dataFetcher, + dataConfig: { + server: 'https://resgen.io/api/v1', + tilesetUid: 'UvVPeLHuRDiYA3qwFlm7xQ' + // coordSystem: "hg19", + }, + animate: () => {}, + onValueScaleChanged: () => {}, + handleTilesetInfoReceived: (tilesetInfo: any) => {}, + onTrackOptionsChanged: () => {}, + pubSub: fakePubSub, + isValueScaleLocked: () => false, + svgElement: colorbarDiv, + isShowGlobalMousePosition: () => false + }; + + super(context, options); + + this.xDomain = xDomain; + this.domOverlay = overlayDiv; + // Now we need to initialize all of the properties that would normally be set by HiGlassComponent + this.setDimensions([width, height]); + this.setPosition([0, 0]); + // Create some scales which span the whole genome + const refXScale = scaleLinear().domain(xDomain.value).range([0, width]); + const refYScale = scaleLinear(); // This doesn't get used anywhere but we need to pass it in + // Set the scales + this.zoomed(refXScale, refYScale); + this.refScalesChanged(refXScale, refYScale); + } + + addInteractor(interactor: (plot: GoslingTrack) => void) { + interactor(this); + return this; // For chaining + } +} diff --git a/src/tracks/gosling-track/index.ts b/src/tracks/gosling-track/index.ts index c3c2de086..3da1b8006 100644 --- a/src/tracks/gosling-track/index.ts +++ b/src/tracks/gosling-track/index.ts @@ -1 +1,2 @@ -export { default as GoslingTrack, type Tile, type DisplayedLegend } from './gosling-track'; +export { GoslingTrackClass, type Tile, type DisplayedLegend } from './gosling-track'; +export { GoslingTrack } from './gosling-track-plot'; \ No newline at end of file diff --git a/src/tracks/utils.ts b/src/tracks/utils.ts index 746e45d59..bcd3bb61b 100644 --- a/src/tracks/utils.ts +++ b/src/tracks/utils.ts @@ -1,3 +1,6 @@ +import { type Signal } from '@preact/signals-core'; +import { type ScaleLinear } from 'd3-scale'; + // Default d3 zoom feels slow so we use this instead // https://d3js.org/d3-zoom#zoom_wheelDelta export function zoomWheelBehavior(event: WheelEvent) { @@ -8,3 +11,13 @@ export function zoomWheelBehavior(event: WheelEvent) { (event.ctrlKey ? 10 : defaultMultiplier) ); } + +/** + * This is the interface that plots must implement for Interactors to work + */ +export interface Plot { + addInteractor(interactor: (plot: Plot) => void): Plot; + domOverlay: HTMLElement; + xDomain: Signal<[number, number]>; + zoomed(xScale: ScaleLinear, yScale: ScaleLinear): void; +} From 32285f791289c018e4234255c799bccbd2c3d1ed Mon Sep 17 00:00:00 2001 From: etowahadams Date: Thu, 13 Jun 2024 12:58:02 -0400 Subject: [PATCH 032/139] feat: gosling track example --- demo/App.tsx | 3 +- demo/examples/gosling-track-example.ts | 242 +++++++++++++++++++++++++ demo/examples/index.ts | 1 + 3 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 demo/examples/gosling-track-example.ts diff --git a/demo/App.tsx b/demo/App.tsx index d75670f3e..6ef85945a 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { PixiManager } from '@pixi-manager'; -import { addDummyTrack, addTextTrack, addCircularBrush } from './examples'; +import { addDummyTrack, addTextTrack, addCircularBrush, addGoslingTrack } from './examples'; import './App.css'; @@ -16,6 +16,7 @@ function App() { addTextTrack(pixiManager); addDummyTrack(pixiManager); addCircularBrush(pixiManager); + addGoslingTrack(pixiManager); }, []); return ( diff --git a/demo/examples/gosling-track-example.ts b/demo/examples/gosling-track-example.ts new file mode 100644 index 000000000..59a1b365d --- /dev/null +++ b/demo/examples/gosling-track-example.ts @@ -0,0 +1,242 @@ +import { PixiManager } from '@pixi-manager'; +import { GoslingTrack } from '@gosling-lang/gosling-track'; +import { DataFetcher } from '@higlass/datafetcher'; +import { fakePubSub } from '@higlass/utils'; +import { signal } from '@preact/signals-core'; +import { panZoom } from '@gosling-lang/interactors'; + +export function addGoslingTrack(pixiManager: PixiManager) { + const circularDomain = signal<[number, number]>([0, 248956422]); + // All tracks use this datafetcher + const dataFetcher = new DataFetcher( + { + server: 'https://server.gosling-lang.org/api/v1', + tilesetUid: 'cistrome-multivec' + }, + fakePubSub + ); + + // Circular track + const pos0 = { x: 10, y: 200, width: 250, height: 250 }; + new GoslingTrack(circularTrackOptions, dataFetcher, pixiManager.makeContainer(pos0)).addInteractor(plot => + panZoom(plot, circularDomain) + ); +} +export const circularTrackOptions = { + id: '8a003683-9a57-4202-bf00-1c4d9b11f13d', + siblingIds: ['8a003683-9a57-4202-bf00-1c4d9b11f13d'], + showMousePosition: false, + mousePositionColor: '#000000', + name: ' ', + labelPosition: 'none', + labelShowResolution: false, + labelColor: 'black', + labelBackgroundColor: 'white', + labelBackgroundOpacity: 0.5, + labelTextOpacity: 1, + labelLeftMargin: 1, + labelTopMargin: 1, + labelRightMargin: 0, + labelBottomMargin: 0, + backgroundColor: 'transparent', + spec: { + spacing: 5, + static: true, + layout: 'circular', + xDomain: { chromosome: 'chr1' }, + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=cistrome-multivec', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1', 'sample 2', 'sample 3', 'sample 4'] + }, + x: { + field: 'start', + type: 'genomic', + domain: { chromosome: 'chr1' }, + axis: 'top' + }, + xe: { field: 'end', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative' }, + row: { field: 'sample', type: 'nominal' }, + color: { field: 'sample', type: 'nominal' }, + width: 250, + height: 250, + assembly: 'hg38', + orientation: 'horizontal', + zoomLimits: [1, null], + centerRadius: 0.4, + xOffset: 0, + yOffset: 0, + style: {}, + id: '8a003683-9a57-4202-bf00-1c4d9b11f13d', + _overlay: [ + { mark: 'bar', style: {} }, + { mark: 'brush', x: {}, style: {} } + ], + overlayOnPreviousTrack: false, + outerRadius: 125, + innerRadius: 50, + startAngle: 7.2, + endAngle: 352.8, + _renderingId: '085fb2cf-83dd-4d47-b7da-7fc96bbde6a1' + }, + theme: { + base: 'light', + root: { + background: 'white', + titleColor: 'black', + titleBackgroundColor: 'transparent', + titleFontSize: 18, + titleFontFamily: 'Arial', + titleAlign: 'left', + titleFontWeight: 'bold', + subtitleColor: 'gray', + subtitleBackgroundColor: 'transparent', + subtitleFontSize: 16, + subtitleFontFamily: 'Arial', + subtitleFontWeight: 'normal', + subtitleAlign: 'left', + showMousePosition: true, + mousePositionColor: '#000000' + }, + track: { + background: 'transparent', + alternatingBackground: 'transparent', + titleColor: 'black', + titleBackground: 'white', + titleFontSize: 24, + titleAlign: 'left', + outline: 'black', + outlineWidth: 1 + }, + legend: { + position: 'top', + background: 'white', + backgroundOpacity: 0.7, + labelColor: 'black', + labelFontSize: 12, + labelFontWeight: 'normal', + labelFontFamily: 'Arial', + backgroundStroke: '#DBDBDB', + tickColor: 'black' + }, + axis: { + tickColor: 'black', + labelColor: 'black', + labelMargin: 5, + labelExcludeChrPrefix: false, + labelFontSize: 12, + labelFontWeight: 'normal', + labelFontFamily: 'Arial', + baselineColor: 'black', + gridColor: '#E3E3E3', + gridStrokeWidth: 1, + gridStrokeType: 'solid', + gridStrokeDash: [4, 4] + }, + markCommon: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + point: { + color: '#E79F00', + size: 3, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + rect: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + triangle: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + area: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + line: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + bar: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + rule: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 1, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + link: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 1, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + text: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6], + textAnchor: 'middle', + textFontWeight: 'normal' + }, + brush: { + color: 'gray', + size: 1, + stroke: 'black', + strokeWidth: 1, + opacity: 0.3, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + } + } +}; diff --git a/demo/examples/index.ts b/demo/examples/index.ts index d4f642815..c1ed6feeb 100644 --- a/demo/examples/index.ts +++ b/demo/examples/index.ts @@ -1,3 +1,4 @@ export { addDummyTrack } from './dummy-track-example'; export { addTextTrack } from './text-track-example'; export { addCircularBrush } from './circular-brush-example'; +export { addGoslingTrack } from './gosling-track-example'; From e16d44536e348ef12b544dd654894d7aba8bf4fb Mon Sep 17 00:00:00 2001 From: etowahadams Date: Thu, 13 Jun 2024 13:24:08 -0400 Subject: [PATCH 033/139] feat: axis track --- src/higlass/utils.ts | 2 +- .../gosling-genomic-axis/axis-track-plot.ts | 105 ++ src/tracks/gosling-genomic-axis/axis-track.ts | 1161 ++++++++--------- src/tracks/gosling-genomic-axis/index.ts | 5 +- 4 files changed, 686 insertions(+), 587 deletions(-) create mode 100644 src/tracks/gosling-genomic-axis/axis-track-plot.ts diff --git a/src/higlass/utils.ts b/src/higlass/utils.ts index aa0e4520a..d919dc3f8 100644 --- a/src/higlass/utils.ts +++ b/src/higlass/utils.ts @@ -1 +1 @@ -export { fakePubSub } from './higlass-vendored'; +export { fakePubSub, absToChr, colorToHex, pixiTextToSvg, svgLine, showMousePosition } from './higlass-vendored'; diff --git a/src/tracks/gosling-genomic-axis/axis-track-plot.ts b/src/tracks/gosling-genomic-axis/axis-track-plot.ts new file mode 100644 index 000000000..70db6aaa3 --- /dev/null +++ b/src/tracks/gosling-genomic-axis/axis-track-plot.ts @@ -0,0 +1,105 @@ +import { AxisTrackClass, type AxisTrackContext, type AxisTrackOptions } from './axis-track'; +import * as PIXI from 'pixi.js'; +import { fakePubSub } from '@higlass/utils'; +import { scaleLinear } from 'd3-scale'; +import { ZoomTransform } from 'd3-zoom'; + +import { type D3ZoomEvent, zoom } from 'd3-zoom'; +import { select } from 'd3-selection'; +import { type Signal, effect } from '@preact/signals-core'; + +// Default d3 zoom feels slow so we use this instead +// https://d3js.org/d3-zoom#zoom_wheelDelta +function wheelDelta(event: WheelEvent) { + const defaultMultiplier = 5; + return ( + -event.deltaY * + (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002) * + (event.ctrlKey ? 10 : defaultMultiplier) + ); +} + +export class AxisTrack extends AxisTrackClass { + xDomain: Signal; + zoomStartScale = scaleLinear(); + #element: HTMLElement; + + constructor( + options: AxisTrackOptions, + xDomain: Signal, + containers: { + pixiContainer: PIXI.Container; + overlayDiv: HTMLElement; + } + ) { + const { pixiContainer, overlayDiv } = containers; + const height = overlayDiv.clientHeight; + const width = overlayDiv.clientWidth; + // Create a new svg element. The brush will be drawn on this element + const svgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svgElement.style.width = `${width}px`; + svgElement.style.height = `${height}px`; + // Add it to the overlay div + overlayDiv.appendChild(svgElement); + + // Setup the context object + const context: AxisTrackContext = { + chromInfoPath: 'https://s3.amazonaws.com/gosling-lang.org/data/hg38.chrom.sizes', + scene: pixiContainer, + id: 'test', + animate: () => {}, + onValueScaleChanged: () => {}, + handleTilesetInfoReceived: (tilesetInfo: any) => {}, + onTrackOptionsChanged: () => {}, + pubSub: fakePubSub, + isValueScaleLocked: () => false, + svgElement: svgElement + }; + + super(context, options); + + this.xDomain = xDomain; + this.#element = overlayDiv; + // Now we need to initialize all of the properties that would normally be set by HiGlassComponent + this.setDimensions([width, height]); + this.setPosition([0, 0]); + // Create some scales which span the whole genome + const refXScale = scaleLinear().domain(xDomain.value).range([0, width]); + const refYScale = scaleLinear(); // This doesn't get used anywhere but we need to pass it in + // Set the scales + this.zoomed(refXScale, refYScale); + this.refScalesChanged(refXScale, refYScale); + + // Add the zoom + this.#addZoom(); + } + + #addZoom(): void { + const baseScale = scaleLinear().domain(this.xDomain.value).range([0, this.#element.clientWidth]); + + // This function will be called every time the user zooms + const zoomed = (event: D3ZoomEvent) => { + const newXDomain = event.transform.rescaleX(this.zoomStartScale).domain(); + this.xDomain.value = newXDomain; + }; + + // Create the zoom behavior + const zoomBehavior = zoom() + .wheelDelta(wheelDelta) + // @ts-expect-error We need to reset the transform when the user stops zooming + .on('end', () => (this.#element.__zoom = new ZoomTransform(1, 0, 0))) + .on('start', () => { + this.zoomStartScale.domain(this.xDomain.value).range([0, this.#element.clientWidth]); + }) + .on('zoom', zoomed.bind(this)); + + // Apply the zoom behavior to the overlay div + select(this.#element).call(zoomBehavior); + + // Every time the domain gets changed we want to update the zoom + effect(() => { + const newScale = baseScale.domain(this.xDomain.value); + this.zoomed(newScale, this._refYScale); + }); + } +} diff --git a/src/tracks/gosling-genomic-axis/axis-track.ts b/src/tracks/gosling-genomic-axis/axis-track.ts index a993620c0..1de2c47de 100644 --- a/src/tracks/gosling-genomic-axis/axis-track.ts +++ b/src/tracks/gosling-genomic-axis/axis-track.ts @@ -1,25 +1,24 @@ // This plugin track is based on higlass/HorizontalChromosomeLabels // https://github.com/higlass/higlass/blob/83dc4fddb33582ef3c26b608c04a81e8f33c7f5f/app/scripts/HorizontalChromosomeLabels.js -import type * as PIXI from 'pixi.js'; +import * as PIXI from 'pixi.js'; import RBush from 'rbush'; import { scaleLinear } from 'd3-scale'; import { format, precisionPrefix, formatPrefix } from 'd3-format'; import { computeChromSizes } from '../../core/utils/assembly'; import { cartesianToPolar } from '../../core/utils/polar'; import { getTextStyle } from '../../core/utils/text-style'; -import { createPluginTrack } from '../../core/utils/define-plugin-track'; - import type { TextStyle } from '../../core/utils/text-style'; -import type { PluginTrackFactory, TrackConfig } from '../../core/utils/define-plugin-track'; import type { Assembly } from '@gosling-lang/gosling-schema'; +import { PixiTrack } from '@higlass/tracks'; +import { absToChr, colorToHex, pixiTextToSvg, svgLine, showMousePosition } from '@higlass/utils'; const TICK_WIDTH = 200; const TICK_HEIGHT = 6; const TICK_TEXT_SEPARATION = 2; const TICK_COLOR = 0x777777; -type AxisTrackOptions = { +export type AxisTrackOptions = { innerRadius: number; outerRadius: number; startAngle: number; @@ -61,722 +60,718 @@ type ChromInfo = { cumPositions: ChrPosInfo[]; }; -// TODO: Change the icon -const icon = - ' '; - -const config: TrackConfig = { - type: 'axis-track', - datatype: ['multivec', 'epilogos'], - local: false, - orientation: '1d-horizontal', - thumbnail: new DOMParser().parseFromString(icon, 'text/xml').documentElement, - defaultOptions: { - innerRadius: 340, - outerRadius: 310, - startAngle: 0, - endAngle: 360, - width: 700, - height: 700, - layout: 'linear', - labelMargin: 5, - excludeChrPrefix: false, - labelPosition: 'none', - labelColor: 'black', - labelTextOpacity: 0.4, - trackBorderWidth: 0, - trackBorderColor: 'black', - tickPositions: 'even', - fontSize: 12, - fontFamily: 'sans-serif', // 'Arial', - fontWeight: 'normal', - color: '#808080', - stroke: '#ffffff', - backgroundColor: 'transparent', - showMousePosition: false, - tickColor: TICK_COLOR - } +export interface AxisTrackContext { + dataConfig: Record; + animate: () => void; + chromInfoPath?: string; + isShowGlobalMousePosition: () => boolean; +} + +const defaultOptions = { + innerRadius: 340, + outerRadius: 310, + startAngle: 0, + endAngle: 360, + width: 700, + height: 700, + layout: 'linear', + labelMargin: 5, + excludeChrPrefix: false, + labelPosition: 'none', + labelColor: 'black', + labelTextOpacity: 0.4, + trackBorderWidth: 0, + trackBorderColor: 'black', + tickPositions: 'even', + fontSize: 12, + fontFamily: 'sans-serif', // 'Arial', + fontWeight: 'normal', + color: '#808080', + stroke: '#ffffff', + backgroundColor: 'transparent', + showMousePosition: false, + tickColor: TICK_COLOR }; -const factory: PluginTrackFactory = (HGC, context, options) => { - const { absToChr, colorToHex, pixiTextToSvg, svgLine, showMousePosition } = HGC.utils; - - function createTickText(text: string, style: Partial): TickText { - return Object.assign(new HGC.libraries.PIXI.Text(text, style), { hashValue: Math.random() }); - } - - class AxisTrackClass extends HGC.tracks.PixiTrack { - allTexts: TickLabelInfo[]; - searchField: null; - chromInfo: ChromInfo; - dataConfig: Record; - pTicksCircular: PIXI.Graphics; - pTicks: PIXI.Graphics | null; - gTicks: Record; - tickTexts: Record; - - isShowGlobalMousePosition: () => boolean; - - pixiTextConfig: ReturnType; - stroke: number; - - tickWidth: number; - tickHeight: number; - tickTextSeparation: number; - tickColor: number; +function createTickText(text: string, style: Partial): TickText { + return Object.assign(new PIXI.Text(text, style), { + hashValue: Math.random() + }); +} + +export class AxisTrackClass extends PixiTrack { + allTexts: TickLabelInfo[]; + searchField: null; + chromInfo: ChromInfo; + dataConfig: Record; + pTicksCircular: PIXI.Graphics; + pTicks: PIXI.Graphics | null; + gTicks: Record; + tickTexts: Record; + + isShowGlobalMousePosition: () => boolean; + + pixiTextConfig: ReturnType; + stroke: number; + + tickWidth: number; + tickHeight: number; + tickTextSeparation: number; + tickColor: number; - animate: () => void; + animate: () => void; - hideMousePosition?: ReturnType; + hideMousePosition?: ReturnType; - gBoundTicks?: PIXI.Graphics; - leftBoundTick?: TickText; - rightBoundTick?: TickText; + gBoundTicks?: PIXI.Graphics; + leftBoundTick?: TickText; + rightBoundTick?: TickText; - is2d?: boolean; - texts?: TickText[]; + is2d?: boolean; + texts?: TickText[]; - constructor() { - super(context, options); - const { dataConfig, animate, chromInfoPath, isShowGlobalMousePosition } = context; + constructor(context: AxisTrackContext, options: AxisTrackOptions) { + super(context, options); + const { dataConfig, animate, chromInfoPath, isShowGlobalMousePosition } = context; - this.searchField = null; - this.dataConfig = dataConfig; + this.searchField = null; + this.dataConfig = dataConfig; - this.allTexts = []; + this.allTexts = []; - this.pTicksCircular = new HGC.libraries.PIXI.Graphics(); - this.pTicks = new HGC.libraries.PIXI.Graphics(); - this.pMain.addChild(this.pTicks); - this.pMain.addChild(this.pTicksCircular); + this.pTicksCircular = new PIXI.Graphics(); + this.pTicks = new PIXI.Graphics(); + this.pMain.addChild(this.pTicks); + this.pMain.addChild(this.pTicksCircular); - this.gTicks = {}; - this.tickTexts = {}; + this.gTicks = {}; + this.tickTexts = {}; - this.options = options; - this.isShowGlobalMousePosition = isShowGlobalMousePosition; + this.options = { ...defaultOptions, ...options }; + this.isShowGlobalMousePosition = isShowGlobalMousePosition; - this.pixiTextConfig = getTextStyle({ - size: +this.options.fontSize, - fontFamily: this.options.fontFamily, - fontWeight: this.options.fontWeight, - color: this.options.color, - stroke: this.options.stroke, - strokeThickness: 2 - }); - this.stroke = colorToHex(this.pixiTextConfig.stroke); + this.pixiTextConfig = getTextStyle({ + size: +this.options.fontSize, + fontFamily: this.options.fontFamily, + fontWeight: this.options.fontWeight, + color: this.options.color, + stroke: this.options.stroke, + strokeThickness: 2 + }); + this.stroke = colorToHex(this.pixiTextConfig.stroke); - // text objects to use if the tick style is "bounds", meaning - // we only draw two ticks on the left and the right of the screen + // text objects to use if the tick style is "bounds", meaning + // we only draw two ticks on the left and the right of the screen - this.tickWidth = TICK_WIDTH; - this.tickHeight = TICK_HEIGHT; - this.tickTextSeparation = TICK_TEXT_SEPARATION; - this.tickColor = colorToHex(this.options.tickColor); + this.tickWidth = TICK_WIDTH; + this.tickHeight = TICK_HEIGHT; + this.tickTextSeparation = TICK_TEXT_SEPARATION; + this.tickColor = colorToHex(this.options.tickColor); - this.animate = animate; + this.animate = animate; - this.pubSubs = []; + this.pubSubs = []; - if (this.options.showMousePosition && !this.hideMousePosition) { - this.hideMousePosition = showMousePosition(this, this.is2d, this.isShowGlobalMousePosition()); - } + if (this.options.showMousePosition && !this.hideMousePosition) { + this.hideMousePosition = showMousePosition(this, this.is2d, this.isShowGlobalMousePosition()); + } - let chromSizesPath = chromInfoPath; + let chromSizesPath = chromInfoPath; - if (!chromSizesPath) { - chromSizesPath = `${dataConfig.server}/chrom-sizes/?id=${dataConfig.tilesetUid}`; - } + if (!chromSizesPath) { + chromSizesPath = `${dataConfig.server}/chrom-sizes/?id=${dataConfig.tilesetUid}`; + } - // Example: - // chrPositions: { - // chr1: { chr: "chr1", pos: 0 }, - // chr2: { chr: "chr2", pos: 1000 }, - // }, - // chromLengths: { - // chr1: 1000, - // chr2: 1000 - // }, - // cumPositions: [ - // { chr: "chr1", pos: 0 }, - // { chr: "chr2", pos: 1000 }, - // ] - - const assembly = this.options.assembly; - const chrPositions: { [k: string]: ChrPosInfo } = {}; - const chromLengths: { [k: string]: number } = { ...computeChromSizes(assembly).size }; - const cumPositions: ChrPosInfo[] = []; - - Object.keys(computeChromSizes(assembly).size).forEach(k => { - chrPositions[k] = { chr: k, pos: computeChromSizes(assembly).size[k] }; + // Example: + // chrPositions: { + // chr1: { chr: "chr1", pos: 0 }, + // chr2: { chr: "chr2", pos: 1000 }, + // }, + // chromLengths: { + // chr1: 1000, + // chr2: 1000 + // }, + // cumPositions: [ + // { chr: "chr1", pos: 0 }, + // { chr: "chr2", pos: 1000 }, + // ] + + const assembly = this.options.assembly; + const chrPositions: { [k: string]: ChrPosInfo } = {}; + const chromLengths: { [k: string]: number } = { + ...computeChromSizes(assembly).size + }; + const cumPositions: ChrPosInfo[] = []; + + Object.keys(computeChromSizes(assembly).size).forEach(k => { + chrPositions[k] = { chr: k, pos: computeChromSizes(assembly).size[k] }; + }); + + Object.keys(computeChromSizes(assembly).interval).forEach(k => { + cumPositions.push({ + chr: k, + pos: computeChromSizes(assembly).interval[k][0] }); + }); - Object.keys(computeChromSizes(assembly).interval).forEach(k => { - cumPositions.push({ chr: k, pos: computeChromSizes(assembly).interval[k][0] }); - }); + this.chromInfo = { chrPositions, chromLengths, cumPositions }; - this.chromInfo = { chrPositions, chromLengths, cumPositions }; + this.rerender(this.options, true); + this.draw(); + this.animate(); + } - this.rerender(this.options, true); - this.draw(); - this.animate(); + initBoundsTicks() { + if (this.pTicks) { + this.pMain.removeChild(this.pTicks); + this.pTicks = null; } - initBoundsTicks() { - if (this.pTicks) { - this.pMain.removeChild(this.pTicks); - this.pTicks = null; - } + if (!this.gBoundTicks) { + this.gBoundTicks = new PIXI.Graphics(); - if (!this.gBoundTicks) { - this.gBoundTicks = new HGC.libraries.PIXI.Graphics(); + this.leftBoundTick = createTickText('', this.pixiTextConfig); + this.rightBoundTick = createTickText('', this.pixiTextConfig); - this.leftBoundTick = createTickText('', this.pixiTextConfig); - this.rightBoundTick = createTickText('', this.pixiTextConfig); - - this.gBoundTicks.addChild(this.leftBoundTick); - this.gBoundTicks.addChild(this.rightBoundTick); - - this.pMain.addChild(this.gBoundTicks); - } + this.gBoundTicks.addChild(this.leftBoundTick); + this.gBoundTicks.addChild(this.rightBoundTick); - this.texts = []; + this.pMain.addChild(this.gBoundTicks); } - initChromLabels() { - if (!this.chromInfo) return; + this.texts = []; + } - if (this.gBoundTicks) { - this.pMain.removeChild(this.gBoundTicks); - this.gBoundTicks = undefined; - } + initChromLabels() { + if (!this.chromInfo) return; - if (!this.pTicks) { - this.pTicks = new HGC.libraries.PIXI.Graphics(); - this.pMain.addChild(this.pTicks); - } + if (this.gBoundTicks) { + this.pMain.removeChild(this.gBoundTicks); + this.gBoundTicks = undefined; + } - this.texts = []; - this.pTicks.removeChildren(); + if (!this.pTicks) { + this.pTicks = new PIXI.Graphics(); + this.pMain.addChild(this.pTicks); + } - this.chromInfo.cumPositions.forEach((info: any) => { - const chromName = info.chr; - this.gTicks[chromName] = new HGC.libraries.PIXI.Graphics(); + this.texts = []; + this.pTicks.removeChildren(); - // create the array that will store tick TEXT objects - if (!this.tickTexts[chromName]) this.tickTexts[chromName] = []; + this.chromInfo.cumPositions.forEach((info: any) => { + const chromName = info.chr; + this.gTicks[chromName] = new PIXI.Graphics(); - // Give each PIXI text object a random hash so that some get hidden when there's overlaps - const chromNameText = this.options.excludeChrPrefix ? chromName.replace('chr', '') : chromName; - const text = createTickText(chromNameText, this.pixiTextConfig); + // create the array that will store tick TEXT objects + if (!this.tickTexts[chromName]) this.tickTexts[chromName] = []; - this.pTicks?.addChild(text); - this.pTicks?.addChild(this.gTicks[chromName]); + // Give each PIXI text object a random hash so that some get hidden when there's overlaps + const chromNameText = this.options.excludeChrPrefix ? chromName.replace('chr', '') : chromName; + const text = createTickText(chromNameText, this.pixiTextConfig); - this.texts?.push(text); - }); - } + this.pTicks?.addChild(text); + this.pTicks?.addChild(this.gTicks[chromName]); - rerender(options: any, force: boolean) { - const strOptions = JSON.stringify(options); + this.texts?.push(text); + }); + } - if (!force && strOptions === this.prevOptions) return; + rerender(options: any, force: boolean) { + const strOptions = JSON.stringify(options); - this.prevOptions = strOptions; - this.options = options; + if (!force && strOptions === this.prevOptions) return; - this.pixiTextConfig.fontSize = +this.options.fontSize - ? (`${+this.options.fontSize}px` as const) - : this.pixiTextConfig.fontSize; - this.pixiTextConfig.fill = this.options.color || this.pixiTextConfig.fill; - this.pixiTextConfig.stroke = this.options.stroke || this.pixiTextConfig.stroke; - this.stroke = colorToHex(this.pixiTextConfig.stroke as string); + this.prevOptions = strOptions; + this.options = options; - this.tickColor = this.options.tickColor ? colorToHex(this.options.tickColor) : TICK_COLOR; + this.pixiTextConfig.fontSize = +this.options.fontSize + ? (`${+this.options.fontSize}px` as const) + : this.pixiTextConfig.fontSize; + this.pixiTextConfig.fill = this.options.color || this.pixiTextConfig.fill; + this.pixiTextConfig.stroke = this.options.stroke || this.pixiTextConfig.stroke; + this.stroke = colorToHex(this.pixiTextConfig.stroke as string); - if (this.options.tickPositions === 'ends' && this.options.layout !== 'circular') { - this.initBoundsTicks(); - } else { - this.initChromLabels(); - } + this.tickColor = this.options.tickColor ? colorToHex(this.options.tickColor) : TICK_COLOR; - super.rerender(options, force); + if (this.options.tickPositions === 'ends' && this.options.layout !== 'circular') { + this.initBoundsTicks(); + } else { + this.initChromLabels(); + } - if (this.options.showMousePosition && !this.hideMousePosition) { - this.hideMousePosition = showMousePosition(this, this.is2d, this.isShowGlobalMousePosition()); - } + super.rerender(options, force); - if (!this.options.showMousePosition && this.hideMousePosition) { - this.hideMousePosition(); - this.hideMousePosition = undefined; - } + if (this.options.showMousePosition && !this.hideMousePosition) { + this.hideMousePosition = showMousePosition(this, this.is2d, this.isShowGlobalMousePosition()); } - formatTick(pos: number) { - if (isNaN(pos)) { - // the value is not proper, so early return - return 'null'; - } + if (!this.options.showMousePosition && this.hideMousePosition) { + this.hideMousePosition(); + this.hideMousePosition = undefined; + } + } - const domain = this._xScale.domain(); + formatTick(pos: number) { + if (isNaN(pos)) { + // the value is not proper, so early return + return 'null'; + } - const viewWidth = domain[1] - domain[0]; + const domain = this._xScale.domain(); - const p = precisionPrefix(pos, viewWidth); + const viewWidth = domain[1] - domain[0]; - const fPlain = format(','); - const fPrecision = formatPrefix(`,.${p}`, viewWidth); - let f = fPlain; + const p = precisionPrefix(pos, viewWidth); - if (this.options.tickFormat === 'si') { - f = fPrecision; - } else if (this.options.tickFormat === 'plain') { - f = fPlain; - } else if (this.options.tickPositions === 'ends') { - // if no format is specified but tickPositions are at 'ends' - // then use precision format - f = fPrecision; - } + const fPlain = format(','); + const fPrecision = formatPrefix(`,.${p}`, viewWidth); + let f = fPlain; - return f(pos); + if (this.options.tickFormat === 'si') { + f = fPrecision; + } else if (this.options.tickFormat === 'plain') { + f = fPlain; + } else if (this.options.tickPositions === 'ends') { + // if no format is specified but tickPositions are at 'ends' + // then use precision format + f = fPrecision; } - /** Show two labels at the end of both left and right sides */ - drawBoundsTicks(x1: ReturnType, x2: ReturnType) { - if (!this.gBoundTicks || !this.leftBoundTick || !this.rightBoundTick) return; - const graphics = this.gBoundTicks; - graphics.clear(); - graphics.lineStyle(1, 0); - - // determine the stard and end positions of tick lines along the vertical axis - const lineYStart = this.options.reverseOrientation ? 0 : this.dimensions[1]; - const lineYEnd = this.options.reverseOrientation ? this.tickHeight : this.dimensions[1] - this.tickHeight; - - // left tick - // line is offset by one because it's right on the edge of the - // visible region and we want to get the full width - graphics.moveTo(1, lineYStart); - graphics.lineTo(1, lineYEnd); - - // right tick - graphics.moveTo(this.dimensions[0] - 1, lineYStart); - graphics.lineTo(this.dimensions[0] - 1, lineYEnd); - - // we want to control the precision of the tick labels - // so that we don't end up with labels like 15.123131M - this.leftBoundTick.x = 0; - this.leftBoundTick.y = this.options.reverseOrientation - ? lineYEnd + this.tickTextSeparation - : lineYEnd - this.tickTextSeparation; - this.leftBoundTick.text = - this.options.assembly === 'unknown' - ? `${this.formatTick(x1[1])}` - : `${x1[0]}: ${this.formatTick(x1[1])}`; - this.leftBoundTick.anchor.y = this.options.reverseOrientation ? 0 : 1; - - this.rightBoundTick.x = this.dimensions[0]; - this.rightBoundTick.text = - this.options.assembly === 'unknown' - ? `${this.formatTick(x2[1])}` - : `${x2[0]}: ${this.formatTick(x2[1])}`; - this.rightBoundTick.y = this.options.reverseOrientation - ? lineYEnd + this.tickTextSeparation - : lineYEnd - this.tickTextSeparation; - this.rightBoundTick.anchor.y = this.options.reverseOrientation ? 0 : 1; - - this.rightBoundTick.anchor.x = 1; - - if (this.flipText) { - // this means this track is displayed vertically, so update the anchor and scale of labels to make them readable! - this.leftBoundTick.scale.x = -1; - this.leftBoundTick.anchor.x = 1; - this.rightBoundTick.scale.x = -1; - this.rightBoundTick.anchor.x = 0; - } + return f(pos); + } - // line is offset by one because it's right on the edge of the visible region and we want to get the full width - this.leftBoundTick.tickLine = [1, this.dimensions[1], 1, this.dimensions[1] - this.tickHeight]; - this.rightBoundTick.tickLine = [ - this.dimensions[0] - 1, - this.dimensions[1], - this.dimensions[0] - 1, - this.dimensions[1] - this.tickHeight - ]; - - this.tickTexts = {}; - this.tickTexts.all = [this.leftBoundTick, this.rightBoundTick]; - // this.rightBoundTick + /** Show two labels at the end of both left and right sides */ + drawBoundsTicks(x1: ReturnType, x2: ReturnType) { + if (!this.gBoundTicks || !this.leftBoundTick || !this.rightBoundTick) return; + const graphics = this.gBoundTicks; + graphics.clear(); + graphics.lineStyle(1, 0); + + // determine the stard and end positions of tick lines along the vertical axis + const lineYStart = this.options.reverseOrientation ? 0 : this.dimensions[1]; + const lineYEnd = this.options.reverseOrientation ? this.tickHeight : this.dimensions[1] - this.tickHeight; + + // left tick + // line is offset by one because it's right on the edge of the + // visible region and we want to get the full width + graphics.moveTo(1, lineYStart); + graphics.lineTo(1, lineYEnd); + + // right tick + graphics.moveTo(this.dimensions[0] - 1, lineYStart); + graphics.lineTo(this.dimensions[0] - 1, lineYEnd); + + // we want to control the precision of the tick labels + // so that we don't end up with labels like 15.123131M + this.leftBoundTick.x = 0; + this.leftBoundTick.y = this.options.reverseOrientation + ? lineYEnd + this.tickTextSeparation + : lineYEnd - this.tickTextSeparation; + this.leftBoundTick.text = + this.options.assembly === 'unknown' ? `${this.formatTick(x1[1])}` : `${x1[0]}: ${this.formatTick(x1[1])}`; + this.leftBoundTick.anchor.y = this.options.reverseOrientation ? 0 : 1; + + this.rightBoundTick.x = this.dimensions[0]; + this.rightBoundTick.text = + this.options.assembly === 'unknown' ? `${this.formatTick(x2[1])}` : `${x2[0]}: ${this.formatTick(x2[1])}`; + this.rightBoundTick.y = this.options.reverseOrientation + ? lineYEnd + this.tickTextSeparation + : lineYEnd - this.tickTextSeparation; + this.rightBoundTick.anchor.y = this.options.reverseOrientation ? 0 : 1; + + this.rightBoundTick.anchor.x = 1; + + if (this.flipText) { + // this means this track is displayed vertically, so update the anchor and scale of labels to make them readable! + this.leftBoundTick.scale.x = -1; + this.leftBoundTick.anchor.x = 1; + this.rightBoundTick.scale.x = -1; + this.rightBoundTick.anchor.x = 0; } - drawTicks(cumPos: { chr: string; pos: number }) { - const graphics = this.gTicks[cumPos.chr]; + // line is offset by one because it's right on the edge of the visible region and we want to get the full width + this.leftBoundTick.tickLine = [1, this.dimensions[1], 1, this.dimensions[1] - this.tickHeight]; + this.rightBoundTick.tickLine = [ + this.dimensions[0] - 1, + this.dimensions[1], + this.dimensions[0] - 1, + this.dimensions[1] - this.tickHeight + ]; + + this.tickTexts = {}; + this.tickTexts.all = [this.leftBoundTick, this.rightBoundTick]; + // this.rightBoundTick + } - graphics.visible = true; + drawTicks(cumPos: { chr: string; pos: number }) { + const graphics = this.gTicks[cumPos.chr]; - // clear graphics *and* ticktexts otherwise the two are out of sync! - graphics.clear(); + graphics.visible = true; - const chromLen = +this.chromInfo.chromLengths[cumPos.chr]; + // clear graphics *and* ticktexts otherwise the two are out of sync! + graphics.clear(); - const vpLeft = Math.max(this._xScale(cumPos.pos), 0); - const vpRight = Math.min(this._xScale(cumPos.pos + chromLen), this.dimensions[0]); + const chromLen = +this.chromInfo.chromLengths[cumPos.chr]; - const numTicks = (vpRight - vpLeft) / this.tickWidth; + const vpLeft = Math.max(this._xScale(cumPos.pos), 0); + const vpRight = Math.min(this._xScale(cumPos.pos + chromLen), this.dimensions[0]); - // what is the domain of this chromosome that is visible? - const xScale = scaleLinear() - .domain([ - Math.max(1, this._xScale.invert(0) - cumPos.pos), - Math.min(chromLen, this._xScale.invert(this.dimensions[0]) - cumPos.pos) - ]) - .range([vpLeft, vpRight]); + const numTicks = (vpRight - vpLeft) / this.tickWidth; - // calculate a certain number of ticks - const ticks = xScale.ticks(numTicks).filter(tick => Number.isInteger(tick)); + // what is the domain of this chromosome that is visible? + const xScale = scaleLinear() + .domain([ + Math.max(1, this._xScale.invert(0) - cumPos.pos), + Math.min(chromLen, this._xScale.invert(this.dimensions[0]) - cumPos.pos) + ]) + .range([vpLeft, vpRight]); - // not sure why we're separating these out by chromosome, but ok - const tickTexts = this.tickTexts[cumPos.chr]; + // calculate a certain number of ticks + const ticks = xScale.ticks(numTicks).filter(tick => Number.isInteger(tick)); - const tickHeight = this.tickHeight; + // not sure why we're separating these out by chromosome, but ok + const tickTexts = this.tickTexts[cumPos.chr]; - const xPadding = 0; + const tickHeight = this.tickHeight; - let yPadding = tickHeight + this.tickTextSeparation; + const xPadding = 0; - if (this.options.reverseOrientation) { - yPadding = this.dimensions[1] - yPadding; - } + let yPadding = tickHeight + this.tickTextSeparation; - // these two loops reuse existing text objects so that we're not constantly recreating texts that already exist - while (tickTexts.length < ticks.length) { - const newText = createTickText('', this.pixiTextConfig); - tickTexts.push(newText); - this.gTicks[cumPos.chr].addChild(newText); - } + if (this.options.reverseOrientation) { + yPadding = this.dimensions[1] - yPadding; + } - while (tickTexts.length > ticks.length) { - const text = tickTexts.pop(); - this.gTicks[cumPos.chr].removeChild(text!); - } + // these two loops reuse existing text objects so that we're not constantly recreating texts that already exist + while (tickTexts.length < ticks.length) { + const newText = createTickText('', this.pixiTextConfig); + tickTexts.push(newText); + this.gTicks[cumPos.chr].addChild(newText); + } - let i = 0; - while (i < ticks.length) { - tickTexts[i].visible = true; + while (tickTexts.length > ticks.length) { + const text = tickTexts.pop(); + this.gTicks[cumPos.chr].removeChild(text!); + } - tickTexts[i].anchor.x = 0.5; - tickTexts[i].anchor.y = - this.options.layout === 'circular' ? 0 : this.options.reverseOrientation ? 0 : 1; + let i = 0; + while (i < ticks.length) { + tickTexts[i].visible = true; - if (this.flipText) tickTexts[i].scale.x = -1; + tickTexts[i].anchor.x = 0.5; + tickTexts[i].anchor.y = this.options.layout === 'circular' ? 0 : this.options.reverseOrientation ? 0 : 1; - const chrText = this.options.assembly === 'unknown' ? '' : `${cumPos.chr}: `; - tickTexts[i].text = ticks[i] === 0 ? `${chrText}1` : `${chrText}${this.formatTick(ticks[i])}`; + if (this.flipText) tickTexts[i].scale.x = -1; - const x = this._xScale(cumPos.pos + ticks[i]); + const chrText = this.options.assembly === 'unknown' ? '' : `${cumPos.chr}: `; + tickTexts[i].text = ticks[i] === 0 ? `${chrText}1` : `${chrText}${this.formatTick(ticks[i])}`; - // show the tick text labels - if (this.options.layout === 'circular') { - const rope = this.addCurvedText(tickTexts[i], x + xPadding); - rope && this.pTicksCircular.addChild(rope); - } else { - tickTexts[i].x = x + xPadding; - tickTexts[i].y = this.dimensions[1] - yPadding; - - // store the position of the tick line so that it can be used in the export function - // TODO: - tickTexts[i].tickLine = [x - 1, this.dimensions[1], x - 1, this.dimensions[1] - tickHeight - 1]; - - // draw outline - const lineYStart = this.options.reverseOrientation ? 0 : this.dimensions[1]; - const lineYEnd = this.options.reverseOrientation ? tickHeight : this.dimensions[1] - tickHeight; - // graphics.lineStyle(1, this.stroke); - // graphics.moveTo(x - 1, lineYStart); - // graphics.lineTo(x - 1, lineYEnd - 1); - // graphics.lineTo(x + 1, lineYEnd - 1); - // graphics.lineTo(x + 1, lineYStart); - - // draw the vertical tick lines - graphics.lineStyle(1, this.tickColor); - graphics.moveTo(x, lineYStart); - graphics.lineTo(x, lineYEnd); - } + const x = this._xScale(cumPos.pos + ticks[i]); - i += 1; + // show the tick text labels + if (this.options.layout === 'circular') { + const rope = this.addCurvedText(tickTexts[i], x + xPadding); + rope && this.pTicksCircular.addChild(rope); + } else { + tickTexts[i].x = x + xPadding; + tickTexts[i].y = this.dimensions[1] - yPadding; + + // store the position of the tick line so that it can be used in the export function + // TODO: + tickTexts[i].tickLine = [x - 1, this.dimensions[1], x - 1, this.dimensions[1] - tickHeight - 1]; + + // draw outline + const lineYStart = this.options.reverseOrientation ? 0 : this.dimensions[1]; + const lineYEnd = this.options.reverseOrientation ? tickHeight : this.dimensions[1] - tickHeight; + // graphics.lineStyle(1, this.stroke); + // graphics.moveTo(x - 1, lineYStart); + // graphics.lineTo(x - 1, lineYEnd - 1); + // graphics.lineTo(x + 1, lineYEnd - 1); + // graphics.lineTo(x + 1, lineYStart); + + // draw the vertical tick lines + graphics.lineStyle(1, this.tickColor); + graphics.moveTo(x, lineYStart); + graphics.lineTo(x, lineYEnd); } - if (this.options.layout === 'circular') i = 0; - while (i < tickTexts.length) { - // we don't need this text so we'll turn it off for now - tickTexts[i].visible = false; + i += 1; + } - i += 1; - } + if (this.options.layout === 'circular') i = 0; + while (i < tickTexts.length) { + // we don't need this text so we'll turn it off for now + tickTexts[i].visible = false; - return ticks.length; + i += 1; } - addCurvedText(textObj: PIXI.Text, cx: number) { - const [width, height] = this.dimensions; - const { startAngle, endAngle } = this.options; - const factor = Math.min(width, height) / Math.min(this.options.width, this.options.height); - const innerRadius = this.options.innerRadius * factor; - const outerRadius = this.options.outerRadius * factor; - - const r = (outerRadius + innerRadius) / 2.0; - const centerPos = cartesianToPolar(cx, width, r, width / 2.0, height / 2.0, startAngle, endAngle); - textObj.x = centerPos.x; - textObj.y = centerPos.y; - - textObj.resolution = 4; - const txtStyle = new HGC.libraries.PIXI.TextStyle(this.pixiTextConfig); - const metric = HGC.libraries.PIXI.TextMetrics.measureText(textObj.text, txtStyle); - - // scale the width of text label so that its width is the same when converted into circular form - const tw = ((metric.width / (2 * r * Math.PI)) * width * 360) / (endAngle - startAngle); - let [minX, maxX] = [cx - tw / 2.0, cx + tw / 2.0]; - - // make sure not to place the label on the origin - if (minX < 0) { - const gap = -minX; - minX = 0; - maxX += gap; - } else if (maxX > width) { - const gap = maxX - width; - maxX = width; - minX -= gap; - } + return ticks.length; + } - const ropePoints: PIXI.Point[] = []; - const baseR = innerRadius + metric.height / 2.0 + 3; - for (let i = maxX; i >= minX; i -= tw / 10.0) { - const p = cartesianToPolar(i, width, baseR, width / 2.0, height / 2.0, startAngle, endAngle); - ropePoints.push(new HGC.libraries.PIXI.Point(p.x, p.y)); - } + addCurvedText(textObj: PIXI.Text, cx: number) { + const [width, height] = this.dimensions; + const { startAngle, endAngle } = this.options; + const factor = Math.min(width, height) / Math.min(this.options.width, this.options.height); + const innerRadius = this.options.innerRadius * factor; + const outerRadius = this.options.outerRadius * factor; + + const r = (outerRadius + innerRadius) / 2.0; + const centerPos = cartesianToPolar(cx, width, r, width / 2.0, height / 2.0, startAngle, endAngle); + textObj.x = centerPos.x; + textObj.y = centerPos.y; + + textObj.resolution = 4; + const txtStyle = new PIXI.TextStyle(this.pixiTextConfig); + const metric = PIXI.TextMetrics.measureText(textObj.text, txtStyle); + + // scale the width of text label so that its width is the same when converted into circular form + const tw = ((metric.width / (2 * r * Math.PI)) * width * 360) / (endAngle - startAngle); + let [minX, maxX] = [cx - tw / 2.0, cx + tw / 2.0]; + + // make sure not to place the label on the origin + if (minX < 0) { + const gap = -minX; + minX = 0; + maxX += gap; + } else if (maxX > width) { + const gap = maxX - width; + maxX = width; + minX -= gap; + } - if (ropePoints.length === 0) { - return undefined; - } + const ropePoints: PIXI.Point[] = []; + const baseR = innerRadius + metric.height / 2.0 + 3; + for (let i = maxX; i >= minX; i -= tw / 10.0) { + const p = cartesianToPolar(i, width, baseR, width / 2.0, height / 2.0, startAngle, endAngle); + ropePoints.push(new PIXI.Point(p.x, p.y)); + } - // @ts-expect-error missing argument - textObj.updateText(); - const rope = new HGC.libraries.PIXI.SimpleRope(textObj.texture, ropePoints); - return rope; + if (ropePoints.length === 0) { + return undefined; } - draw() { - this.allTexts = []; + // @ts-expect-error missing argument + textObj.updateText(); + const rope = new PIXI.SimpleRope(textObj.texture, ropePoints); + return rope; + } - if (!this.texts) return; + draw() { + this.allTexts = []; - const x1 = absToChr(this._xScale.domain()[0], this.chromInfo); - const x2 = absToChr(this._xScale.domain()[1], this.chromInfo); + if (!this.texts) return; - if (!x1 || !x2) { - console.warn('Empty chromInfo:', this.dataConfig, this.chromInfo); - return; - } + const x1 = absToChr(this._xScale.domain()[0], this.chromInfo); + const x2 = absToChr(this._xScale.domain()[1], this.chromInfo); - if (this.options.tickPositions === 'ends' && this.options.layout !== 'circular') { - // We only support linear layouts for this. - if (!this.gBoundTicks) return; + if (!x1 || !x2) { + console.warn('Empty chromInfo:', this.dataConfig, this.chromInfo); + return; + } - this.gBoundTicks.visible = true; + if (this.options.tickPositions === 'ends' && this.options.layout !== 'circular') { + // We only support linear layouts for this. + if (!this.gBoundTicks) return; - this.drawBoundsTicks(x1, x2); + this.gBoundTicks.visible = true; - return; - } + this.drawBoundsTicks(x1, x2); - if (!this.pTicks) { - // options.tickPositiosn was probably just changed to 'even' and initChromLabels hasn't been called yet - return; - } + return; + } - const circular = this.options.layout === 'circular'; + if (!this.pTicks) { + // options.tickPositiosn was probably just changed to 'even' and initChromLabels hasn't been called yet + return; + } - for (let i = 0; i < this.texts.length; i++) { - this.texts[i].visible = false; - this.gTicks[this.chromInfo.cumPositions[i].chr].visible = false; - } + const circular = this.options.layout === 'circular'; - let yPadding = this.tickHeight + this.tickTextSeparation; + for (let i = 0; i < this.texts.length; i++) { + this.texts[i].visible = false; + this.gTicks[this.chromInfo.cumPositions[i].chr].visible = false; + } - if (this.options.reverseOrientation) { - yPadding = this.dimensions[1] - yPadding; - } + let yPadding = this.tickHeight + this.tickTextSeparation; - // hide all the chromosome labels in preparation for drawing new ones - Object.keys(this.chromInfo.chrPositions).forEach(chrom => { - if (this.tickTexts[chrom]) { - this.tickTexts[chrom].forEach((tick: any) => { - tick.visible = false; - }); - } - }); + if (this.options.reverseOrientation) { + yPadding = this.dimensions[1] - yPadding; + } - /* tslint:disable */ - this.pTicksCircular.removeChildren(); + // hide all the chromosome labels in preparation for drawing new ones + Object.keys(this.chromInfo.chrPositions).forEach(chrom => { + if (this.tickTexts[chrom]) { + this.tickTexts[chrom].forEach((tick: any) => { + tick.visible = false; + }); + } + }); - // iterate over each chromosome - for (let i = x1[3]; i <= x2[3]; i++) { - const xCumPos = this.chromInfo.cumPositions[i]; + /* tslint:disable */ + this.pTicksCircular.removeChildren(); - const midX = xCumPos.pos + this.chromInfo.chromLengths[xCumPos.chr] / 2; + // iterate over each chromosome + for (let i = x1[3]; i <= x2[3]; i++) { + const xCumPos = this.chromInfo.cumPositions[i]; - const viewportMidX = this._xScale(midX); + const midX = xCumPos.pos + this.chromInfo.chromLengths[xCumPos.chr] / 2; - // This is ONLY the bare chromosome name. Not the tick label! - const chrText = this.texts[i]; + const viewportMidX = this._xScale(midX); - chrText.anchor.x = 0.5; - chrText.anchor.y = circular ? 0.5 : this.options.reverseOrientation ? 0 : 1; + // This is ONLY the bare chromosome name. Not the tick label! + const chrText = this.texts[i]; - let rope: PIXI.SimpleRope | undefined; - if (circular) { - rope = this.addCurvedText(chrText, viewportMidX); - if (rope) { - this.pTicksCircular.addChild(rope); - } - } else { - chrText.x = viewportMidX; - chrText.y = this.dimensions[1] - yPadding; + chrText.anchor.x = 0.5; + chrText.anchor.y = circular ? 0.5 : this.options.reverseOrientation ? 0 : 1; + + let rope: PIXI.SimpleRope | undefined; + if (circular) { + rope = this.addCurvedText(chrText, viewportMidX); + if (rope) { + this.pTicksCircular.addChild(rope); } + } else { + chrText.x = viewportMidX; + chrText.y = this.dimensions[1] - yPadding; + } - chrText.updateTransform(); + chrText.updateTransform(); - if (this.flipText) chrText.scale.x = -1; + if (this.flipText) chrText.scale.x = -1; - const numTicksDrawn = this.drawTicks(xCumPos); + const numTicksDrawn = this.drawTicks(xCumPos); - // only show chromsome labels if there's no ticks drawn - if (!circular) { - chrText.visible = numTicksDrawn <= 0; - } else { - if (numTicksDrawn > 0) { - rope && this.pTicksCircular.removeChild(rope); - } + // only show chromsome labels if there's no ticks drawn + if (!circular) { + chrText.visible = numTicksDrawn <= 0; + } else { + if (numTicksDrawn > 0) { + rope && this.pTicksCircular.removeChild(rope); } - - this.allTexts.push({ - importance: chrText.hashValue, - text: chrText, - rope - }); } - /* tslint:enable */ - // define the edge chromosome which are visible - this.hideOverlaps(this.allTexts); + this.allTexts.push({ + importance: chrText.hashValue, + text: chrText, + rope + }); } + /* tslint:enable */ + + // define the edge chromosome which are visible + this.hideOverlaps(this.allTexts); + } - hideOverlaps(allTexts: TickLabelInfo[]) { - const tree = new RBush<{ minX: number; minY: number; maxX: number; maxY: number }>(); - - // using bounding boxes of the text objects, calculate overlaps - allTexts - .sort((a, b) => b.importance - a.importance) - .forEach(({ text, rope }: any) => { - text.updateTransform(); - const b = text.getBounds(); - const m = this.options.labelMargin; - const boxWithMargin = { - minX: b.x - m, - minY: b.y - m, - maxX: b.x + b.width + m * 2, - maxY: b.y + b.height + m * 2 - }; - if (m < 0 || !tree.collides(boxWithMargin)) { - // if not overlapping, add a new boundingbox - tree.insert(boxWithMargin); - } else { - // if overlapping, hide text labels - text.visible = false; - if (this.options.layout === 'circular' && rope) { - this.pTicksCircular.removeChild(rope); - } + hideOverlaps(allTexts: TickLabelInfo[]) { + const tree = new RBush<{ + minX: number; + minY: number; + maxX: number; + maxY: number; + }>(); + + // using bounding boxes of the text objects, calculate overlaps + allTexts + .sort((a, b) => b.importance - a.importance) + .forEach(({ text, rope }: any) => { + text.updateTransform(); + const b = text.getBounds(); + const m = this.options.labelMargin; + const boxWithMargin = { + minX: b.x - m, + minY: b.y - m, + maxX: b.x + b.width + m * 2, + maxY: b.y + b.height + m * 2 + }; + if (m < 0 || !tree.collides(boxWithMargin)) { + // if not overlapping, add a new boundingbox + tree.insert(boxWithMargin); + } else { + // if overlapping, hide text labels + text.visible = false; + if (this.options.layout === 'circular' && rope) { + this.pTicksCircular.removeChild(rope); } - }); - } + } + }); + } - setPosition(newPosition: [number, number]) { - super.setPosition(newPosition); + setPosition(newPosition: [number, number]) { + super.setPosition(newPosition); - [this.pMain.position.x, this.pMain.position.y] = this.position; + [this.pMain.position.x, this.pMain.position.y] = this.position; + } + + zoomed(newXScale: any, newYScale: any) { + const domainValues = [...newXScale.domain(), ...newYScale.domain()]; + if (domainValues.filter(d => isNaN(d)).length !== 0) { + // we received an invalid scale somehow + // console.warn(''); + return; } - zoomed(newXScale: any, newYScale: any) { - const domainValues = [...newXScale.domain(), ...newYScale.domain()]; - if (domainValues.filter(d => isNaN(d)).length !== 0) { - // we received an invalid scale somehow - // console.warn(''); - return; - } + this.xScale(newXScale); + this.yScale(newYScale); - this.xScale(newXScale); - this.yScale(newYScale); + this.draw(); + } - this.draw(); - } + exportSVG(): [HTMLElement, HTMLElement] { + let track = null; + let base = null; - exportSVG(): [HTMLElement, HTMLElement] { - let track = null; - let base = null; + // @ts-expect-error always true because it's defined on HGC.tracks.PixiTrack + if (super.exportSVG) { + [base, track] = super.exportSVG(); + } else { + base = document.createElement('g'); + track = base; + } + base.setAttribute('class', 'chromosome-labels'); - // @ts-expect-error always true because it's defined on HGC.tracks.PixiTrack - if (super.exportSVG) { - [base, track] = super.exportSVG(); - } else { - base = document.createElement('g'); - track = base; - } - base.setAttribute('class', 'chromosome-labels'); + const output = document.createElement('g'); + track.appendChild(output); - const output = document.createElement('g'); - track.appendChild(output); + output.setAttribute('transform', `translate(${this.position[0]},${this.position[1]})`); - output.setAttribute('transform', `translate(${this.position[0]},${this.position[1]})`); + this.allTexts + .filter(text => text.text.visible) + .forEach(text => { + const g = pixiTextToSvg(text.text); + output.appendChild(g); + }); - this.allTexts - .filter(text => text.text.visible) + Object.values(this.tickTexts).forEach(texts => { + texts + .filter(x => x.visible) .forEach(text => { - const g = pixiTextToSvg(text.text); + if (!text.tickLine) return; + + let g = pixiTextToSvg(text); output.appendChild(g); - }); + g = svgLine( + text.x, + this.options.reverseOrientation ? 0 : this.dimensions[1], + text.x, + this.options.reverseOrientation ? this.tickHeight : this.dimensions[1] - this.tickHeight, + 1, + this.tickColor + ); + + const line = document.createElement('line'); + + line.setAttribute('x1', String(text.tickLine[0])); + line.setAttribute('y1', String(text.tickLine[1])); + line.setAttribute('x2', String(text.tickLine[2])); + line.setAttribute('y2', String(text.tickLine[3])); + line.setAttribute('style', 'stroke: grey'); - Object.values(this.tickTexts).forEach(texts => { - texts - .filter(x => x.visible) - .forEach(text => { - if (!text.tickLine) return; - - let g = pixiTextToSvg(text); - output.appendChild(g); - g = svgLine( - text.x, - this.options.reverseOrientation ? 0 : this.dimensions[1], - text.x, - this.options.reverseOrientation ? this.tickHeight : this.dimensions[1] - this.tickHeight, - 1, - this.tickColor - ); - - const line = document.createElement('line'); - - line.setAttribute('x1', String(text.tickLine[0])); - line.setAttribute('y1', String(text.tickLine[1])); - line.setAttribute('x2', String(text.tickLine[2])); - line.setAttribute('y2', String(text.tickLine[3])); - line.setAttribute('style', 'stroke: grey'); - - output.appendChild(g); - output.appendChild(line); - }); - }); + output.appendChild(g); + output.appendChild(line); + }); + }); - return [base, track]; - } + return [base, track]; } - return new AxisTrackClass(); -}; - -export default createPluginTrack(config, factory); +} diff --git a/src/tracks/gosling-genomic-axis/index.ts b/src/tracks/gosling-genomic-axis/index.ts index 8b09879c8..d16a38965 100644 --- a/src/tracks/gosling-genomic-axis/index.ts +++ b/src/tracks/gosling-genomic-axis/index.ts @@ -1,3 +1,2 @@ -import AxisTrack from './axis-track'; - -export { AxisTrack }; +export { AxisTrackClass, type AxisTrackOptions } from './axis-track'; +export { AxisTrack } from './axis-track-plot'; From e41130a5ee87e6fb9a90b6bf4cd43397ff441643 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Thu, 13 Jun 2024 13:24:16 -0400 Subject: [PATCH 034/139] feat: axis track example --- demo/App.tsx | 3 +- demo/examples/axis-track-example.ts | 191 ++++++++++++++++++++++++++++ demo/examples/index.ts | 1 + 3 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 demo/examples/axis-track-example.ts diff --git a/demo/App.tsx b/demo/App.tsx index 6ef85945a..23ad42792 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { PixiManager } from '@pixi-manager'; -import { addDummyTrack, addTextTrack, addCircularBrush, addGoslingTrack } from './examples'; +import { addDummyTrack, addTextTrack, addCircularBrush, addGoslingTrack, addAxisTrack } from './examples'; import './App.css'; @@ -17,6 +17,7 @@ function App() { addDummyTrack(pixiManager); addCircularBrush(pixiManager); addGoslingTrack(pixiManager); + addAxisTrack(pixiManager); }, []); return ( diff --git a/demo/examples/axis-track-example.ts b/demo/examples/axis-track-example.ts new file mode 100644 index 000000000..d6a7b72ff --- /dev/null +++ b/demo/examples/axis-track-example.ts @@ -0,0 +1,191 @@ +import { PixiManager } from '@pixi-manager'; +import { signal } from '@preact/signals-core'; +import { AxisTrack } from '@gosling-lang/gosling-genomic-axis'; + +export function addAxisTrack(pixiManager: PixiManager) { + const view1Domain = signal<[number, number]>([543317951, 544039951]); + // Axis track + const posAxis = { + x: 0, + y: 300, + width: 400, + height: 30 + }; + new AxisTrack(axisTrack, view1Domain, pixiManager.makeContainer(posAxis)); +} + +export const axisTrack = { + id: '62c6e5ca-1713-4d0d-afb0-cfcc00b2c703-top-axis', + layout: 'linear', + innerRadius: null, + width: 400, + height: 70, + theme: { + base: 'light', + root: { + background: 'white', + titleColor: 'black', + titleBackgroundColor: 'transparent', + titleFontSize: 18, + titleFontFamily: 'Arial', + titleAlign: 'left', + titleFontWeight: 'bold', + subtitleColor: 'gray', + subtitleBackgroundColor: 'transparent', + subtitleFontSize: 16, + subtitleFontFamily: 'Arial', + subtitleFontWeight: 'normal', + subtitleAlign: 'left', + showMousePosition: true, + mousePositionColor: '#000000' + }, + track: { + background: 'transparent', + alternatingBackground: 'transparent', + titleColor: 'black', + titleBackground: 'white', + titleFontSize: 24, + titleAlign: 'left', + outline: 'black', + outlineWidth: 1 + }, + legend: { + position: 'top', + background: 'white', + backgroundOpacity: 0.7, + labelColor: 'black', + labelFontSize: 12, + labelFontWeight: 'normal', + labelFontFamily: 'Arial', + backgroundStroke: '#DBDBDB', + tickColor: 'black' + }, + axis: { + tickColor: 'black', + labelColor: 'black', + labelMargin: 5, + labelExcludeChrPrefix: false, + labelFontSize: 12, + labelFontWeight: 'normal', + labelFontFamily: 'Arial', + baselineColor: 'black', + gridColor: '#E3E3E3', + gridStrokeWidth: 1, + gridStrokeType: 'solid', + gridStrokeDash: [4, 4] + }, + markCommon: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + point: { + color: '#E79F00', + size: 3, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + rect: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + triangle: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + area: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + line: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + bar: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + rule: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 1, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + link: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 1, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + text: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6], + textAnchor: 'middle', + textFontWeight: 'normal' + }, + brush: { + color: 'gray', + size: 1, + stroke: 'black', + strokeWidth: 1, + opacity: 0.3, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + } + }, + assembly: 'hg38', + stroke: 'transparent', + color: 'black', + labelMargin: 5, + excludeChrPrefix: false, + fontSize: 12, + fontFamily: 'Arial', + fontWeight: 'normal', + tickColor: 'black', + tickFormat: 'plain', + tickPositions: 'ends', + reverseOrientation: false +}; diff --git a/demo/examples/index.ts b/demo/examples/index.ts index c1ed6feeb..4fbe700e6 100644 --- a/demo/examples/index.ts +++ b/demo/examples/index.ts @@ -2,3 +2,4 @@ export { addDummyTrack } from './dummy-track-example'; export { addTextTrack } from './text-track-example'; export { addCircularBrush } from './circular-brush-example'; export { addGoslingTrack } from './gosling-track-example'; +export { addAxisTrack } from './axis-track-example'; From 1778baef6f08d4724c732eccad393496e3470b36 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Thu, 13 Jun 2024 14:01:37 -0400 Subject: [PATCH 035/139] feat: brush linear --- src/tracks/brush-linear/brush-linear-plot.ts | 68 +++++++ src/tracks/brush-linear/brush-linear-track.ts | 186 ++++++++++++++++++ src/tracks/brush-linear/index.ts | 1 + tsconfig.json | 1 + vite.config.js | 1 + 5 files changed, 257 insertions(+) create mode 100644 src/tracks/brush-linear/brush-linear-plot.ts create mode 100644 src/tracks/brush-linear/brush-linear-track.ts create mode 100644 src/tracks/brush-linear/index.ts diff --git a/src/tracks/brush-linear/brush-linear-plot.ts b/src/tracks/brush-linear/brush-linear-plot.ts new file mode 100644 index 000000000..e6e22569e --- /dev/null +++ b/src/tracks/brush-linear/brush-linear-plot.ts @@ -0,0 +1,68 @@ +import { + BrushLinearTrackClass, + type BrushLinearTrackOptions, + type BrushLinearTrackContext +} from './brush-linear-track'; +import { scaleLinear } from 'd3-scale'; +import { type Signal, effect, signal } from '@preact/signals-core'; + +export class BrushLinearTrack extends BrushLinearTrackClass { + xDomain: Signal<[number, number]>; + xBrushDomain: Signal<[number, number]>; + domOverlay: HTMLElement; + + constructor( + options: BrushLinearTrackOptions, + xBrushDomain: Signal<[number, number]>, + domOverlay: HTMLElement, + xDomain = signal<[number, number]>([0, 3088269832]) // Default domain + ) { + const height = domOverlay.clientHeight; + const width = domOverlay.clientWidth; + // Create a new svg element. The brush will be drawn on this element + const svgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svgElement.style.width = `${width}px`; + svgElement.style.height = `${height}px`; + // Add it to the overlay div + domOverlay.appendChild(svgElement); + + // Setup the context object + const context: BrushLinearTrackContext = { + id: 'test', + svgElement: svgElement, + getTheme: () => 'light', + registerViewportChanged: () => {}, + removeViewportChanged: () => {}, + setDomainsCallback: (xDomain: [number, number]) => (xBrushDomain.value = xDomain), + projectionXDomain: xBrushDomain.value + }; + + super(context, options); + + this.xDomain = xDomain; + this.xBrushDomain = xBrushDomain; + this.domOverlay = domOverlay; + // Now we need to initialize all of the properties that would normally be set by HiGlassComponent + this.setDimensions([width, height]); + this.setPosition([0, 0]); + // Create some scales to pass in + const refXScale = scaleLinear().domain(xDomain.value).range([0, width]); + const refYScale = scaleLinear(); // This doesn't get used anywhere but we need to pass it in + // Set the scales + this.zoomed(refXScale, refYScale); + this.refScalesChanged(refXScale, refYScale); + // Draw and add the zoom behavior + this.draw(); + + // When the brush signal changes, we want to update the brush + effect(() => { + const newXDomain = scaleLinear().domain(this.xBrushDomain.value); + this.viewportChanged(newXDomain, scaleLinear()); + }); + } + + addInteractor(interactor: (plot: BrushLinearTrack) => void) { + interactor(this); + return this; // For chaining + } +} diff --git a/src/tracks/brush-linear/brush-linear-track.ts b/src/tracks/brush-linear/brush-linear-track.ts new file mode 100644 index 000000000..e705b7af5 --- /dev/null +++ b/src/tracks/brush-linear/brush-linear-track.ts @@ -0,0 +1,186 @@ +import { brushX } from 'd3-brush'; +import { uuid } from '../../core/utils/uuid'; +import { type ScaleLinear } from 'd3-scale'; +import { SVGTrack, type SVGTrackContext } from '@higlass/tracks'; + +export interface BrushLinearTrackContext extends SVGTrackContext { + registerViewportChanged: ( + uid: string, + callback: (viewportXScale: ScaleLinear, viewportYScale: ScaleLinear) => void + ) => void; + removeViewportChanged: (uid: string) => void; + setDomainsCallback: (xDomain: [number, number], yDomain: [number, number]) => void; + projectionXDomain: [number, number]; // The domain of the brush +} + +export interface BrushLinearTrackOptions { + projectionFillColor: string; + projectionStrokeColor: string; + projectionFillOpacity: number; + projectionStrokeOpacity: number; + strokeWidth: number; +} + +export class BrushLinearTrackClass extends SVGTrack { + uid: string; + options: Options & BrushLinearTrackOptions; + hasFromView: boolean; + removeViewportChanged: (uid: string) => void; + setDomainsCallback: (xDomain: [number, number], yDomain: [number, number]) => void; + viewportXDomain: [number, number] | null; + viewportYDomain: [number, number] | null; + brush: any; + gBrush: any; + + constructor(context: BrushLinearTrackContext, options: Options & BrushLinearTrackOptions) { + // create a clipped SVG Path + super(context, options); + const { registerViewportChanged, removeViewportChanged, setDomainsCallback } = context; + + const uid = uuid(); + this.uid = uid; + this.options = options; + + // Is there actually a linked _from_ view? Or is this projection "independent"? + this.hasFromView = !context.projectionXDomain; + + this.removeViewportChanged = removeViewportChanged; + this.setDomainsCallback = setDomainsCallback; + + this.viewportXDomain = this.hasFromView ? null : context.projectionXDomain; + this.viewportYDomain = this.hasFromView ? null : [0, 0]; + + this.brush = brushX().on('brush', this.brushed.bind(this)); + + this.gBrush = this.gMain.append('g').attr('id', `brush-${this.uid}`).call(this.brush); + + // turn off the ability to select new regions for this brush + this.gBrush.selectAll('.overlay').style('pointer-events', 'none'); + + // turn off the ability to modify the aspect ratio of the brush + this.gBrush.selectAll('.handle--ne').style('pointer-events', 'none'); + + this.gBrush.selectAll('.handle--nw').style('pointer-events', 'none'); + + this.gBrush.selectAll('.handle--sw').style('pointer-events', 'none'); + + this.gBrush.selectAll('.handle--se').style('pointer-events', 'none'); + + this.gBrush.selectAll('.handle--n').style('pointer-events', 'none'); + + this.gBrush.selectAll('.handle--s').style('pointer-events', 'none'); + + // the viewport will call this.viewportChanged immediately upon + // hearing registerViewportChanged + registerViewportChanged(uid, this.viewportChanged.bind(this)); + + this.rerender(); + this.draw(); + } + + brushed(event) { + /** + * Should only be called on active brushing, not in response to the + * draw event + */ + const s = event.selection; + + if (!this._xScale || !this._yScale) { + return; + } + + const xDomain = [this._xScale.invert(s[0]), this._xScale.invert(s[1])] as [number, number]; + + const yDomain = this.viewportYDomain as [number, number]; + + if (!this.hasFromView) { + this.viewportXDomain = xDomain; + } + + // console.log('xDomain:', xDomain); + // console.log('yDomain:', yDomain); + + this.setDomainsCallback(xDomain, yDomain); + } + + viewportChanged(viewportXScale: ScaleLinear, viewportYScale: ScaleLinear) { + // console.log('viewport changed:', viewportXScale.domain()); + const viewportXDomain = viewportXScale.domain() as [number, number]; + const viewportYDomain = viewportYScale.domain() as [number, number]; + + this.viewportXDomain = viewportXDomain; + this.viewportYDomain = viewportYDomain; + + this.draw(); + } + + remove() { + // remove the event handler that updates this viewport tracker + this.removeViewportChanged(this.uid); + + super.remove(); + } + + rerender() { + // set the fill and stroke colors + this.gBrush + .selectAll('.selection') + .attr('fill', this.options.projectionFillColor) + .attr('stroke', this.options.projectionStrokeColor) + .attr('fill-opacity', this.options.projectionFillOpacity) + .attr('stroke-opacity', this.options.projectionStrokeOpacity) + .attr('stroke-width', this.options.strokeWidth); + } + + draw() { + if (!this._xScale || !this.yScale) { + return; + } + + if (!this.viewportXDomain || !this.viewportYDomain) { + return; + } + + const x0 = this._xScale(this.viewportXDomain[0]); + const x1 = this._xScale(this.viewportXDomain[1]); + + const dest = [x0, x1]; + + // console.log('dest:', dest[0], dest[1]); + + // user hasn't actively brushed so we don't want to emit a + // 'brushed' event + this.brush.on('brush', null); + this.gBrush.call(this.brush.move, dest); + this.brush.on('brush', this.brushed.bind(this)); + } + + zoomed(newXScale: ScaleLinear, newYScale: ScaleLinear) { + this.xScale(newXScale); + this.yScale(newYScale); + + this.draw(); + } + + setPosition(newPosition: [number, number]) { + super.setPosition(newPosition); + + this.draw(); + } + + setDimensions(newDimensions: [number, number]) { + super.setDimensions(newDimensions); + + const xRange = this._xScale.range(); + const yRange = this._yScale.range(); + const xDiff = xRange[1] - xRange[0]; + + this.brush.extent([ + [xRange[0] - xDiff, yRange[0]], + [xRange[1] + xDiff, yRange[1]] + ]); + this.gBrush.call(this.brush); + + this.draw(); + } +} diff --git a/src/tracks/brush-linear/index.ts b/src/tracks/brush-linear/index.ts new file mode 100644 index 000000000..3464e4b7f --- /dev/null +++ b/src/tracks/brush-linear/index.ts @@ -0,0 +1 @@ +export { BrushLinearTrack } from './brush-linear-plot'; diff --git a/tsconfig.json b/tsconfig.json index 5f2170a94..b22b2abe8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,6 +30,7 @@ "@gosling-lang/gosling-track": ["./src/tracks/gosling-track/index.ts"], "@gosling-lang/gosling-genomic-axis": ["./src/tracks/gosling-genomic-axis/index.ts"], "@gosling-lang/circular-brush": ["src/tracks/circular-brush/index.ts"], + "@gosling-lang/brush-linear": ["src/tracks/brush-linear/index.ts"], "@gosling-lang/dummy-track": ["./src/tracks/dummy-track/index.ts"], "@gosling-lang/text-track": ["./src/tracks/text-track/index.ts"], "@gosling-lang/interactors": ["./src/interactors/index.ts"], diff --git a/vite.config.js b/vite.config.js index b399a120c..efbff7950 100644 --- a/vite.config.js +++ b/vite.config.js @@ -72,6 +72,7 @@ const alias = { '@gosling-lang/gosling-track': path.resolve(__dirname, './src/tracks/gosling-track/index.ts'), '@gosling-lang/gosling-genomic-axis': path.resolve(__dirname, './src/tracks/gosling-genomic-axis/index.ts'), '@gosling-lang/circular-brush': path.resolve(__dirname, './src/tracks/circular-brush/index.ts'), + '@gosling-lang/brush-linear': path.resolve(__dirname, './src/tracks/brush-linear/index.ts'), '@gosling-lang/dummy-track': path.resolve(__dirname, './src/tracks/dummy-track/index.ts'), '@gosling-lang/text-track': path.resolve(__dirname, './src/tracks/text-track/index.ts'), '@gosling-lang/interactors': path.resolve(__dirname, './src/interactors/index.ts'), From 1048754baed299303fbeebd67f51acaa1e445653 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Thu, 13 Jun 2024 14:02:22 -0400 Subject: [PATCH 036/139] fix: add missing type package --- package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/package.json b/package.json index 160ae1f3d..b35ca487e 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "@types/bezier-js": "^4.1.0", "@types/d3": "^7.4.3", "@types/d3-array": "^3.2.1", + "@types/d3-brush": "^3.0.6", "@types/d3-color": "^3.1.3", "@types/d3-drag": "^3.0.7", "@types/d3-dsv": "^3.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17d8e1f31..d5b866c67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,6 +139,9 @@ devDependencies: '@types/d3-array': specifier: ^3.2.1 version: 3.2.1 + '@types/d3-brush': + specifier: ^3.0.6 + version: 3.0.6 '@types/d3-color': specifier: ^3.1.3 version: 3.1.3 From c56d5b0a2bccdf9d12d3c1f7a40b9376951120a0 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Thu, 13 Jun 2024 14:02:41 -0400 Subject: [PATCH 037/139] refactor: remove types --- src/missing-types.d.ts | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/src/missing-types.d.ts b/src/missing-types.d.ts index 8f0efa34a..76089ba0f 100644 --- a/src/missing-types.d.ts +++ b/src/missing-types.d.ts @@ -587,31 +587,6 @@ declare module '@higlass/tracks' { interface SVGTrackContext extends TrackContext { svgElement: SVGElement; } - interface ViewportTrackerHorizontalContext extends SVGTrackContext { - registerViewportChanged: ( - uid: string, - callback: (viewportXScale: ScaleLinear, viewportYScale: ScaleLinear) => void - ) => void; - removeViewportChanged: (uid: string) => void; - setDomainsCallback: (xDomain: [number, number], yDomain: [number, number]) => void; - projectionXDomain: [number, number]; // The domain of the brush - } - - interface ViewportTrackerHorizontalOptions { - projectionFillColor: string; - projectionStrokeColor: string; - projectionFillOpacity: number; - projectionStrokeOpacity: number; - strokeWidth: number; - } - - export class ViewportTrackerHorizontal extends SVGTrack { - options: Options & ViewportTrackerHorizontalOptions; - context: ViewportTrackerHorizontalContext; - viewportChanged: (viewportXScale: Scale, viewportYScale: Scale) => void; - - constructor(context: ViewportTrackerHorizontalContext, options: Options & ViewportTrackerHorizontalOptions); - } /* eslint-disable-next-line @typescript-eslint/ban-types */ type LiteralUnion = T | (U & {}); From d15977a0c40494b2add0d888b81de6928657748f Mon Sep 17 00:00:00 2001 From: etowahadams Date: Thu, 13 Jun 2024 14:02:47 -0400 Subject: [PATCH 038/139] feat: add example --- demo/App.tsx | 11 ++++++++++- demo/examples/brush-linear-example.ts | 22 ++++++++++++++++++++++ demo/examples/index.ts | 1 + 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 demo/examples/brush-linear-example.ts diff --git a/demo/App.tsx b/demo/App.tsx index 23ad42792..87fae1410 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -1,8 +1,16 @@ import React, { useState, useEffect } from 'react'; import { PixiManager } from '@pixi-manager'; -import { addDummyTrack, addTextTrack, addCircularBrush, addGoslingTrack, addAxisTrack } from './examples'; +import { + addDummyTrack, + addTextTrack, + addCircularBrush, + addGoslingTrack, + addAxisTrack, + addLinearBrush +} from './examples'; import './App.css'; +import { add } from 'lodash-es'; function App() { const [fps, setFps] = useState(120); @@ -18,6 +26,7 @@ function App() { addCircularBrush(pixiManager); addGoslingTrack(pixiManager); addAxisTrack(pixiManager); + addLinearBrush(pixiManager); }, []); return ( diff --git a/demo/examples/brush-linear-example.ts b/demo/examples/brush-linear-example.ts new file mode 100644 index 000000000..da48578ff --- /dev/null +++ b/demo/examples/brush-linear-example.ts @@ -0,0 +1,22 @@ +import { PixiManager } from '@pixi-manager'; +import { BrushLinearTrack } from '@gosling-lang/brush-linear'; +import { signal } from '@preact/signals-core'; +import { panZoom } from '@gosling-lang/interactors'; + +export function addLinearBrush(pixiManager: PixiManager) { + const pos0 = { x: 10, y: 100, width: 250, height: 250 }; + const circularDomain = signal<[number, number]>([0, 248956422]); + const detailedDomain = signal<[number, number]>([160000000, 200000000]); + + // Brush track + const options = { + projectionFillColor: 'red', + projectionStrokeColor: 'red', + projectionFillOpacity: 0.3, + projectionStrokeOpacity: 0.3, + strokeWidth: 1 + }; + new BrushLinearTrack(options, detailedDomain, pixiManager.makeContainer(pos0).overlayDiv).addInteractor(plot => + panZoom(plot, circularDomain) + ); +} diff --git a/demo/examples/index.ts b/demo/examples/index.ts index 4fbe700e6..db938bfe1 100644 --- a/demo/examples/index.ts +++ b/demo/examples/index.ts @@ -3,3 +3,4 @@ export { addTextTrack } from './text-track-example'; export { addCircularBrush } from './circular-brush-example'; export { addGoslingTrack } from './gosling-track-example'; export { addAxisTrack } from './axis-track-example'; +export { addLinearBrush } from './brush-linear-example'; From 9bd22f41ecd3bbb45fce6cd62251621b6e8ddb2b Mon Sep 17 00:00:00 2001 From: etowahadams Date: Thu, 13 Jun 2024 15:33:44 -0400 Subject: [PATCH 039/139] feat: rename to brush-circular --- demo/examples/circular-brush-example.ts | 2 +- .../brush-circular-plot.ts} | 2 +- .../circular-brush.ts => brush-circular/brush-circular.ts} | 0 src/tracks/brush-circular/index.ts | 2 ++ src/tracks/circular-brush/index.ts | 2 -- tsconfig.json | 2 +- vite.config.js | 2 +- 7 files changed, 6 insertions(+), 6 deletions(-) rename src/tracks/{circular-brush/circular-brush-plot.ts => brush-circular/brush-circular-plot.ts} (99%) rename src/tracks/{circular-brush/circular-brush.ts => brush-circular/brush-circular.ts} (100%) create mode 100644 src/tracks/brush-circular/index.ts delete mode 100644 src/tracks/circular-brush/index.ts diff --git a/demo/examples/circular-brush-example.ts b/demo/examples/circular-brush-example.ts index 2851531fc..25c03a818 100644 --- a/demo/examples/circular-brush-example.ts +++ b/demo/examples/circular-brush-example.ts @@ -1,5 +1,5 @@ import { PixiManager } from '@pixi-manager'; -import { CircularBrushTrack } from '@gosling-lang/circular-brush'; +import { CircularBrushTrack } from '@gosling-lang/brush-circular'; import { signal } from '@preact/signals-core'; export function addCircularBrush(pixiManager: PixiManager) { diff --git a/src/tracks/circular-brush/circular-brush-plot.ts b/src/tracks/brush-circular/brush-circular-plot.ts similarity index 99% rename from src/tracks/circular-brush/circular-brush-plot.ts rename to src/tracks/brush-circular/brush-circular-plot.ts index 615e3a1ad..227a4e155 100644 --- a/src/tracks/circular-brush/circular-brush-plot.ts +++ b/src/tracks/brush-circular/brush-circular-plot.ts @@ -2,7 +2,7 @@ import { CircularBrushTrackClass, type CircularBrushTrackOptions, type CircularBrushTrackContext -} from './circular-brush'; +} from './brush-circular'; import { scaleLinear } from 'd3-scale'; import { ZoomTransform, type D3ZoomEvent, zoom } from 'd3-zoom'; import { select } from 'd3-selection'; diff --git a/src/tracks/circular-brush/circular-brush.ts b/src/tracks/brush-circular/brush-circular.ts similarity index 100% rename from src/tracks/circular-brush/circular-brush.ts rename to src/tracks/brush-circular/brush-circular.ts diff --git a/src/tracks/brush-circular/index.ts b/src/tracks/brush-circular/index.ts new file mode 100644 index 000000000..90b645a5d --- /dev/null +++ b/src/tracks/brush-circular/index.ts @@ -0,0 +1,2 @@ +export { CircularBrushTrack } from './brush-circular-plot'; +export type { CircularBrushTrackOptions, CircularBrushTrackContext } from './brush-circular'; diff --git a/src/tracks/circular-brush/index.ts b/src/tracks/circular-brush/index.ts deleted file mode 100644 index 061546e7b..000000000 --- a/src/tracks/circular-brush/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { CircularBrushTrack } from './circular-brush-plot'; -export type { CircularBrushTrackOptions, CircularBrushTrackContext } from './circular-brush'; diff --git a/tsconfig.json b/tsconfig.json index b22b2abe8..afca3b45d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,7 +29,7 @@ "@gosling-lang/gosling-theme": ["src/gosling-theme/index.ts"], "@gosling-lang/gosling-track": ["./src/tracks/gosling-track/index.ts"], "@gosling-lang/gosling-genomic-axis": ["./src/tracks/gosling-genomic-axis/index.ts"], - "@gosling-lang/circular-brush": ["src/tracks/circular-brush/index.ts"], + "@gosling-lang/brush-circular": ["src/tracks/brush-circular/index.ts"], "@gosling-lang/brush-linear": ["src/tracks/brush-linear/index.ts"], "@gosling-lang/dummy-track": ["./src/tracks/dummy-track/index.ts"], "@gosling-lang/text-track": ["./src/tracks/text-track/index.ts"], diff --git a/vite.config.js b/vite.config.js index efbff7950..8d4b1542b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -71,7 +71,7 @@ const alias = { '@gosling-lang/gosling-theme': path.resolve(__dirname, './src/gosling-theme/index.ts'), '@gosling-lang/gosling-track': path.resolve(__dirname, './src/tracks/gosling-track/index.ts'), '@gosling-lang/gosling-genomic-axis': path.resolve(__dirname, './src/tracks/gosling-genomic-axis/index.ts'), - '@gosling-lang/circular-brush': path.resolve(__dirname, './src/tracks/circular-brush/index.ts'), + '@gosling-lang/brush-circular': path.resolve(__dirname, './src/tracks/brush-circular/index.ts'), '@gosling-lang/brush-linear': path.resolve(__dirname, './src/tracks/brush-linear/index.ts'), '@gosling-lang/dummy-track': path.resolve(__dirname, './src/tracks/dummy-track/index.ts'), '@gosling-lang/text-track': path.resolve(__dirname, './src/tracks/text-track/index.ts'), From c5b95dd67689c029cea42ca681c48aff785d5339 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Thu, 13 Jun 2024 15:37:00 -0400 Subject: [PATCH 040/139] feat: rename genomic axis --- demo/examples/axis-track-example.ts | 2 +- .../{gosling-genomic-axis => genomic-axis}/axis-track-plot.ts | 0 src/tracks/{gosling-genomic-axis => genomic-axis}/axis-track.ts | 0 src/tracks/{gosling-genomic-axis => genomic-axis}/index.ts | 0 tsconfig.json | 2 +- vite.config.js | 2 +- 6 files changed, 3 insertions(+), 3 deletions(-) rename src/tracks/{gosling-genomic-axis => genomic-axis}/axis-track-plot.ts (100%) rename src/tracks/{gosling-genomic-axis => genomic-axis}/axis-track.ts (100%) rename src/tracks/{gosling-genomic-axis => genomic-axis}/index.ts (100%) diff --git a/demo/examples/axis-track-example.ts b/demo/examples/axis-track-example.ts index d6a7b72ff..521a97e0a 100644 --- a/demo/examples/axis-track-example.ts +++ b/demo/examples/axis-track-example.ts @@ -1,6 +1,6 @@ import { PixiManager } from '@pixi-manager'; import { signal } from '@preact/signals-core'; -import { AxisTrack } from '@gosling-lang/gosling-genomic-axis'; +import { AxisTrack } from '@gosling-lang/genomic-axis'; export function addAxisTrack(pixiManager: PixiManager) { const view1Domain = signal<[number, number]>([543317951, 544039951]); diff --git a/src/tracks/gosling-genomic-axis/axis-track-plot.ts b/src/tracks/genomic-axis/axis-track-plot.ts similarity index 100% rename from src/tracks/gosling-genomic-axis/axis-track-plot.ts rename to src/tracks/genomic-axis/axis-track-plot.ts diff --git a/src/tracks/gosling-genomic-axis/axis-track.ts b/src/tracks/genomic-axis/axis-track.ts similarity index 100% rename from src/tracks/gosling-genomic-axis/axis-track.ts rename to src/tracks/genomic-axis/axis-track.ts diff --git a/src/tracks/gosling-genomic-axis/index.ts b/src/tracks/genomic-axis/index.ts similarity index 100% rename from src/tracks/gosling-genomic-axis/index.ts rename to src/tracks/genomic-axis/index.ts diff --git a/tsconfig.json b/tsconfig.json index afca3b45d..b9c091d64 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,7 +28,7 @@ "@gosling-lang/higlass-schema": ["src/higlass-schema/index.ts"], "@gosling-lang/gosling-theme": ["src/gosling-theme/index.ts"], "@gosling-lang/gosling-track": ["./src/tracks/gosling-track/index.ts"], - "@gosling-lang/gosling-genomic-axis": ["./src/tracks/gosling-genomic-axis/index.ts"], + "@gosling-lang/genomic-axis": ["src/tracks/genomic-axis/index.ts"], "@gosling-lang/brush-circular": ["src/tracks/brush-circular/index.ts"], "@gosling-lang/brush-linear": ["src/tracks/brush-linear/index.ts"], "@gosling-lang/dummy-track": ["./src/tracks/dummy-track/index.ts"], diff --git a/vite.config.js b/vite.config.js index 8d4b1542b..d4beb8cb5 100644 --- a/vite.config.js +++ b/vite.config.js @@ -70,7 +70,7 @@ const alias = { '@gosling-lang/higlass-schema': path.resolve(__dirname, './src/higlass-schema/index.ts'), '@gosling-lang/gosling-theme': path.resolve(__dirname, './src/gosling-theme/index.ts'), '@gosling-lang/gosling-track': path.resolve(__dirname, './src/tracks/gosling-track/index.ts'), - '@gosling-lang/gosling-genomic-axis': path.resolve(__dirname, './src/tracks/gosling-genomic-axis/index.ts'), + '@gosling-lang/genomic-axis': path.resolve(__dirname, './src/tracks/genomic-axis/index.ts'), '@gosling-lang/brush-circular': path.resolve(__dirname, './src/tracks/brush-circular/index.ts'), '@gosling-lang/brush-linear': path.resolve(__dirname, './src/tracks/brush-linear/index.ts'), '@gosling-lang/dummy-track': path.resolve(__dirname, './src/tracks/dummy-track/index.ts'), From 829142b4e05b8a6832fbcddeac03a08fb59b411c Mon Sep 17 00:00:00 2001 From: etowahadams Date: Thu, 13 Jun 2024 16:17:38 -0400 Subject: [PATCH 041/139] feat: bigwig datafetcher --- demo/App.tsx | 4 +- demo/examples/bigwig-data-example.ts | 243 +++++++++ demo/examples/index.ts | 1 + .../bigwig/bigwig-data-fetcher.ts | 493 +++++++++--------- src/data-fetchers/index.ts | 2 +- src/higlass/utils.ts | 11 +- 6 files changed, 511 insertions(+), 243 deletions(-) create mode 100644 demo/examples/bigwig-data-example.ts diff --git a/demo/App.tsx b/demo/App.tsx index 87fae1410..e21f7627f 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -6,7 +6,8 @@ import { addCircularBrush, addGoslingTrack, addAxisTrack, - addLinearBrush + addLinearBrush, + addBigwig } from './examples'; import './App.css'; @@ -27,6 +28,7 @@ function App() { addGoslingTrack(pixiManager); addAxisTrack(pixiManager); addLinearBrush(pixiManager); + addBigwig(pixiManager); }, []); return ( diff --git a/demo/examples/bigwig-data-example.ts b/demo/examples/bigwig-data-example.ts new file mode 100644 index 000000000..444eee0d3 --- /dev/null +++ b/demo/examples/bigwig-data-example.ts @@ -0,0 +1,243 @@ +import { PixiManager } from '@pixi-manager'; +import { GoslingTrack } from '@gosling-lang/gosling-track'; +import { BigWigDataFetcher } from '@data-fetchers'; +import { signal } from '@preact/signals-core'; +import { panZoom, cursor } from '@gosling-lang/interactors'; + +export function addBigwig(pixiManager: PixiManager) { + const view1Domain = signal<[number, number]>([543317951, 544039951]); + const cursorPosition = signal(0); + + const dataFetcher = new BigWigDataFetcher(excitatory_neurons.spec.data); + // dataFetcher.config.cache = true; // turn on caching + const pos1 = { + x: 0, + y: 300, + width: 400, + height: 40 + }; + new GoslingTrack(excitatory_neurons, dataFetcher, pixiManager.makeContainer(pos1)) + .addInteractor(plot => panZoom(plot, view1Domain)) + .addInteractor(plot => cursor(plot, cursorPosition)); +} + +// bigwig datafetcher +// bar mark +// orange color +const excitatory_neurons = { + id: '3486b662-d79a-4c14-936b-b62d2d0e9205', + siblingIds: ['3486b662-d79a-4c14-936b-b62d2d0e9205'], + showMousePosition: false, + mousePositionColor: '#000000', + name: 'Excitatory neurons', + labelPosition: 'topLeft', + labelShowResolution: false, + labelColor: 'black', + labelBackgroundColor: 'white', + labelBackgroundOpacity: 0.5, + labelTextOpacity: 1, + labelLeftMargin: 1, + labelTopMargin: 1, + labelRightMargin: 0, + labelBottomMargin: 0, + backgroundColor: 'transparent', + spec: { + xDomain: { + chromosome: 'chr3', + interval: [52168000, 52890000] + }, + linkingId: 'detail', + x: { + field: 'position', + type: 'genomic', + axis: 'none', + domain: { + chromosome: 'chr3', + interval: [52168000, 52890000] + } + }, + y: { + field: 'peak', + type: 'quantitative', + axis: 'right' + }, + style: { outline: '#20102F' }, + width: 400, + height: 40, + assembly: 'hg38', + layout: 'linear', + orientation: 'horizontal', + static: false, + zoomLimits: [1, null], + centerRadius: 0.3, + xOffset: 0, + yOffset: 0, + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/ExcitatoryNeurons-insertions_bin100_RIPnorm.bw', + type: 'bigwig', + column: 'position', + value: 'peak' + }, + title: 'Excitatory neurons', + mark: 'bar', + color: { value: '#F29B67' }, + id: '3486b662-d79a-4c14-936b-b62d2d0e9205', + overlayOnPreviousTrack: false + }, + theme: { + base: 'light', + root: { + background: 'white', + titleColor: 'black', + titleBackgroundColor: 'transparent', + titleFontSize: 18, + titleFontFamily: 'Arial', + titleAlign: 'left', + titleFontWeight: 'bold', + subtitleColor: 'gray', + subtitleBackgroundColor: 'transparent', + subtitleFontSize: 16, + subtitleFontFamily: 'Arial', + subtitleFontWeight: 'normal', + subtitleAlign: 'left', + showMousePosition: true, + mousePositionColor: '#000000' + }, + track: { + background: 'transparent', + alternatingBackground: 'transparent', + titleColor: 'black', + titleBackground: 'white', + titleFontSize: 24, + titleAlign: 'left', + outline: 'black', + outlineWidth: 1 + }, + legend: { + position: 'top', + background: 'white', + backgroundOpacity: 0.7, + labelColor: 'black', + labelFontSize: 12, + labelFontWeight: 'normal', + labelFontFamily: 'Arial', + backgroundStroke: '#DBDBDB', + tickColor: 'black' + }, + axis: { + tickColor: 'black', + labelColor: 'black', + labelMargin: 5, + labelExcludeChrPrefix: false, + labelFontSize: 12, + labelFontWeight: 'normal', + labelFontFamily: 'Arial', + baselineColor: 'black', + gridColor: '#E3E3E3', + gridStrokeWidth: 1, + gridStrokeType: 'solid', + gridStrokeDash: [4, 4] + }, + markCommon: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + point: { + color: '#E79F00', + size: 3, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + rect: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + triangle: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + area: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + line: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + bar: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + rule: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 1, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + link: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 1, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + text: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6], + textAnchor: 'middle', + textFontWeight: 'normal' + }, + brush: { + color: 'gray', + size: 1, + stroke: 'black', + strokeWidth: 1, + opacity: 0.3, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + } + } +}; diff --git a/demo/examples/index.ts b/demo/examples/index.ts index db938bfe1..83873ac55 100644 --- a/demo/examples/index.ts +++ b/demo/examples/index.ts @@ -4,3 +4,4 @@ export { addCircularBrush } from './circular-brush-example'; export { addGoslingTrack } from './gosling-track-example'; export { addAxisTrack } from './axis-track-example'; export { addLinearBrush } from './brush-linear-example'; +export { addBigwig } from './bigwig-data-example'; diff --git a/src/data-fetchers/bigwig/bigwig-data-fetcher.ts b/src/data-fetchers/bigwig/bigwig-data-fetcher.ts index f89e7e6a3..c36575b15 100644 --- a/src/data-fetchers/bigwig/bigwig-data-fetcher.ts +++ b/src/data-fetchers/bigwig/bigwig-data-fetcher.ts @@ -9,6 +9,7 @@ import { type CommonDataConfig, RemoteFile } from '../utils'; import type { Feature } from '@gmod/bbi'; import type { ChromInfo, TilesetInfo } from '@higlass/types'; +import { DenseDataExtrema1D, chrToAbs } from '@higlass/utils'; type BigWigDataConfig = BigWigData & CommonDataConfig; @@ -30,289 +31,301 @@ type BigWigHeader = { type ExtendedFeature = Feature & { startAbs: number; endAbs: number }; -function BigWigDataFetcher(HGC: import('@higlass/types').HGC, dataConfig: BigWigDataConfig) { - if (!new.target) { - throw new Error('Uncaught TypeError: Class constructor cannot be invoked without "new"'); - } - - const cls = class BigWigDataFetcherClass { - dataConfig: typeof dataConfig; - bwFileHeader: BigWigHeader | null; - bwFile: BigWig | null; - TILE_SIZE: number; - errorTxt: string; - dataPromises: Promise[]; - chromSizes: ChromInfo & { chrToAbs: (name: string, pos: number) => number }; - assembly?: Assembly; - tilesetInfoLoading?: boolean; - - constructor() { - this.dataConfig = dataConfig; - this.assembly = this.dataConfig.assembly; - this.bwFileHeader = null; - this.bwFile = null; - this.TILE_SIZE = 1024; - - this.errorTxt = ''; - this.dataPromises = []; - - // Prepare chromosome interval information - const chromosomeSizes = computeChromSizes(this.assembly).size; - - const chromosomeCumPositions: ChromInfo['cumPositions'] = []; - const chromosomePositions: Record = {}; - let prevEndPosition = 0; - - Object.keys(computeChromSizes(this.assembly).size).forEach((chrStr, i) => { - const positionInfo = { - id: i, - chr: chrStr, - pos: prevEndPosition - }; +export class BigWigDataFetcher { + dataConfig: BigWigDataConfig; + bwFileHeader: BigWigHeader | null; + bwFile: BigWig | null; + TILE_SIZE: number; + errorTxt: string; + dataPromises: Promise[]; + chromSizes: ChromInfo & { + chrToAbs: (name: string, pos: number) => number; + }; + assembly?: Assembly; + tilesetInfoLoading?: boolean; + config = { + type: 'bigwig', + cache: false // whether to cache the data after fetching it + }; + cachedTiles: Record = {}; + + constructor(dataConfig: BigWigDataConfig) { + this.dataConfig = dataConfig; + this.assembly = this.dataConfig.assembly; + this.bwFileHeader = null; + this.bwFile = null; + this.TILE_SIZE = 1024; + + this.errorTxt = ''; + this.dataPromises = []; + + // Prepare chromosome interval information + const chromosomeSizes = computeChromSizes(this.assembly).size; + + const chromosomeCumPositions: ChromInfo['cumPositions'] = []; + const chromosomePositions: Record = {}; + let prevEndPosition = 0; + + Object.keys(computeChromSizes(this.assembly).size).forEach((chrStr, i) => { + const positionInfo = { + id: i, + chr: chrStr, + pos: prevEndPosition + }; - chromosomeCumPositions.push(positionInfo); - chromosomePositions[chrStr] = positionInfo; + chromosomeCumPositions.push(positionInfo); + chromosomePositions[chrStr] = positionInfo; - prevEndPosition += computeChromSizes(this.assembly).size[chrStr]; - }); - this.chromSizes = { - chrToAbs: (chrom, chromPos) => this.chromSizes.chrPositions[chrom].pos + chromPos, - cumPositions: chromosomeCumPositions, - chrPositions: chromosomePositions, - totalLength: prevEndPosition, - chromLengths: chromosomeSizes - }; + prevEndPosition += computeChromSizes(this.assembly).size[chrStr]; + }); + this.chromSizes = { + chrToAbs: (chrom, chromPos) => this.chromSizes.chrPositions[chrom].pos + chromPos, + cumPositions: chromosomeCumPositions, + chrPositions: chromosomePositions, + totalLength: prevEndPosition, + chromLengths: chromosomeSizes + }; - this.dataPromises.push(this.loadBBI(dataConfig)); - } + this.dataPromises.push(this.loadBBI(dataConfig)); + } - async loadBBI(dataConfig: BigWigDataConfig) { - if (dataConfig.url) { - this.bwFile = new BigWig({ - filehandle: new RemoteFile(dataConfig.url, { overrides: dataConfig.urlFetchOptions }) - }); - return this.bwFile.getHeader().then((h: BigWigHeader) => { - this.bwFileHeader = h; - }); - } else { - console.error('Please enter a "url" field to the data config'); - return null; - } + async loadBBI(dataConfig: BigWigDataConfig) { + if (dataConfig.url) { + this.bwFile = new BigWig({ + filehandle: new RemoteFile(dataConfig.url, { + overrides: dataConfig.urlFetchOptions + }) + }); + return this.bwFile.getHeader().then((h: BigWigHeader) => { + this.bwFileHeader = h; + }); + } else { + console.error('Please enter a "url" field to the data config'); + return null; } + } - tilesetInfo(callback?: (info: TilesetInfo | { error: string }) => void) { - this.tilesetInfoLoading = true; - - return Promise.all(this.dataPromises) - .then(() => { - this.tilesetInfoLoading = false; + tilesetInfo(callback?: (info: TilesetInfo | { error: string }) => void) { + this.tilesetInfoLoading = true; - const totalLength = this.chromSizes.totalLength; + return Promise.all(this.dataPromises) + .then(() => { + this.tilesetInfoLoading = false; - const retVal = { - tile_size: this.TILE_SIZE, - max_zoom: Math.ceil(Math.log(totalLength / this.TILE_SIZE) / Math.log(2)), - max_width: 2 ** Math.ceil(Math.log(totalLength) / Math.log(2)), - min_pos: [0], - max_pos: [totalLength] - }; + const totalLength = this.chromSizes.totalLength; - if (callback) { - callback(retVal); - } + const retVal = { + tile_size: this.TILE_SIZE, + max_zoom: Math.ceil(Math.log(totalLength / this.TILE_SIZE) / Math.log(2)), + max_width: 2 ** Math.ceil(Math.log(totalLength) / Math.log(2)), + min_pos: [0], + max_pos: [totalLength] + }; - return retVal; - }) - .catch(err => { - this.tilesetInfoLoading = false; - - console.error(err); - - if (callback) { - callback({ - error: `Error parsing bigwig: ${err}` - }); - } - return null; - }); - } + if (callback) { + callback(retVal); + } - fetchTilesDebounced(receivedTiles: (tiles: Record) => void, tileIds: string[]) { - const tiles: Record = {}; - const validTileIds: string[] = []; - const tilePromises = []; + return retVal; + }) + .catch(err => { + this.tilesetInfoLoading = false; - for (const tileId of tileIds) { - const parts = tileId.split('.'); - const z = parseInt(parts[0], 10); - const x = parseInt(parts[1], 10); + console.error(err); - if (Number.isNaN(x) || Number.isNaN(z)) { - console.warn('Invalid tile zoom or position:', z, x); - continue; + if (callback) { + callback({ + error: `Error parsing bigwig: ${err}` + }); } + return null; + }); + } - validTileIds.push(tileId); - tilePromises.push(this.tile(z, x)); - } - - Promise.all(tilePromises).then(values => { - for (let i = 0; i < values.length; i++) { - const validTileId = validTileIds[i]; - tiles[validTileId] = values[i]; - tiles[validTileId].tilePositionId = validTileId; - } + fetchTilesDebounced(receivedTiles: (tiles: Record) => void, tileIds: string[]) { + const tiles: Record = {}; + const validTileIds: string[] = []; + const tilePromises = []; - receivedTiles(tiles); + // If the tile is already cached, return it immediately + if (tileIds.every(tileId => this.cachedTiles[tileId])) { + tileIds.forEach(tileId => { + tiles[tileId] = this.cachedTiles[tileId]; }); - // tiles = tileResponseToData(tiles, null, tileIds); + receivedTiles(tiles); return tiles; } + // Otherwise, fetch the tile and cache it + for (const tileId of tileIds) { + const parts = tileId.split('.'); + const z = parseInt(parts[0], 10); + const x = parseInt(parts[1], 10); + + if (Number.isNaN(x) || Number.isNaN(z)) { + console.warn('Invalid tile zoom or position:', z, x); + continue; + } - async tile(z: number, x: number) { - const tsInfo = (await this.tilesetInfo())!; - const tileWidth = +tsInfo.max_width / 2 ** +z; - - const recordPromises: Promise[] = []; + validTileIds.push(tileId); + tilePromises.push(this.tile(z, x)); + } - const tile: Partial = { - tilePos: [x], - tileId: `bigwig.${z}.${x}`, - zoomLevel: z - }; + Promise.all(tilePromises).then(values => { + for (let i = 0; i < values.length; i++) { + const validTileId = validTileIds[i]; + tiles[validTileId] = values[i]; + tiles[validTileId].tilePositionId = validTileId; + // Cache the tile + if (this.config.cache) this.cachedTiles[validTileId] = values[i]; + } - // get the bounds of the tile - const minXOriginal = tsInfo.min_pos[0] + x * tileWidth; - let minX = minXOriginal; - const maxX = tsInfo.min_pos[0] + (x + 1) * tileWidth; + receivedTiles(tiles); + }); + // tiles = tileResponseToData(tiles, null, tileIds); + return tiles; + } - const basesPerPixel = this.determineScale(minX, maxX); - const basesPerBin = (maxX - minX) / this.TILE_SIZE; + async tile(z: number, x: number) { + const tsInfo = (await this.tilesetInfo())!; + const tileWidth = +tsInfo.max_width / 2 ** +z; - const binStarts: number[] = []; - for (let i = 0; i < this.TILE_SIZE; i++) { - binStarts.push(minX + i * basesPerBin); - } + const recordPromises: Promise[] = []; - const { chromLengths, cumPositions } = this.chromSizes; + const tile: Partial = { + tilePos: [x], + tileId: `bigwig.${z}.${x}`, + zoomLevel: z + }; - cumPositions.forEach(cumPos => { - const chromName = cumPos.chr; - const chromStart = cumPos.pos; - const chromEnd = cumPos.pos + chromLengths[chromName]; + // get the bounds of the tile + const minXOriginal = tsInfo.min_pos[0] + x * tileWidth; + let minX = minXOriginal; + const maxX = tsInfo.min_pos[0] + (x + 1) * tileWidth; - let startPos, endPos; + const basesPerPixel = this.determineScale(minX, maxX); + const basesPerBin = (maxX - minX) / this.TILE_SIZE; - if (chromStart <= minX && minX < chromEnd) { - // start of the visible region is within this chromosome + const binStarts: number[] = []; + for (let i = 0; i < this.TILE_SIZE; i++) { + binStarts.push(minX + i * basesPerBin); + } - if (maxX > chromEnd) { - // the visible region extends beyond the end of this chromosome - // fetch from the start until the end of the chromosome - startPos = minX - chromStart; - endPos = chromEnd - chromStart; - recordPromises.push( - this.bwFile!.getFeatures(chromName, startPos, endPos, { + const { chromLengths, cumPositions } = this.chromSizes; + + cumPositions.forEach(cumPos => { + const chromName = cumPos.chr; + const chromStart = cumPos.pos; + const chromEnd = cumPos.pos + chromLengths[chromName]; + + let startPos, endPos; + + if (chromStart <= minX && minX < chromEnd) { + // start of the visible region is within this chromosome + + if (maxX > chromEnd) { + // the visible region extends beyond the end of this chromosome + // fetch from the start until the end of the chromosome + startPos = minX - chromStart; + endPos = chromEnd - chromStart; + recordPromises.push( + this.bwFile!.getFeatures(chromName, startPos, endPos, { + scale: 1 / basesPerPixel + }).then(values => { + values.forEach((v: Feature & { startAbs?: number; endAbs?: number }) => { + v['startAbs'] = chrToAbs(chromName, v.start, this.chromSizes); + v['endAbs'] = chrToAbs(chromName, v.end, this.chromSizes); + }); + return values as (Feature & { + startAbs: number; + endAbs: number; + })[]; + }) + ); + + minX = chromEnd; + } else { + startPos = Math.floor(minX - chromStart); + endPos = Math.ceil(maxX - chromStart); + if (!this.bwFile) return; + recordPromises.push( + this.bwFile + .getFeatures(chromName, startPos, endPos, { scale: 1 / basesPerPixel - }).then(values => { + }) + .then(values => { values.forEach((v: Feature & { startAbs?: number; endAbs?: number }) => { - v['startAbs'] = HGC.utils.chrToAbs(chromName, v.start, this.chromSizes); - v['endAbs'] = HGC.utils.chrToAbs(chromName, v.end, this.chromSizes); + v['startAbs'] = chrToAbs(chromName, v.start, this.chromSizes); + v['endAbs'] = chrToAbs(chromName, v.end, this.chromSizes); }); - return values as (Feature & { startAbs: number; endAbs: number })[]; + return values as (Feature & { + startAbs: number; + endAbs: number; + })[]; }) - ); - - minX = chromEnd; - } else { - startPos = Math.floor(minX - chromStart); - endPos = Math.ceil(maxX - chromStart); - if (!this.bwFile) return; - recordPromises.push( - this.bwFile - .getFeatures(chromName, startPos, endPos, { - scale: 1 / basesPerPixel - }) - .then(values => { - values.forEach((v: Feature & { startAbs?: number; endAbs?: number }) => { - v['startAbs'] = HGC.utils.chrToAbs(chromName, v.start, this.chromSizes); - v['endAbs'] = HGC.utils.chrToAbs(chromName, v.end, this.chromSizes); - }); - return values as (Feature & { startAbs: number; endAbs: number })[]; - }) - ); - return; - } + ); + return; } - }); - - return Promise.all(recordPromises).then(v => { - const values = v.flat(); - - const dense: (number | null)[] = []; - for (let i = 0; i < this.TILE_SIZE; i++) { - dense.push(null); - } - - // Currently we use the same binning strategy in all cases (basesPerBin =>< basesPerBinInFile) - binStarts.forEach((curStart, index) => { - if (curStart < minXOriginal || curStart > maxX) { - return; - } - const filtered = values - .filter(v => { - return curStart >= v.startAbs && curStart < v.endAbs; - }) - .map(v => v.score); - dense[index] = filtered.length > 0 ? filtered[0] : null; - }); - - const dde = new HGC.utils.DenseDataExtrema1D(dense); - // @ts-expect-error Math.min() allows `null` but results in min - tile.min_value = Math.min(...dense); - // @ts-expect-error Math.max() allows `null` but results in min - tile.max_value = Math.max(...dense); - tile.dense = dense; - tile.denseDataExtrema = dde; - tile.minNonZero = dde.minNonZeroInTile; - tile.maxNonZero = dde.maxNonZeroInTile; - return tile as Tile; - }); - } + } + }); - // We never want to request more than 1024 * 20 elements from the file. - determineScale(minX: number, maxX: number) { - const reductionLevels = [1]; - const numRequestedElements = maxX - minX; + return Promise.all(recordPromises).then(v => { + const values = v.flat(); - if (!this.bwFileHeader) { - throw Error('no bigwig header'); + const dense: (number | null)[] = []; + for (let i = 0; i < this.TILE_SIZE; i++) { + dense.push(null); } - this.bwFileHeader.zoomLevels.forEach(z => { - reductionLevels.push(z.reductionLevel); + // Currently we use the same binning strategy in all cases (basesPerBin =>< basesPerBinInFile) + binStarts.forEach((curStart, index) => { + if (curStart < minXOriginal || curStart > maxX) { + return; + } + const filtered = values + .filter(v => { + return curStart >= v.startAbs && curStart < v.endAbs; + }) + .map(v => v.score); + dense[index] = filtered.length > 0 ? filtered[0] : null; }); - let level: number | undefined; - reductionLevels.forEach(rl => { - if (level) return; // we found one + const dde = new DenseDataExtrema1D(dense); + // @ts-expect-error Math.min() allows `null` but results in min + tile.min_value = Math.min(...dense); + // @ts-expect-error Math.max() allows `null` but results in min + tile.max_value = Math.max(...dense); + tile.dense = dense; + tile.denseDataExtrema = dde; + tile.minNonZero = dde.minNonZeroInTile; + tile.maxNonZero = dde.maxNonZeroInTile; + return tile as Tile; + }); + } - const numElementsFromFile = numRequestedElements / rl; - if (numElementsFromFile <= this.TILE_SIZE * 20) { - level = rl; - } - }); + // We never want to request more than 1024 * 20 elements from the file. + determineScale(minX: number, maxX: number) { + const reductionLevels = [1]; + const numRequestedElements = maxX - minX; - // return the highest reductionLevel, if we could not find anything better - return level || reductionLevels.slice(-1)[0]; + if (!this.bwFileHeader) { + throw Error('no bigwig header'); } - }; - return new cls(); -} + this.bwFileHeader.zoomLevels.forEach(z => { + reductionLevels.push(z.reductionLevel); + }); -BigWigDataFetcher.config = { - type: 'bigwig' -}; + let level: number | undefined; + reductionLevels.forEach(rl => { + if (level) return; // we found one -export default BigWigDataFetcher; + const numElementsFromFile = numRequestedElements / rl; + if (numElementsFromFile <= this.TILE_SIZE * 20) { + level = rl; + } + }); + + // return the highest reductionLevel, if we could not find anything better + return level || reductionLevels.slice(-1)[0]; + } +} diff --git a/src/data-fetchers/index.ts b/src/data-fetchers/index.ts index eda39a021..4e59e5ee3 100644 --- a/src/data-fetchers/index.ts +++ b/src/data-fetchers/index.ts @@ -1,6 +1,6 @@ export { default as BamDataFetcher } from './bam/bam-data-fetcher'; export { default as VcfDataFetcher } from './vcf/vcf-data-fetcher'; -export { default as BigWigDataFetcher } from './bigwig/bigwig-data-fetcher'; +export { BigWigDataFetcher } from './bigwig/bigwig-data-fetcher'; export { default as CsvDataFetcher } from './csv/csv-data-fetcher'; export { default as JsonDataFetcher } from './json/json-data-fetcher'; export { default as GffDataFetcher } from './gff/gff-data-fetcher'; diff --git a/src/higlass/utils.ts b/src/higlass/utils.ts index d919dc3f8..2dbacc52f 100644 --- a/src/higlass/utils.ts +++ b/src/higlass/utils.ts @@ -1 +1,10 @@ -export { fakePubSub, absToChr, colorToHex, pixiTextToSvg, svgLine, showMousePosition } from './higlass-vendored'; +export { + fakePubSub, + absToChr, + chrToAbs, + colorToHex, + pixiTextToSvg, + svgLine, + showMousePosition, + DenseDataExtrema1D +} from './higlass-vendored'; From ce315632fb8cc8384b9673bf5d7655d2d670a5b5 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Thu, 13 Jun 2024 16:32:59 -0400 Subject: [PATCH 042/139] fix: update datafetchers --- src/data-fetchers/index.ts | 4 +- src/data-fetchers/json/json-data-fetcher.ts | 269 ++++++++++---------- 2 files changed, 139 insertions(+), 134 deletions(-) diff --git a/src/data-fetchers/index.ts b/src/data-fetchers/index.ts index 4e59e5ee3..d469217b1 100644 --- a/src/data-fetchers/index.ts +++ b/src/data-fetchers/index.ts @@ -1,8 +1,8 @@ export { default as BamDataFetcher } from './bam/bam-data-fetcher'; export { default as VcfDataFetcher } from './vcf/vcf-data-fetcher'; export { BigWigDataFetcher } from './bigwig/bigwig-data-fetcher'; -export { default as CsvDataFetcher } from './csv/csv-data-fetcher'; -export { default as JsonDataFetcher } from './json/json-data-fetcher'; +export { CsvDataFetcherClass as CsvDataFetcher } from './csv/csv-data-fetcher'; +export { JsonDataFetcherClass as JsonDataFetcher } from './json/json-data-fetcher'; export { default as GffDataFetcher } from './gff/gff-data-fetcher'; export { default as BedDataFetcher } from './bed/bed-data-fetcher'; export { type TabularDataFetcher } from './utils'; diff --git a/src/data-fetchers/json/json-data-fetcher.ts b/src/data-fetchers/json/json-data-fetcher.ts index f8279c98b..b803b918b 100644 --- a/src/data-fetchers/json/json-data-fetcher.ts +++ b/src/data-fetchers/json/json-data-fetcher.ts @@ -8,165 +8,170 @@ type CsvDataConfig = JsonData & CommonDataConfig; /** * HiGlass data fetcher specific for Gosling which ultimately will accept any types of data other than JSON values. */ -function JsonDataFetcher(HGC: any, ...args: any): any { - if (!new.target) { - throw new Error('Uncaught TypeError: Class constructor cannot be invoked without "new"'); - } - - class JsonDataFetcherClass { - private dataConfig: CsvDataConfig; - // @ts-ignore - private tilesetInfoLoading: boolean; - private chromSizes: any; - private values: any; - private assembly: Assembly; - - constructor(params: any[]) { - const [dataConfig] = params; - this.dataConfig = dataConfig; - this.tilesetInfoLoading = false; - this.assembly = this.dataConfig.assembly; - - if (!dataConfig.values) { - console.error('Please provide `values` of the JSON data'); - return; - } - - // Prepare chromosome interval information - const chromosomeSizes: { [k: string]: number } = computeChromSizes(this.assembly).size; - const chromosomeCumPositions: { id: number; chr: string; pos: number }[] = []; - const chromosomePositions: { [k: string]: { id: number; chr: string; pos: number } } = {}; - let prevEndPosition = 0; - - Object.keys(computeChromSizes(this.assembly).size).forEach((chrStr, i) => { - const positionInfo = { - id: i, - chr: chrStr, - pos: prevEndPosition - }; - - chromosomeCumPositions.push(positionInfo); - chromosomePositions[chrStr] = positionInfo; +export class JsonDataFetcherClass { + private dataConfig: CsvDataConfig; + // @ts-ignore + private tilesetInfoLoading: boolean; + private chromSizes: any; + private values: any; + private assembly: Assembly; + + constructor(params: any[]) { + const [dataConfig] = params; + this.dataConfig = dataConfig; + this.tilesetInfoLoading = false; + this.assembly = this.dataConfig.assembly; + + if (!dataConfig.values) { + console.error('Please provide `values` of the JSON data'); + return; + } - prevEndPosition += computeChromSizes(this.assembly).size[chrStr]; - }); - this.chromSizes = { - chrToAbs: (chrom: string, chromPos: number) => this.chromSizes.chrPositions[chrom].pos + chromPos, - cumPositions: chromosomeCumPositions, - chrPositions: chromosomePositions, - totalLength: prevEndPosition, - chromLengths: chromosomeSizes + // Prepare chromosome interval information + const chromosomeSizes: { [k: string]: number } = computeChromSizes(this.assembly).size; + const chromosomeCumPositions: { id: number; chr: string; pos: number }[] = []; + const chromosomePositions: { [k: string]: { id: number; chr: string; pos: number } } = {}; + let prevEndPosition = 0; + + Object.keys(computeChromSizes(this.assembly).size).forEach((chrStr, i) => { + const positionInfo = { + id: i, + chr: chrStr, + pos: prevEndPosition }; - const { chromosomeField, genomicFields, genomicFieldsToConvert } = this.dataConfig; - this.values = dataConfig.values.map((row: any) => { - try { - if (genomicFieldsToConvert) { - // This spec is used when multiple chromosomes are stored in a single row - genomicFieldsToConvert.forEach(chromMap => { - const genomicFields = chromMap.genomicFields; - const chromName = sanitizeChrName(row[chromMap.chromosomeField], this.assembly) as string; - - genomicFields.forEach((positionCol: string) => { - const chromPosition = row[positionCol] as string; - row[positionCol] = String(this.chromSizes.chrToAbs(chromName, chromPosition)); - }); - }); - } else if (chromosomeField && genomicFields) { + chromosomeCumPositions.push(positionInfo); + chromosomePositions[chrStr] = positionInfo; + + prevEndPosition += computeChromSizes(this.assembly).size[chrStr]; + }); + this.chromSizes = { + chrToAbs: (chrom: string, chromPos: number) => this.chromSizes.chrPositions[chrom].pos + chromPos, + cumPositions: chromosomeCumPositions, + chrPositions: chromosomePositions, + totalLength: prevEndPosition, + chromLengths: chromosomeSizes + }; + + const { chromosomeField, genomicFields, genomicFieldsToConvert } = this.dataConfig; + this.values = dataConfig.values.map((row: any) => { + try { + if (genomicFieldsToConvert) { + // This spec is used when multiple chromosomes are stored in a single row + genomicFieldsToConvert.forEach(chromMap => { + const genomicFields = chromMap.genomicFields; + const chromName = sanitizeChrName(row[chromMap.chromosomeField], this.assembly) as string; + genomicFields.forEach((positionCol: string) => { const chromPosition = row[positionCol] as string; - const chromName = sanitizeChrName(row[chromosomeField], this.assembly) as string; row[positionCol] = String(this.chromSizes.chrToAbs(chromName, chromPosition)); }); - } - return row; - } catch { - // skip the rows that had errors in them - return undefined; + }); + } else if (chromosomeField && genomicFields) { + genomicFields.forEach((positionCol: string) => { + const chromPosition = row[positionCol] as string; + const chromName = sanitizeChrName(row[chromosomeField], this.assembly) as string; + row[positionCol] = String(this.chromSizes.chrToAbs(chromName, chromPosition)); + }); } - }); - } - - tilesetInfo(callback?: any) { - this.tilesetInfoLoading = false; - - const TILE_SIZE = 1024; - const totalLength = this.chromSizes.totalLength; - const retVal = { - tile_size: TILE_SIZE, - max_zoom: Math.ceil(Math.log(totalLength / TILE_SIZE) / Math.log(2)), - max_width: totalLength, - min_pos: [0, 0], - max_pos: [totalLength, totalLength] - }; - - if (callback) { - callback(retVal); + return row; + } catch { + // skip the rows that had errors in them + return undefined; } + }); + } - return retVal; + tilesetInfo(callback?: any) { + this.tilesetInfoLoading = false; + + const TILE_SIZE = 1024; + const totalLength = this.chromSizes.totalLength; + const retVal = { + tile_size: TILE_SIZE, + max_zoom: Math.ceil(Math.log(totalLength / TILE_SIZE) / Math.log(2)), + max_width: totalLength, + min_pos: [0, 0], + max_pos: [totalLength, totalLength] + }; + + if (callback) { + callback(retVal); } - fetchTilesDebounced(receivedTiles: any, tileIds: any) { - const tiles: { [k: string]: any } = {}; + return retVal; + } - const validTileIds: any[] = []; - const tilePromises = []; + fetchTilesDebounced(receivedTiles: any, tileIds: any) { + const tiles: { [k: string]: any } = {}; - for (const tileId of tileIds) { - const parts = tileId.split('.'); - const z = parseInt(parts[0], 10); - const x = parseInt(parts[1], 10); - const y = parseInt(parts[2], 10); + const validTileIds: any[] = []; + const tilePromises = []; - if (Number.isNaN(x) || Number.isNaN(z)) { - console.warn('[Gosling Data Fetcher] Invalid tile zoom or position:', z, x, y); - continue; - } + for (const tileId of tileIds) { + const parts = tileId.split('.'); + const z = parseInt(parts[0], 10); + const x = parseInt(parts[1], 10); + const y = parseInt(parts[2], 10); - validTileIds.push(tileId); - tilePromises.push(this.tile(z, x, y)); + if (Number.isNaN(x) || Number.isNaN(z)) { + console.warn('[Gosling Data Fetcher] Invalid tile zoom or position:', z, x, y); + continue; } - Promise.all(tilePromises).then(values => { - values.forEach((value, i) => { - const validTileId = validTileIds[i]; - tiles[validTileId] = value; - tiles[validTileId].tilePositionId = validTileId; - }); - receivedTiles(tiles); - }); - - return tiles; + validTileIds.push(tileId); + tilePromises.push(this.tile(z, x, y)); } - tile(z: any, x: any, y: any) { - const tsInfo = this.tilesetInfo(); - const tileWidth = +tsInfo.max_width / 2 ** +z; + Promise.all(tilePromises).then(values => { + values.forEach((value, i) => { + const validTileId = validTileIds[i]; + tiles[validTileId] = value; + tiles[validTileId].tilePositionId = validTileId; + }); + receivedTiles(tiles); + }); - // get the bounds of the tile - const minX = tsInfo.min_pos[0] + x * tileWidth; - const maxX = tsInfo.min_pos[0] + (x + 1) * tileWidth; + return tiles; + } - // filter the data so that visible data is sent to tracks - let tabularData = filterUsingGenoPos(this.values, [minX, maxX], this.dataConfig); + tile(z: any, x: any, y: any) { + const tsInfo = this.tilesetInfo(); + const tileWidth = +tsInfo.max_width / 2 ** +z; - // sample the data to make it managable for visualization components - const sizeLimit = this.dataConfig.sampleLength ?? 1000; - if (sizeLimit < tabularData.length) { - tabularData = sampleSize(tabularData, sizeLimit); - } + // get the bounds of the tile + const minX = tsInfo.min_pos[0] + x * tileWidth; + const maxX = tsInfo.min_pos[0] + (x + 1) * tileWidth; - return { - tabularData, - server: null, - tilePos: [x, y], - zoomLevel: z - }; + // filter the data so that visible data is sent to tracks + let tabularData = filterUsingGenoPos(this.values, [minX, maxX], this.dataConfig); + + // sample the data to make it managable for visualization components + const sizeLimit = this.dataConfig.sampleLength ?? 1000; + if (sizeLimit < tabularData.length) { + tabularData = sampleSize(tabularData, sizeLimit); } + + return { + tabularData, + server: null, + tilePos: [x, y], + zoomLevel: z + }; + } +} + +function JsonDataFetcher( + _HGC: import('@higlass/types').HGC, + dataConfig: any, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _pubsub: Record +): JsonDataFetcherClass { + if (!new.target) { + throw new Error('Uncaught TypeError: Class constructor cannot be invoked without "new"'); } - return new JsonDataFetcherClass(args); + return new JsonDataFetcherClass(dataConfig); } JsonDataFetcher.config = { From 2c2fc8e375ba8d72c7f09580d877ff59c4a0794a Mon Sep 17 00:00:00 2001 From: etowahadams Date: Thu, 13 Jun 2024 16:56:14 -0400 Subject: [PATCH 043/139] feat: compile --- demo/App.tsx | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index e21f7627f..81d380c8e 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -9,10 +9,10 @@ import { addLinearBrush, addBigwig } from './examples'; +import { compile } from '../src/compiler/compile'; +import { getTheme } from '../src/core/utils/theme'; import './App.css'; -import { add } from 'lodash-es'; - function App() { const [fps, setFps] = useState(120); @@ -29,6 +29,17 @@ function App() { addAxisTrack(pixiManager); addLinearBrush(pixiManager); addBigwig(pixiManager); + + const callback = (hg, size, gs, tracksAndViews, idTable) => { + console.warn(hg); + console.warn(size); + console.warn(gs); + console.warn(tracksAndViews); + console.warn(idTable); + }; + + // Compile the spec + compile(spec, callback, [], getTheme('light'), { containerSize: { width: 600, height: 600 } }); }, []); return ( @@ -43,3 +54,44 @@ function App() { } export default App; + +const spec = { + title: 'Basic Marks: line', + subtitle: 'Tutorial Examples', + tracks: [ + { + layout: 'linear', + width: 800, + height: 180, + data: { + url: 'https://resgen.io/api/v1/tileset_info/?d=UvVPeLHuRDiYA3qwFlm7xQ', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1'] + }, + mark: 'line', + x: { field: 'position', type: 'genomic', axis: 'bottom' }, + y: { field: 'peak', type: 'quantitative', axis: 'right' }, + size: { value: 2 } + }, + { + layout: 'linear', + width: 800, + height: 180, + data: { + url: 'https://resgen.io/api/v1/tileset_info/?d=UvVPeLHuRDiYA3qwFlm7xQ', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1'] + }, + mark: 'bar', + x: { field: 'position', type: 'genomic', axis: 'bottom' }, + y: { field: 'peak', type: 'quantitative', axis: 'right' }, + size: { value: 2 } + } + ] +}; From 5ba3607e52135b0e0ed01bb565fc2d66a5587e0d Mon Sep 17 00:00:00 2001 From: etowahadams Date: Fri, 14 Jun 2024 15:12:33 -0400 Subject: [PATCH 044/139] feat: add basic layout Co-authored-by: SEHI L'YI --- demo/App.tsx | 46 +++-- demo/trackInfoToCanvas.ts | 286 ++++++++++++++++++++++++++ src/compiler/compile.ts | 6 +- src/compiler/create-higlass-models.ts | 4 +- 4 files changed, 322 insertions(+), 20 deletions(-) create mode 100644 demo/trackInfoToCanvas.ts diff --git a/demo/App.tsx b/demo/App.tsx index 81d380c8e..c6bbcf3c2 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -13,6 +13,10 @@ import { compile } from '../src/compiler/compile'; import { getTheme } from '../src/core/utils/theme'; import './App.css'; +import type { HiGlassSpec } from '@gosling-lang/higlass-schema'; +import { trackInfoToCanvas } from './trackInfoToCanvas'; +import type { TrackInfo } from 'src/compiler/bounding-box'; + function App() { const [fps, setFps] = useState(120); @@ -22,24 +26,33 @@ function App() { plotElement.innerHTML = ''; // Initialize the PixiManager. This will be used to get containers and overlay divs for the plots const pixiManager = new PixiManager(1000, 600, plotElement, setFps); - addTextTrack(pixiManager); - addDummyTrack(pixiManager); - addCircularBrush(pixiManager); - addGoslingTrack(pixiManager); - addAxisTrack(pixiManager); - addLinearBrush(pixiManager); - addBigwig(pixiManager); + // addTextTrack(pixiManager); + // addDummyTrack(pixiManager); + // addCircularBrush(pixiManager); + // addGoslingTrack(pixiManager); + // addAxisTrack(pixiManager); + // addLinearBrush(pixiManager); + // addBigwig(pixiManager); - const callback = (hg, size, gs, tracksAndViews, idTable) => { - console.warn(hg); - console.warn(size); - console.warn(gs); - console.warn(tracksAndViews); - console.warn(idTable); + const callback = ( + hg: HiGlassSpec, + size, + gs, + tracksAndViews, + idTable, + trackInfos: TrackInfo[], + theme: Require + ) => { + // console.warn(hg); + // console.warn(idTable); + // console.warn(tracksAndViews); + // drawFromHgSpec(hg, pixiManager); + console.warn(trackInfos); + trackInfoToCanvas(trackInfos, pixiManager, theme); }; // Compile the spec - compile(spec, callback, [], getTheme('light'), { containerSize: { width: 600, height: 600 } }); + compile(spec, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); }, []); return ( @@ -58,9 +71,10 @@ export default App; const spec = { title: 'Basic Marks: line', subtitle: 'Tutorial Examples', + layout: 'circular', tracks: [ { - layout: 'linear', + layout: 'circular', width: 800, height: 180, data: { @@ -77,7 +91,7 @@ const spec = { size: { value: 2 } }, { - layout: 'linear', + layout: 'circular', width: 800, height: 180, data: { diff --git a/demo/trackInfoToCanvas.ts b/demo/trackInfoToCanvas.ts new file mode 100644 index 000000000..b58c99ca0 --- /dev/null +++ b/demo/trackInfoToCanvas.ts @@ -0,0 +1,286 @@ +import type { PixiManager } from '@pixi-manager'; +import { TextTrack, type TextTrackOptions } from '@gosling-lang/text-track'; +import { DummyTrack, type DummyTrackOptions } from '@gosling-lang/dummy-track'; +import { GoslingTrack } from '@gosling-lang/gosling-track'; +import { AxisTrack, type AxisTrackOptions } from '@gosling-lang/genomic-axis'; +import { signal } from '@preact/signals-core'; +import { DataFetcher } from '@higlass/datafetcher'; +import { fakePubSub } from '@higlass/utils'; +import { BigWigDataFetcher } from '@data-fetchers'; +import { cursor, panZoom } from '@gosling-lang/interactors'; +import type { TrackInfo } from '../src/compiler/bounding-box'; +import { IsChannelDeep, IsDummyTrack, IsXAxis, type AxisPosition, type Track } from '@gosling-lang/gosling-schema'; +import type { CompleteThemeDeep } from '../src/core/utils/theme'; +import { resolveSuperposedTracks } from '../src/core/utils/overlay'; +import type { GoslingTrackOptions } from '../src/tracks/gosling-track/gosling-track'; +import { HIGLASS_AXIS_SIZE } from '../src/compiler/higlass-model'; + +function getTextTrackOptions(spec: Track, theme: Required): TextTrackOptions { + return { + backgroundColor: theme.root.titleBackgroundColor, + textColor: theme.root.titleColor, + fontSize: theme.root.titleFontSize ?? 18, + fontWeight: theme.root.titleFontWeight, + fontFamily: theme.root.titleFontFamily, + offsetY: 0, + align: theme.root.titleAlign, + text: spec.title + }; +} + +function getGoslingTrackOptions(spec: Track, theme: Required): GoslingTrackOptions { + return { + spec: spec, + id: '9f4abc56-cb8d-4494-a9ca-56086ab28de2', + siblingIds: ['9f4abc56-cb8d-4494-a9ca-56086ab28de2'], + showMousePosition: true, + mousePositionColor: '#000000', + name: spec.title, + labelPosition: 'topLeft', + labelShowResolution: false, + labelColor: 'black', + labelBackgroundColor: 'white', + labelBackgroundOpacity: 0.5, + labelTextOpacity: 1, + labelLeftMargin: 1, + labelTopMargin: 1, + labelRightMargin: 0, + labelBottomMargin: 0, + backgroundColor: 'transparent', + theme + }; +} + +function getDataFetcher(spec: Track) { + if (!('data' in spec)) { + console.warn('No data in the track spec', spec); + } + if (spec.data.type == 'multivec') { + const url = spec.data.url; + const server = url.split('/').slice(0, -2).join('/'); + const tilesetUid = url.split('=').slice(-1)[0]; + console.warn('server', server, 'tilesetUid', tilesetUid); + return new DataFetcher({ server, tilesetUid }, fakePubSub); + } + if (spec.data.type == 'bigwig') { + return new BigWigDataFetcher(spec.data); + } +} + +function getDummyTrackOptions(spec: Track, theme: Required): DummyTrackOptions { + // TODO + return spec; +} + +export function trackInfoToCanvas( + trackInfos: TrackInfo[], + pixiManager: PixiManager, + theme: Required +) { + const domain = signal([0, 100000000]); + trackInfos.forEach(trackInfo => { + const { track, boundingBox } = trackInfo; + + const resolvedSpecs = resolveSuperposedTracks(track); + const firstResolvedSpec = resolvedSpecs[0]; + + boundingBox.width -= + firstResolvedSpec.layout !== 'circular' && + firstResolvedSpec.orientation === 'vertical' && + IsXAxis(firstResolvedSpec) + ? HIGLASS_AXIS_SIZE + : 0; + + boundingBox.height -= + firstResolvedSpec.layout !== 'circular' && + firstResolvedSpec.orientation === 'horizontal' && + IsXAxis(firstResolvedSpec) + ? HIGLASS_AXIS_SIZE + : 0; + if (track.mark === '_header') { + const textTrackOptions = getTextTrackOptions(track, theme); + new TextTrack(textTrackOptions, pixiManager.makeContainer(boundingBox)); + // subtitle + } else if (IsDummyTrack(track)) { + const options = getDummyTrackOptions(track, theme); + new DummyTrack(options, pixiManager.makeContainer(boundingBox).overlayDiv); + } else { + const goslingTrackOptions = getGoslingTrackOptions(track, theme); + const datafetcher = getDataFetcher(track); + new GoslingTrack(goslingTrackOptions, datafetcher, pixiManager.makeContainer(boundingBox)).addInteractor( + plot => panZoom(plot, domain) + ); + } + + // Taken from gosling-to-higlass.ts + // we only look into the first resolved spec to get information, such as size of the track + ['x', 'y'].forEach(c => { + const channel = (firstResolvedSpec as any)[c]; + if ( + IsChannelDeep(channel) && + 'axis' in channel && + channel.axis && + channel.axis !== 'none' && + channel.type === 'genomic' + ) { + const narrowType = getAxisNarrowType( + c as any, + track.orientation, + boundingBox.width, + boundingBox.height + ); + const widthOrHeight = channel.axis === 'left' || channel.axis === 'right' ? 'width' : 'height'; + const options = getAxisTrackOptions(channel.axis, narrowType, { + id: `random-str`, // ${trackId}-${channel.axis}-axis`, + layout: firstResolvedSpec.layout, + innerRadius: + channel.axis === 'top' + ? (firstResolvedSpec.outerRadius as number) - 30 + : firstResolvedSpec.innerRadius, + outerRadius: + channel.axis === 'top' + ? firstResolvedSpec.outerRadius + : (firstResolvedSpec.innerRadius as number) + 30, + width: firstResolvedSpec.width, + height: firstResolvedSpec.height, + startAngle: firstResolvedSpec.startAngle, + endAngle: firstResolvedSpec.endAngle, + theme + }); + new AxisTrack( + options, + domain, + pixiManager.makeContainer({ + ...boundingBox, + y: channel.axis === 'bottom' ? boundingBox.y + boundingBox.height : boundingBox.y, + [widthOrHeight]: 30 + }) + ); + } + }); + }); + + // const cursorPosition = signal(0); + // hgSpec.views.forEach(v => { + // const { x, y } = v.layout; + // const { initialXDomain, initialYDomain } = v; + // const domain = signal<[number, number]>(initialXDomain); + // let cumHeight = 0; + // let cumWidth = 0; + + // for (const key in v.tracks) { + // const tracks = v.tracks[key]; + // tracks.forEach(track => { + // switch (track.type) { + // case 'text': { + // const { height, width, options } = track; + + // const titlePos = { x, y: y + cumHeight, width, height }; + // new TextTrack(options, pixiManager.makeContainer(titlePos)); + // // In case there are multiple text tracks, we need to stack them + // cumHeight += height; + // break; + // } + // case 'combined': { + // const { height, width, contents } = track; + // const combinedPos = { x, y: y + cumHeight, width, height }; + // contents.forEach(gosTrack => { + // if (gosTrack.type !== 'gosling-track') console.error('Not a Gosling track'); + // const { options, server, tilesetUid } = gosTrack; + // const dataFetcher = new DataFetcher({ server, tilesetUid }, fakePubSub); + + // new GoslingTrack(options, dataFetcher, pixiManager.makeContainer(combinedPos)) + // .addInteractor(plot => panZoom(plot, domain)) + // .addInteractor(plot => cursor(plot, cursorPosition)); + // }); + // // In case there are multiple combined tracks, we need to stack them + // cumHeight += height; + // break; + // } + // case 'axis-track': { + // const { options } = track; + // const { height, width } = options; + // // Axis track + // const posAxis = { + // x, + // y: y + cumHeight, + // width, + // height + // }; + // new AxisTrack(options, domain, pixiManager.makeContainer(posAxis)); + // break; + // } + // default: + // console.warn(track.type, 'is not supported yet'); + // } + // }); + // } + // }); +} + +export function getAxisTrackOptions( + position: Exclude, + type: 'regular' | 'narrow' | 'narrower' = 'regular', + options: { + id?: string; + layout?: 'circular' | 'linear'; + innerRadius?: number; + outerRadius?: number; + width?: number; + height?: number; + startAngle?: number; + endAngle?: number; + theme: Required; + } +): AxisTrackOptions { + const widthOrHeight = position === 'left' || position === 'right' ? 'width' : 'height'; + let opt: AxisTrackOptions = { + ...options, + assembly: 'hg38', + stroke: 'transparent', // text outline + color: options.theme.axis.labelColor, + labelMargin: options.theme.axis.labelMargin, + excludeChrPrefix: options.theme.axis.labelExcludeChrPrefix, + fontSize: options.theme.axis.labelFontSize, + fontFamily: options.theme.axis.labelFontFamily, + fontWeight: options.theme.axis.labelFontWeight, + tickColor: options.theme.axis.tickColor, + tickFormat: type === 'narrower' ? 'si' : 'plain', + tickPositions: type === 'regular' ? 'even' : 'ends', + reverseOrientation: position === 'bottom' || position === 'right' ? true : false + }; + if (options.layout === 'circular') { + // circular axis: superpose an axis track on top of the `center` track + opt = { ...opt, layout: 'circular' }; + } + return opt; +} + +// determine the compactness type of an axis considering the size of a track +export const getAxisNarrowType = ( + c: 'x' | 'y', + orientation: 'horizontal' | 'vertical' = 'horizontal', + width: number, + height: number +) => { + const narrowSizeThreshold = 400; + const narrowerSizeThreshold = 200; + + if (orientation === 'horizontal') { + if ((c === 'x' && width <= narrowerSizeThreshold) || (c === 'y' && height <= narrowerSizeThreshold)) { + return 'narrower'; + } else if ((c === 'x' && width <= narrowSizeThreshold) || (c === 'y' && height <= narrowSizeThreshold)) { + return 'narrow'; + } else { + return 'regular'; + } + } else { + if ((c === 'x' && height <= narrowerSizeThreshold) || (c === 'y' && width <= narrowerSizeThreshold)) { + return 'narrower'; + } else if ((c === 'x' && height <= narrowSizeThreshold) || (c === 'y' && width <= narrowSizeThreshold)) { + return 'narrow'; + } else { + return 'regular'; + } + } +}; diff --git a/src/compiler/compile.ts b/src/compiler/compile.ts index f34c92cfb..9ada6c8e3 100644 --- a/src/compiler/compile.ts +++ b/src/compiler/compile.ts @@ -2,7 +2,7 @@ import type { GoslingSpec, TemplateTrackDef, VisUnitApiData } from '@gosling-lan import type { HiGlassSpec } from '@gosling-lang/higlass-schema'; import { traverseToFixSpecDownstream } from './spec-preprocess'; import { replaceTrackTemplates } from '../core/utils/template'; -import { getRelativeTrackInfo, type Size } from './bounding-box'; +import { getRelativeTrackInfo, type Size, type TrackInfo } from './bounding-box'; import type { CompleteThemeDeep } from '../core/utils/theme'; import type { UrlToFetchOptions } from 'src/core/gosling-component'; import { renderHiGlass as createHiGlassModels } from './create-higlass-models'; @@ -15,7 +15,9 @@ export type CompileCallback = ( size: Size, gs: GoslingSpec, tracksAndViews: VisUnitApiData[], - idTable: IdTable + idTable: IdTable, + trackInfos: TrackInfo[], + theme: Required ) => void; export function compile( diff --git a/src/compiler/create-higlass-models.ts b/src/compiler/create-higlass-models.ts index dc1811a79..a37b2e43a 100644 --- a/src/compiler/create-higlass-models.ts +++ b/src/compiler/create-higlass-models.ts @@ -21,7 +21,7 @@ export function renderHiGlass( spec: GoslingSpec, trackInfos: TrackInfo[], callback: CompileCallback, - theme: CompleteThemeDeep, + theme: Required, urlToFetchOptions?: UrlToFetchOptions ) { if (trackInfos.length === 0) { @@ -111,5 +111,5 @@ export function renderHiGlass( ...views.map(d => ({ ...d, type: 'view' } as VisUnitApiData)) ]; - callback(hgModel.spec(), getBoundingBox(trackInfos), spec, tracksAndViews, idMapper.getTable()); + callback(hgModel.spec(), getBoundingBox(trackInfos), spec, tracksAndViews, idMapper.getTable(), trackInfos, theme); } From e030b4a26a9058ab9f28f5f7fc431b2272f26658 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Mon, 17 Jun 2024 16:05:45 -0400 Subject: [PATCH 045/139] feat: new examples --- demo/App.tsx | 558 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 551 insertions(+), 7 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index c6bbcf3c2..3d01ffdfc 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -14,7 +14,7 @@ import { getTheme } from '../src/core/utils/theme'; import './App.css'; import type { HiGlassSpec } from '@gosling-lang/higlass-schema'; -import { trackInfoToCanvas } from './trackInfoToCanvas'; +import { trackInfoToTracks } from './trackInfoToCanvas'; import type { TrackInfo } from 'src/compiler/bounding-box'; function App() { @@ -48,7 +48,8 @@ function App() { // console.warn(tracksAndViews); // drawFromHgSpec(hg, pixiManager); console.warn(trackInfos); - trackInfoToCanvas(trackInfos, pixiManager, theme); + // trackInfoToCanvas(trackInfos, pixiManager, theme); + trackInfoToTracks(trackInfos, pixiManager, theme); }; // Compile the spec @@ -71,11 +72,11 @@ export default App; const spec = { title: 'Basic Marks: line', subtitle: 'Tutorial Examples', - layout: 'circular', + layout: 'linear', tracks: [ { layout: 'circular', - width: 800, + width: 500, height: 180, data: { url: 'https://resgen.io/api/v1/tileset_info/?d=UvVPeLHuRDiYA3qwFlm7xQ', @@ -86,13 +87,13 @@ const spec = { categories: ['sample 1'] }, mark: 'line', - x: { field: 'position', type: 'genomic', axis: 'bottom' }, + x: { field: 'position', type: 'genomic', axis: 'top' }, y: { field: 'peak', type: 'quantitative', axis: 'right' }, size: { value: 2 } }, { layout: 'circular', - width: 800, + width: 500, height: 180, data: { url: 'https://resgen.io/api/v1/tileset_info/?d=UvVPeLHuRDiYA3qwFlm7xQ', @@ -103,9 +104,552 @@ const spec = { categories: ['sample 1'] }, mark: 'bar', - x: { field: 'position', type: 'genomic', axis: 'bottom' }, + x: { field: 'position', type: 'genomic', axis: 'top' }, y: { field: 'peak', type: 'quantitative', axis: 'right' }, size: { value: 2 } } ] }; +const spec2 = { + layout: 'linear', + xDomain: { chromosome: 'chr3', interval: [52168000, 52890000] }, + arrangement: 'horizontal', + views: [ + { + arrangement: 'vertical', + views: [ + { + alignment: 'overlay', + title: 'HiGlass', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ], + exonIntervalFields: [ + { index: 12, name: 'start' }, + { index: 13, name: 'end' } + ] + }, + tracks: [ + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['+'] } + ], + mark: 'triangleRight', + x: { field: 'end', type: 'genomic', axis: 'top' }, + size: { value: 15 } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'text', + text: { field: 'name', type: 'nominal' }, + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + style: { dy: -15 } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['-'] } + ], + mark: 'triangleLeft', + x: { field: 'start', type: 'genomic' }, + size: { value: 15 }, + style: { align: 'right' } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['exon'] }], + mark: 'rect', + x: { field: 'start', type: 'genomic' }, + size: { value: 15 }, + xe: { field: 'end', type: 'genomic' } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['+'] } + ], + mark: 'rule', + x: { field: 'start', type: 'genomic' }, + strokeWidth: { value: 3 }, + xe: { field: 'end', type: 'genomic' }, + style: { linePattern: { type: 'triangleRight', size: 5 } } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['-'] } + ], + mark: 'rule', + x: { field: 'start', type: 'genomic' }, + strokeWidth: { value: 3 }, + xe: { field: 'end', type: 'genomic' }, + style: { linePattern: { type: 'triangleLeft', size: 5 } } + } + ], + row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, + color: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'], + range: ['#7585FF', '#FF8A85'] + }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ], + opacity: { value: 0.8 }, + width: 350, + height: 100 + }, + { + alignment: 'overlay', + title: 'Corces et al.', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ], + exonIntervalFields: [ + { index: 12, name: 'start' }, + { index: 13, name: 'end' } + ] + }, + tracks: [ + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['+'] } + ], + mark: 'text', + text: { field: 'name', type: 'nominal' }, + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + size: { value: 8 }, + style: { textFontSize: 8, dy: -12 } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['-'] } + ], + mark: 'text', + text: { field: 'name', type: 'nominal' }, + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + size: { value: 8 }, + style: { textFontSize: 8, dy: 10 } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['+'] } + ], + mark: 'rect', + x: { field: 'end', type: 'genomic' }, + size: { value: 7 } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['-'] } + ], + mark: 'rect', + x: { field: 'start', type: 'genomic' }, + size: { value: 7 } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['exon'] }], + mark: 'rect', + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + size: { value: 14 } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'rule', + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + strokeWidth: { value: 3 } + } + ], + row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, + color: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'], + range: ['#012DB8', '#BE1E2C'] + }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ], + width: 350, + height: 100 + }, + { + alignment: 'overlay', + title: 'IGV', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ], + exonIntervalFields: [ + { index: 12, name: 'start' }, + { index: 13, name: 'end' } + ] + }, + tracks: [ + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'text', + text: { field: 'name', type: 'nominal' }, + x: { field: 'start', type: 'genomic', axis: 'top' }, + xe: { field: 'end', type: 'genomic' } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'rect', + x: { field: 'start', type: 'genomic', axis: 'top' }, + size: { value: 15 }, + xe: { field: 'end', type: 'genomic' } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['-'] } + ], + mark: 'rule', + x: { field: 'start', type: 'genomic', axis: 'top' }, + strokeWidth: { value: 0 }, + xe: { field: 'end', type: 'genomic' }, + color: { value: 'white' }, + opacity: { value: 0.6 }, + style: { linePattern: { type: 'triangleLeft', size: 10 } } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['+'] } + ], + mark: 'rule', + x: { field: 'start', type: 'genomic', axis: 'top' }, + strokeWidth: { value: 0 }, + xe: { field: 'end', type: 'genomic' }, + color: { value: 'white' }, + opacity: { value: 0.6 }, + style: { linePattern: { type: 'triangleRight', size: 10 } } + } + ], + row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, + color: { value: '#0900B1' }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ], + width: 350, + height: 100 + } + ] + }, + { + arrangement: 'vertical', + views: [ + { + alignment: 'overlay', + title: 'Cyverse-QUBES', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ], + exonIntervalFields: [ + { index: 12, name: 'start' }, + { index: 13, name: 'end' } + ] + }, + tracks: [ + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'text', + text: { field: 'name', type: 'nominal' }, + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + color: { value: 'black' } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['+'] } + ], + mark: 'triangleRight', + x: { field: 'end', type: 'genomic', axis: 'top' }, + color: { value: '#999999' } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['-'] } + ], + mark: 'triangleLeft', + x: { field: 'start', type: 'genomic', axis: 'top' }, + color: { value: '#999999' }, + style: { align: 'right' } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'rect', + x: { field: 'start', type: 'genomic', axis: 'top' }, + xe: { field: 'end', type: 'genomic' }, + color: { value: 'lightgray' } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'rule', + x: { field: 'start', type: 'genomic', axis: 'top' }, + strokeWidth: { value: 5 }, + xe: { field: 'end', type: 'genomic' }, + color: { value: 'gray' } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['exon'] }], + mark: 'rect', + x: { field: 'start', type: 'genomic', axis: 'top' }, + xe: { field: 'end', type: 'genomic' }, + color: { value: '#E2A6F5' }, + stroke: { value: '#BB57C9' }, + strokeWidth: { value: 1 } + } + ], + row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ], + size: { value: 15 }, + width: 350, + height: 100 + }, + { + alignment: 'overlay', + title: 'GmGDV', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ], + exonIntervalFields: [ + { index: 12, name: 'start' }, + { index: 13, name: 'end' } + ] + }, + tracks: [ + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'text', + text: { field: 'name', type: 'nominal' }, + x: { field: 'start', type: 'genomic', axis: 'top' }, + xe: { field: 'end', type: 'genomic' }, + style: { dy: -14 } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['+'] } + ], + mark: 'triangleRight', + x: { field: 'end', type: 'genomic', axis: 'top' }, + size: { value: 15 } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['-'] } + ], + mark: 'triangleLeft', + x: { field: 'start', type: 'genomic', axis: 'top' }, + size: { value: 15 }, + style: { align: 'right' } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['exon'] }], + mark: 'rect', + x: { field: 'start', type: 'genomic', axis: 'top' }, + size: { value: 10 }, + xe: { field: 'end', type: 'genomic' } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'rule', + x: { field: 'start', type: 'genomic', axis: 'top' }, + strokeWidth: { value: 3 }, + xe: { field: 'end', type: 'genomic' } + } + ], + row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, + color: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'], + range: ['blue', 'red'] + }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ], + width: 350, + height: 100 + }, + { + alignment: 'overlay', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ], + exonIntervalFields: [ + { index: 12, name: 'start' }, + { index: 13, name: 'end' } + ] + }, + tracks: [ + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'text', + text: { field: 'name', type: 'nominal' }, + x: { field: 'start', type: 'genomic', axis: 'top' }, + color: { value: 'black' }, + xe: { field: 'end', type: 'genomic' } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'rect', + x: { field: 'start', type: 'genomic', axis: 'top' }, + xe: { field: 'end', type: 'genomic' }, + color: { value: '#666666' } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['exon'] }], + mark: 'rect', + x: { field: 'start', type: 'genomic', axis: 'top' }, + xe: { field: 'end', type: 'genomic' }, + color: { value: '#FF6666' } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['intron'] }], + mark: 'rect', + x: { field: 'start', type: 'genomic', axis: 'top' }, + xe: { field: 'end', type: 'genomic' }, + color: { value: '#99FEFF' } + } + ], + size: { value: 30 }, + row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, + stroke: { value: '#777777' }, + strokeWidth: { value: 1 }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ], + width: 350, + height: 100 + } + ] + } + ] +}; +const spec3 = { + title: 'Basic Marks: bar', + subtitle: 'Tutorial Examples', + orientation: 'horizontal', + tracks: [ + { + layout: 'linear', + width: 180, + height: 800, + data: { + url: 'https://resgen.io/api/v1/tileset_info/?d=UvVPeLHuRDiYA3qwFlm7xQ', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1'], + binSize: 5 + }, + mark: 'bar', + x: { field: 'start', type: 'genomic', axis: 'bottom' }, + xe: { field: 'end', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative', axis: 'right' }, + size: { value: 5 } + } + ] +}; From 19cfcb980684c6989983d1afa539ba38cc788876 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Mon, 17 Jun 2024 16:05:56 -0400 Subject: [PATCH 046/139] feat: new render function --- demo/trackInfoToCanvas.ts | 468 ++++++++++++++++---------- src/tracks/genomic-axis/axis-track.ts | 2 +- 2 files changed, 292 insertions(+), 178 deletions(-) diff --git a/demo/trackInfoToCanvas.ts b/demo/trackInfoToCanvas.ts index b58c99ca0..a002d9a6b 100644 --- a/demo/trackInfoToCanvas.ts +++ b/demo/trackInfoToCanvas.ts @@ -9,23 +9,50 @@ import { fakePubSub } from '@higlass/utils'; import { BigWigDataFetcher } from '@data-fetchers'; import { cursor, panZoom } from '@gosling-lang/interactors'; import type { TrackInfo } from '../src/compiler/bounding-box'; -import { IsChannelDeep, IsDummyTrack, IsXAxis, type AxisPosition, type Track } from '@gosling-lang/gosling-schema'; +import { + IsChannelDeep, + IsDummyTrack, + IsTemplateTrack, + IsXAxis, + type AxisPosition, + type OverlaidTrack, + type SingleTrack, + type TemplateTrack, + type Track +} from '@gosling-lang/gosling-schema'; import type { CompleteThemeDeep } from '../src/core/utils/theme'; import { resolveSuperposedTracks } from '../src/core/utils/overlay'; import type { GoslingTrackOptions } from '../src/tracks/gosling-track/gosling-track'; import { HIGLASS_AXIS_SIZE } from '../src/compiler/higlass-model'; -function getTextTrackOptions(spec: Track, theme: Required): TextTrackOptions { - return { - backgroundColor: theme.root.titleBackgroundColor, - textColor: theme.root.titleColor, - fontSize: theme.root.titleFontSize ?? 18, - fontWeight: theme.root.titleFontWeight, - fontFamily: theme.root.titleFontFamily, - offsetY: 0, - align: theme.root.titleAlign, - text: spec.title - }; +function getTextTrackOptions( + spec: Track, + type: 'title' | 'subtitle', + theme: Required +): TextTrackOptions { + if (type === 'title') { + return { + backgroundColor: theme.root.titleBackgroundColor, + textColor: theme.root.titleColor, + fontSize: theme.root.titleFontSize ?? 18, + fontWeight: theme.root.titleFontWeight, + fontFamily: theme.root.titleFontFamily, + offsetY: 0, + align: theme.root.titleAlign, + text: spec.title + }; + } else { + return { + backgroundColor: theme.root.subtitleBackgroundColor, + textColor: theme.root.subtitleColor, + fontSize: theme.root.subtitleFontSize ?? 18, + fontWeight: theme.root.subtitleFontWeight, + fontFamily: theme.root.subtitleFontFamily, + offsetY: 0, + align: theme.root.subtitleAlign, + text: spec.subtitle + }; + } } function getGoslingTrackOptions(spec: Track, theme: Required): GoslingTrackOptions { @@ -72,188 +99,275 @@ function getDummyTrackOptions(spec: Track, theme: Required): return spec; } -export function trackInfoToCanvas( - trackInfos: TrackInfo[], - pixiManager: PixiManager, - theme: Required -) { - const domain = signal([0, 100000000]); - trackInfos.forEach(trackInfo => { - const { track, boundingBox } = trackInfo; +enum TrackType { + Text, + Dummy, + Gosling, + Axis, + BrushLinear, + BrushCircular, + Heatmap +} - const resolvedSpecs = resolveSuperposedTracks(track); - const firstResolvedSpec = resolvedSpecs[0]; +interface TrackOptionsMap { + [TrackType.Text]: TextTrackOptions; + [TrackType.Dummy]: DummyTrackOptions; + [TrackType.Gosling]: GoslingTrackOptions; + [TrackType.Axis]: AxisTrackOptions; + [TrackType.BrushLinear]: any; + [TrackType.BrushCircular]: any; + [TrackType.Heatmap]: any; +} - boundingBox.width -= - firstResolvedSpec.layout !== 'circular' && - firstResolvedSpec.orientation === 'vertical' && - IsXAxis(firstResolvedSpec) - ? HIGLASS_AXIS_SIZE - : 0; +interface TrackDef { + type: TrackType; + boundingBox: { x: number; y: number; width: number; height: number }; + options: T; +} - boundingBox.height -= - firstResolvedSpec.layout !== 'circular' && - firstResolvedSpec.orientation === 'horizontal' && - IsXAxis(firstResolvedSpec) - ? HIGLASS_AXIS_SIZE - : 0; - if (track.mark === '_header') { - const textTrackOptions = getTextTrackOptions(track, theme); - new TextTrack(textTrackOptions, pixiManager.makeContainer(boundingBox)); - // subtitle - } else if (IsDummyTrack(track)) { - const options = getDummyTrackOptions(track, theme); - new DummyTrack(options, pixiManager.makeContainer(boundingBox).overlayDiv); - } else { - const goslingTrackOptions = getGoslingTrackOptions(track, theme); - const datafetcher = getDataFetcher(track); - new GoslingTrack(goslingTrackOptions, datafetcher, pixiManager.makeContainer(boundingBox)).addInteractor( - plot => panZoom(plot, domain) - ); - } +type TrackOptions = { + [K in keyof TrackOptionsMap]: TrackDef; +}[keyof TrackOptionsMap]; - // Taken from gosling-to-higlass.ts - // we only look into the first resolved spec to get information, such as size of the track - ['x', 'y'].forEach(c => { - const channel = (firstResolvedSpec as any)[c]; - if ( - IsChannelDeep(channel) && - 'axis' in channel && - channel.axis && - channel.axis !== 'none' && - channel.type === 'genomic' - ) { - const narrowType = getAxisNarrowType( - c as any, - track.orientation, - boundingBox.width, - boundingBox.height - ); - const widthOrHeight = channel.axis === 'left' || channel.axis === 'right' ? 'width' : 'height'; - const options = getAxisTrackOptions(channel.axis, narrowType, { - id: `random-str`, // ${trackId}-${channel.axis}-axis`, - layout: firstResolvedSpec.layout, - innerRadius: - channel.axis === 'top' - ? (firstResolvedSpec.outerRadius as number) - 30 - : firstResolvedSpec.innerRadius, - outerRadius: - channel.axis === 'top' - ? firstResolvedSpec.outerRadius - : (firstResolvedSpec.innerRadius as number) + 30, - width: firstResolvedSpec.width, - height: firstResolvedSpec.height, - startAngle: firstResolvedSpec.startAngle, - endAngle: firstResolvedSpec.endAngle, - theme - }); - new AxisTrack( - options, - domain, - pixiManager.makeContainer({ - ...boundingBox, - y: channel.axis === 'bottom' ? boundingBox.y + boundingBox.height : boundingBox.y, - [widthOrHeight]: 30 - }) - ); - } - }); - }); +function getAxisPositions(track: Track): { + xAxisPosition: AxisPosition | undefined; + yAxisPosition: AxisPosition | undefined; +} { + if (IsTemplateTrack(track) || IsDummyTrack(track)) { + return { xAxisPosition: undefined, yAxisPosition: undefined }; + } - // const cursorPosition = signal(0); - // hgSpec.views.forEach(v => { - // const { x, y } = v.layout; - // const { initialXDomain, initialYDomain } = v; - // const domain = signal<[number, number]>(initialXDomain); - // let cumHeight = 0; - // let cumWidth = 0; + const resolvedSpecs = resolveSuperposedTracks(track); + const firstResolvedSpec = resolvedSpecs[0]; - // for (const key in v.tracks) { - // const tracks = v.tracks[key]; - // tracks.forEach(track => { - // switch (track.type) { - // case 'text': { - // const { height, width, options } = track; + const hasXAxis = + ('x' in firstResolvedSpec && + firstResolvedSpec.x && + 'axis' in firstResolvedSpec.x && + firstResolvedSpec.x.axis !== 'none' && + firstResolvedSpec.x.type === 'genomic') || + false; + const hasYAxis = + ('y' in firstResolvedSpec && + firstResolvedSpec.y && + 'axis' in firstResolvedSpec.y && + firstResolvedSpec.y.axis !== 'none' && + firstResolvedSpec.y.type === 'genomic') || + false; - // const titlePos = { x, y: y + cumHeight, width, height }; - // new TextTrack(options, pixiManager.makeContainer(titlePos)); - // // In case there are multiple text tracks, we need to stack them - // cumHeight += height; - // break; - // } - // case 'combined': { - // const { height, width, contents } = track; - // const combinedPos = { x, y: y + cumHeight, width, height }; - // contents.forEach(gosTrack => { - // if (gosTrack.type !== 'gosling-track') console.error('Not a Gosling track'); - // const { options, server, tilesetUid } = gosTrack; - // const dataFetcher = new DataFetcher({ server, tilesetUid }, fakePubSub); + const xAxisPosition = + hasXAxis && IsChannelDeep(firstResolvedSpec.x) ? (firstResolvedSpec.x?.axis as AxisPosition) : undefined; + const yAxisPosition = + hasYAxis && IsChannelDeep(firstResolvedSpec.y) ? (firstResolvedSpec.y?.axis as AxisPosition) : undefined; - // new GoslingTrack(options, dataFetcher, pixiManager.makeContainer(combinedPos)) - // .addInteractor(plot => panZoom(plot, domain)) - // .addInteractor(plot => cursor(plot, cursorPosition)); - // }); - // // In case there are multiple combined tracks, we need to stack them - // cumHeight += height; - // break; - // } - // case 'axis-track': { - // const { options } = track; - // const { height, width } = options; - // // Axis track - // const posAxis = { - // x, - // y: y + cumHeight, - // width, - // height - // }; - // new AxisTrack(options, domain, pixiManager.makeContainer(posAxis)); - // break; - // } - // default: - // console.warn(track.type, 'is not supported yet'); - // } - // }); - // } - // }); + return { + xAxisPosition, + yAxisPosition + }; } -export function getAxisTrackOptions( - position: Exclude, - type: 'regular' | 'narrow' | 'narrower' = 'regular', - options: { - id?: string; - layout?: 'circular' | 'linear'; - innerRadius?: number; - outerRadius?: number; - width?: number; - height?: number; - startAngle?: number; - endAngle?: number; - theme: Required; +/** + * Separate the the track with mark "_header" into title and subtitle text tracks + * @param track + * @param boundingBox + * @returns + */ +function proccessTextHeader( + track: Track, + boundingBox: { x: number; y: number; width: number; height: number }, + theme: Required +): TrackDef[] { + let cumHeight = 0; + const trackInfosProcessed: TrackDef[] = []; + if (track.title) { + const textTrackOptions = getTextTrackOptions(track, 'title', theme); + const height = textTrackOptions.fontSize + 6; + trackInfosProcessed.push({ + type: TrackType.Text, + boundingBox: { ...boundingBox, height }, + options: textTrackOptions + }); + cumHeight += height; + } + if (track.subtitle) { + const textTrackOptions = getTextTrackOptions(track, 'subtitle', theme); + const height = textTrackOptions.fontSize + 6; + trackInfosProcessed.push({ + type: TrackType.Text, + boundingBox: { ...boundingBox, y: boundingBox.y + cumHeight, height }, + options: textTrackOptions + }); } + return trackInfosProcessed; +} + +/** + * Generates options for the linear axis track + * @param boundingBox Bounding box of the track + * @param position "top" | "bottom" | "left" | "right + */ +function getAxisTrackLinearOptions( + boundingBox: { x: number; y: number; width: number; height: number }, + position: AxisPosition, + theme: Required ): AxisTrackOptions { - const widthOrHeight = position === 'left' || position === 'right' ? 'width' : 'height'; - let opt: AxisTrackOptions = { - ...options, + const narrowType = getAxisNarrowType('x', 'horizontal', boundingBox.width, boundingBox.height); + const options: AxisTrackOptions = { + innerRadius: 0, + outerRadius: 0, + width: boundingBox.width, + height: boundingBox.height, + startAngle: 0, + endAngle: 0, + layout: 'linear', assembly: 'hg38', stroke: 'transparent', // text outline - color: options.theme.axis.labelColor, - labelMargin: options.theme.axis.labelMargin, - excludeChrPrefix: options.theme.axis.labelExcludeChrPrefix, - fontSize: options.theme.axis.labelFontSize, - fontFamily: options.theme.axis.labelFontFamily, - fontWeight: options.theme.axis.labelFontWeight, - tickColor: options.theme.axis.tickColor, - tickFormat: type === 'narrower' ? 'si' : 'plain', - tickPositions: type === 'regular' ? 'even' : 'ends', + color: theme.axis.labelColor, + labelMargin: theme.axis.labelMargin, + excludeChrPrefix: theme.axis.labelExcludeChrPrefix, + fontSize: theme.axis.labelFontSize, + fontFamily: theme.axis.labelFontFamily, + fontWeight: theme.axis.labelFontWeight, + tickColor: theme.axis.tickColor, + tickFormat: narrowType === 'narrower' ? 'si' : 'plain', + tickPositions: narrowType === 'regular' ? 'even' : 'ends', reverseOrientation: position === 'bottom' || position === 'right' ? true : false }; - if (options.layout === 'circular') { - // circular axis: superpose an axis track on top of the `center` track - opt = { ...opt, layout: 'circular' }; + return options; +} + +function getAxisTrackCircularOptions( + track: SingleTrack | OverlaidTrack | TemplateTrack, + boundingBox: { x: number; y: number; width: number; height: number }, + position: AxisPosition, + theme: Required +): AxisTrackOptions { + const narrowType = getAxisNarrowType('x', 'horizontal', boundingBox.width, boundingBox.height); + const { startAngle, endAngle, outerRadius } = track; + let { innerRadius } = track; + if (position === 'top') { + innerRadius = outerRadius - 30; + } else if (position === 'left' || position === 'right') { + console.error('Axis position left or right is not supported in circular layout'); } - return opt; + + const options: AxisTrackOptions = { + layout: 'circular', + innerRadius, + outerRadius, + width: boundingBox.width, + height: boundingBox.height, + startAngle, + endAngle, + assembly: 'hg38', + stroke: 'transparent', // text outline + color: theme.axis.labelColor, + labelMargin: theme.axis.labelMargin, + excludeChrPrefix: theme.axis.labelExcludeChrPrefix, + fontSize: theme.axis.labelFontSize, + fontFamily: theme.axis.labelFontFamily, + fontWeight: theme.axis.labelFontWeight, + tickColor: theme.axis.tickColor, + tickFormat: narrowType === 'narrower' ? 'si' : 'plain', + tickPositions: narrowType === 'regular' ? 'even' : 'ends', + reverseOrientation: position === 'bottom' || position === 'right' ? true : false + }; + return options; +} + +function processGoslingTrack( + track: Track, + boundingBox: { x: number; y: number; width: number; height: number }, + theme: Required +): (TrackDef | TrackDef)[] { + const trackInfosProcessed: (TrackDef | TrackDef)[] = []; + + const { xAxisPosition, yAxisPosition } = getAxisPositions(track); + if (xAxisPosition) { + if (track.layout === 'linear') { + const isHorizontal = track.orientation === 'horizontal'; + const widthOrHeight = isHorizontal ? 'height' : 'width'; + const axisBbox = { ...boundingBox, [widthOrHeight]: HIGLASS_AXIS_SIZE }; + boundingBox[widthOrHeight] -= axisBbox[widthOrHeight]; + if (xAxisPosition === 'top') { + boundingBox.y += axisBbox.height; + } else if (xAxisPosition === 'bottom') { + axisBbox.y = boundingBox.y + boundingBox.height; + } else if (xAxisPosition === 'right') { + axisBbox.x = boundingBox.x + boundingBox.width; + } else if (xAxisPosition === 'left') { + boundingBox.x += axisBbox.width; + } + trackInfosProcessed.push({ + type: TrackType.Axis, + boundingBox: axisBbox, + options: getAxisTrackLinearOptions(axisBbox, xAxisPosition, theme) + }); + } else if (track.layout === 'circular') { + trackInfosProcessed.push({ + type: TrackType.Axis, + boundingBox: boundingBox, + options: getAxisTrackCircularOptions(track, boundingBox, xAxisPosition, theme) + }); + } + } + + const goslingTrackOptions = getGoslingTrackOptions(track, theme); + + trackInfosProcessed.push({ + type: TrackType.Gosling, + boundingBox: { ...boundingBox }, + options: goslingTrackOptions + }); + + return trackInfosProcessed; +} + +export function trackInfoToTracks( + trackInfos: TrackInfo[], + pixiManager: PixiManager, + theme: Required +) { + const trackInfosProcessed: TrackOptions[] = []; + trackInfos.forEach(trackInfo => { + const { track, boundingBox } = trackInfo; + // console.warn('boundingBox', boundingBox); + // const div = pixiManager.makeContainer(boundingBox).overlayDiv; + // div.style.border = '3px solid red'; + // div.innerHTML = track.mark || 'No mark'; + // div.style.textAlign = 'left'; + + // Header marks contain both the title and subtitle + if (track.mark === '_header') { + const trackOptions = proccessTextHeader(track, boundingBox, theme); + trackInfosProcessed.push(...trackOptions); + } else { + const trackOptions = processGoslingTrack(track, boundingBox, theme); + trackInfosProcessed.push(...trackOptions); + } + }); + + const domain = signal<[number, number]>([0, 3088269832]); + trackInfosProcessed.forEach(trackInfo => { + const { boundingBox, type } = trackInfo; + // console.warn('boundingBox', boundingBox); + // const div = pixiManager.makeContainer(boundingBox).overlayDiv; + // div.style.border = '1px solid black'; + // div.innerHTML = TrackType[type] || 'No mark'; + + if (type === TrackType.Text) { + new TextTrack(trackInfo.options, pixiManager.makeContainer(boundingBox)); + } + if (type === TrackType.Gosling) { + const datafetcher = getDataFetcher(trackInfo.options.spec); + new GoslingTrack(trackInfo.options, datafetcher, pixiManager.makeContainer(boundingBox)).addInteractor( + plot => panZoom(plot, domain) + ); + } + if (type === TrackType.Axis) { + new AxisTrack(trackInfo.options, domain, pixiManager.makeContainer(boundingBox)); + } + }); } // determine the compactness type of an axis considering the size of a track diff --git a/src/tracks/genomic-axis/axis-track.ts b/src/tracks/genomic-axis/axis-track.ts index 1de2c47de..b12f89cb9 100644 --- a/src/tracks/genomic-axis/axis-track.ts +++ b/src/tracks/genomic-axis/axis-track.ts @@ -41,7 +41,7 @@ export type AxisTrackOptions = { stroke: string; backgroundColor: string; showMousePosition: boolean; - tickColor: number; + tickColor: number | string; tickFormat?: string; assembly?: Assembly; reverseOrientation?: boolean; From 67928cb2f310f9cba0dd317374821b92c95e4464 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Mon, 17 Jun 2024 17:46:19 -0400 Subject: [PATCH 047/139] refactor: split into files --- demo/App.tsx | 481 ++++++++++++++++++++++++++++++++++- demo/renderer/axis.ts | 211 +++++++++++++++ demo/renderer/dataFetcher.ts | 22 ++ demo/renderer/gosling.ts | 55 ++++ demo/renderer/main.ts | 119 +++++++++ demo/renderer/text.ts | 70 +++++ demo/trackInfoToCanvas.ts | 400 ----------------------------- 7 files changed, 950 insertions(+), 408 deletions(-) create mode 100644 demo/renderer/axis.ts create mode 100644 demo/renderer/dataFetcher.ts create mode 100644 demo/renderer/gosling.ts create mode 100644 demo/renderer/main.ts create mode 100644 demo/renderer/text.ts delete mode 100644 demo/trackInfoToCanvas.ts diff --git a/demo/App.tsx b/demo/App.tsx index 3d01ffdfc..f21a9e870 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -14,7 +14,7 @@ import { getTheme } from '../src/core/utils/theme'; import './App.css'; import type { HiGlassSpec } from '@gosling-lang/higlass-schema'; -import { trackInfoToTracks } from './trackInfoToCanvas'; +import { createTrackDefs, renderTrackDefs } from './renderer/main'; import type { TrackInfo } from 'src/compiler/bounding-box'; function App() { @@ -43,13 +43,9 @@ function App() { trackInfos: TrackInfo[], theme: Require ) => { - // console.warn(hg); - // console.warn(idTable); - // console.warn(tracksAndViews); - // drawFromHgSpec(hg, pixiManager); console.warn(trackInfos); - // trackInfoToCanvas(trackInfos, pixiManager, theme); - trackInfoToTracks(trackInfos, pixiManager, theme); + const trackDefs = createTrackDefs(trackInfos, pixiManager, theme); + renderTrackDefs(trackDefs, pixiManager); }; // Compile the spec @@ -72,7 +68,7 @@ export default App; const spec = { title: 'Basic Marks: line', subtitle: 'Tutorial Examples', - layout: 'linear', + layout: 'circular', tracks: [ { layout: 'circular', @@ -653,3 +649,472 @@ const spec3 = { } ] }; + +const spec4 = { + static: true, + layout: 'linear', + centerRadius: 0.2, + arrangement: 'parallel', + views: [ + { + xDomain: { chromosome: 'chr1' }, + tracks: [ + { + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=cistrome-multivec', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1', 'sample 2', 'sample 3', 'sample 4'] + }, + mark: 'area', + x: { field: 'position', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative' }, + color: { field: 'sample', type: 'nominal' }, + width: 1000, + height: 30 + }, + { + alignment: 'overlay', + data: { + url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/cytogenetic_band.csv', + type: 'csv', + chromosomeField: 'Chr.', + genomicFields: ['ISCN_start', 'ISCN_stop', 'Basepair_start', 'Basepair_stop'] + }, + tracks: [ + { + mark: 'text', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen-1', 'acen-2'], + not: true + } + ], + text: { field: 'Band', type: 'nominal' }, + color: { value: 'black' }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ] + }, + { + mark: 'rect', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen-1', 'acen-2'], + not: true + } + ], + color: { + field: 'Density', + type: 'nominal', + domain: ['', '25', '50', '75', '100'], + range: ['white', '#D9D9D9', '#979797', '#636363', 'black'] + } + }, + { + mark: 'rect', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['gvar'] }], + color: { value: '#A0A0F2' } + }, + { + mark: 'triangleRight', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-1'] }], + color: { value: '#B40101' } + }, + { + mark: 'triangleLeft', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-2'] }], + color: { value: '#B40101' } + } + ], + x: { field: 'Basepair_start', type: 'genomic' }, + xe: { field: 'Basepair_stop', type: 'genomic' }, + stroke: { value: 'gray' }, + strokeWidth: { value: 0.5 }, + width: 1000, + height: 20 + } + ] + }, + { + xDomain: { chromosome: 'chr2' }, + tracks: [ + { + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=cistrome-multivec', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1', 'sample 2', 'sample 3', 'sample 4'] + }, + mark: 'area', + x: { field: 'position', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative' }, + color: { field: 'sample', type: 'nominal' }, + width: 970, + height: 30 + }, + { + alignment: 'overlay', + data: { + url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/cytogenetic_band.csv', + type: 'csv', + chromosomeField: 'Chr.', + genomicFields: ['ISCN_start', 'ISCN_stop', 'Basepair_start', 'Basepair_stop'] + }, + tracks: [ + { + mark: 'text', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen-1', 'acen-2'], + not: true + } + ], + text: { field: 'Band', type: 'nominal' }, + color: { value: 'black' }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ] + }, + { + mark: 'rect', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen-1', 'acen-2'], + not: true + } + ], + color: { + field: 'Density', + type: 'nominal', + domain: ['', '25', '50', '75', '100'], + range: ['white', '#D9D9D9', '#979797', '#636363', 'black'] + } + }, + { + mark: 'rect', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['gvar'] }], + color: { value: '#A0A0F2' } + }, + { + mark: 'triangleRight', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-1'] }], + color: { value: '#B40101' } + }, + { + mark: 'triangleLeft', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-2'] }], + color: { value: '#B40101' } + } + ], + x: { field: 'Basepair_start', type: 'genomic' }, + xe: { field: 'Basepair_stop', type: 'genomic' }, + stroke: { value: 'gray' }, + strokeWidth: { value: 0.5 }, + width: 970, + height: 20 + } + ] + }, + { + xDomain: { chromosome: 'chr3' }, + tracks: [ + { + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=cistrome-multivec', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1', 'sample 2', 'sample 3', 'sample 4'] + }, + mark: 'area', + x: { field: 'position', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative' }, + color: { field: 'sample', type: 'nominal' }, + width: 800, + height: 30 + }, + { + alignment: 'overlay', + data: { + url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/cytogenetic_band.csv', + type: 'csv', + chromosomeField: 'Chr.', + genomicFields: ['ISCN_start', 'ISCN_stop', 'Basepair_start', 'Basepair_stop'] + }, + tracks: [ + { + mark: 'text', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen-1', 'acen-2'], + not: true + } + ], + text: { field: 'Band', type: 'nominal' }, + color: { value: 'black' }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ] + }, + { + mark: 'rect', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen-1', 'acen-2'], + not: true + } + ], + color: { + field: 'Density', + type: 'nominal', + domain: ['', '25', '50', '75', '100'], + range: ['white', '#D9D9D9', '#979797', '#636363', 'black'] + } + }, + { + mark: 'rect', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['gvar'] }], + color: { value: '#A0A0F2' } + }, + { + mark: 'triangleRight', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-1'] }], + color: { value: '#B40101' } + }, + { + mark: 'triangleLeft', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-2'] }], + color: { value: '#B40101' } + } + ], + x: { field: 'Basepair_start', type: 'genomic' }, + xe: { field: 'Basepair_stop', type: 'genomic' }, + stroke: { value: 'gray' }, + strokeWidth: { value: 0.5 }, + width: 800, + height: 20 + } + ] + }, + { + xDomain: { chromosome: 'chr4' }, + tracks: [ + { + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=cistrome-multivec', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1', 'sample 2', 'sample 3', 'sample 4'] + }, + mark: 'area', + x: { field: 'position', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative' }, + color: { field: 'sample', type: 'nominal' }, + width: 770, + height: 30 + }, + { + alignment: 'overlay', + data: { + url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/cytogenetic_band.csv', + type: 'csv', + chromosomeField: 'Chr.', + genomicFields: ['ISCN_start', 'ISCN_stop', 'Basepair_start', 'Basepair_stop'] + }, + tracks: [ + { + mark: 'text', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen-1', 'acen-2'], + not: true + } + ], + text: { field: 'Band', type: 'nominal' }, + color: { value: 'black' }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ] + }, + { + mark: 'rect', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen-1', 'acen-2'], + not: true + } + ], + color: { + field: 'Density', + type: 'nominal', + domain: ['', '25', '50', '75', '100'], + range: ['white', '#D9D9D9', '#979797', '#636363', 'black'] + } + }, + { + mark: 'rect', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['gvar'] }], + color: { value: '#A0A0F2' } + }, + { + mark: 'triangleRight', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-1'] }], + color: { value: '#B40101' } + }, + { + mark: 'triangleLeft', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-2'] }], + color: { value: '#B40101' } + } + ], + x: { field: 'Basepair_start', type: 'genomic' }, + xe: { field: 'Basepair_stop', type: 'genomic' }, + stroke: { value: 'gray' }, + strokeWidth: { value: 0.5 }, + width: 770, + height: 20 + } + ] + }, + { + xDomain: { chromosome: 'chr5' }, + tracks: [ + { + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=cistrome-multivec', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1', 'sample 2', 'sample 3', 'sample 4'] + }, + mark: 'area', + x: { field: 'position', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative' }, + color: { field: 'sample', type: 'nominal' }, + width: 740, + height: 30 + }, + { + alignment: 'overlay', + data: { + url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/cytogenetic_band.csv', + type: 'csv', + chromosomeField: 'Chr.', + genomicFields: ['ISCN_start', 'ISCN_stop', 'Basepair_start', 'Basepair_stop'] + }, + tracks: [ + { + mark: 'text', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen-1', 'acen-2'], + not: true + } + ], + text: { field: 'Band', type: 'nominal' }, + color: { value: 'black' }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ] + }, + { + mark: 'rect', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen-1', 'acen-2'], + not: true + } + ], + color: { + field: 'Density', + type: 'nominal', + domain: ['', '25', '50', '75', '100'], + range: ['white', '#D9D9D9', '#979797', '#636363', 'black'] + } + }, + { + mark: 'rect', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['gvar'] }], + color: { value: '#A0A0F2' } + }, + { + mark: 'triangleRight', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-1'] }], + color: { value: '#B40101' } + }, + { + mark: 'triangleLeft', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-2'] }], + color: { value: '#B40101' } + } + ], + x: { field: 'Basepair_start', type: 'genomic' }, + xe: { field: 'Basepair_stop', type: 'genomic' }, + stroke: { value: 'gray' }, + strokeWidth: { value: 0.5 }, + width: 740, + height: 20 + } + ] + } + ] +}; diff --git a/demo/renderer/axis.ts b/demo/renderer/axis.ts new file mode 100644 index 000000000..106ade7a8 --- /dev/null +++ b/demo/renderer/axis.ts @@ -0,0 +1,211 @@ +import { type AxisTrackOptions } from '@gosling-lang/genomic-axis'; +import { + IsChannelDeep, + IsDummyTrack, + IsTemplateTrack, + IsXAxis, + type AxisPosition, + type OverlaidTrack, + type SingleTrack, + type TemplateTrack, + type Track +} from '@gosling-lang/gosling-schema'; +import type { CompleteThemeDeep } from '../../src/core/utils/theme'; +import { resolveSuperposedTracks } from '../../src/core/utils/overlay'; +import { HIGLASS_AXIS_SIZE } from '../../src/compiler/higlass-model'; +import { TrackType, type TrackDef } from './main'; + +/** + * Generates the track definition for the axis track + * @param track + * @param boundingBox + * @param theme + */ +export function getAxisTrackDef( + track: SingleTrack | OverlaidTrack | TemplateTrack, + boundingBox: { x: number; y: number; width: number; height: number }, + theme: Required +): TrackDef | undefined { + const { xAxisPosition, yAxisPosition } = getAxisPositions(track); + if (xAxisPosition) { + if (track.layout === 'linear') { + const isHorizontal = track.orientation === 'horizontal'; + const widthOrHeight = isHorizontal ? 'height' : 'width'; + const axisBbox = { ...boundingBox, [widthOrHeight]: HIGLASS_AXIS_SIZE }; + boundingBox[widthOrHeight] -= axisBbox[widthOrHeight]; + if (xAxisPosition === 'top') { + boundingBox.y += axisBbox.height; + } else if (xAxisPosition === 'bottom') { + axisBbox.y = boundingBox.y + boundingBox.height; + } else if (xAxisPosition === 'right') { + axisBbox.x = boundingBox.x + boundingBox.width; + } else if (xAxisPosition === 'left') { + boundingBox.x += axisBbox.width; + } + return { + type: TrackType.Axis, + boundingBox: axisBbox, + options: getAxisTrackLinearOptions(axisBbox, xAxisPosition, theme) + }; + } else if (track.layout === 'circular') { + return { + type: TrackType.Axis, + boundingBox: boundingBox, + options: getAxisTrackCircularOptions(track, boundingBox, xAxisPosition, theme) + }; + } + } + if (yAxisPosition) { + console.warn('Vertical axis is not supported yet'); + } +} + +/** + * Generates options for the linear axis track + * @param boundingBox Bounding box of the track + * @param position "top" | "bottom" | "left" | "right + */ +function getAxisTrackLinearOptions( + boundingBox: { x: number; y: number; width: number; height: number }, + position: AxisPosition, + theme: Required +): AxisTrackOptions { + const narrowType = getAxisNarrowType('x', 'horizontal', boundingBox.width, boundingBox.height); + const options: AxisTrackOptions = { + innerRadius: 0, + outerRadius: 0, + width: boundingBox.width, + height: boundingBox.height, + startAngle: 0, + endAngle: 0, + layout: 'linear', + assembly: 'hg38', + stroke: 'transparent', // text outline + color: theme.axis.labelColor, + labelMargin: theme.axis.labelMargin, + excludeChrPrefix: theme.axis.labelExcludeChrPrefix, + fontSize: theme.axis.labelFontSize, + fontFamily: theme.axis.labelFontFamily, + fontWeight: theme.axis.labelFontWeight, + tickColor: theme.axis.tickColor, + tickFormat: narrowType === 'narrower' ? 'si' : 'plain', + tickPositions: narrowType === 'regular' ? 'even' : 'ends', + reverseOrientation: position === 'bottom' || position === 'right' ? true : false + }; + return options; +} + +/** + * Generates options for the circular axis track + */ +function getAxisTrackCircularOptions( + track: SingleTrack | OverlaidTrack | TemplateTrack, + boundingBox: { x: number; y: number; width: number; height: number }, + position: AxisPosition, + theme: Required +): AxisTrackOptions { + const narrowType = getAxisNarrowType('x', 'horizontal', boundingBox.width, boundingBox.height); + const { startAngle, endAngle, outerRadius } = track; + let { innerRadius } = track; + if (position === 'top') { + innerRadius = outerRadius - 30; + } else if (position === 'left' || position === 'right') { + console.error('Axis position left or right is not supported in circular layout'); + } + + const options: AxisTrackOptions = { + layout: 'circular', + innerRadius, + outerRadius, + width: boundingBox.width, + height: boundingBox.height, + startAngle, + endAngle, + assembly: 'hg38', + stroke: 'transparent', // text outline + color: theme.axis.labelColor, + labelMargin: theme.axis.labelMargin, + excludeChrPrefix: theme.axis.labelExcludeChrPrefix, + fontSize: theme.axis.labelFontSize, + fontFamily: theme.axis.labelFontFamily, + fontWeight: theme.axis.labelFontWeight, + tickColor: theme.axis.tickColor, + tickFormat: narrowType === 'narrower' ? 'si' : 'plain', + tickPositions: narrowType === 'regular' ? 'even' : 'ends', + reverseOrientation: position === 'bottom' || position === 'right' ? true : false + }; + return options; +} + +/** + * Determines the position of the x and y axes for a given track + * @param track + * @returns + */ +function getAxisPositions(track: Track): { + xAxisPosition: AxisPosition | undefined; + yAxisPosition: AxisPosition | undefined; +} { + if (IsTemplateTrack(track) || IsDummyTrack(track)) { + return { xAxisPosition: undefined, yAxisPosition: undefined }; + } + + const resolvedSpecs = resolveSuperposedTracks(track); + const firstResolvedSpec = resolvedSpecs[0]; + + const hasXAxis = + ('x' in firstResolvedSpec && + firstResolvedSpec.x && + 'axis' in firstResolvedSpec.x && + firstResolvedSpec.x.axis !== 'none' && + firstResolvedSpec.x.type === 'genomic') || + false; + const hasYAxis = + ('y' in firstResolvedSpec && + firstResolvedSpec.y && + 'axis' in firstResolvedSpec.y && + firstResolvedSpec.y.axis !== 'none' && + firstResolvedSpec.y.type === 'genomic') || + false; + + const xAxisPosition = + hasXAxis && IsChannelDeep(firstResolvedSpec.x) ? (firstResolvedSpec.x?.axis as AxisPosition) : undefined; + const yAxisPosition = + hasYAxis && IsChannelDeep(firstResolvedSpec.y) ? (firstResolvedSpec.y?.axis as AxisPosition) : undefined; + + return { + xAxisPosition, + yAxisPosition + }; +} + +/** + * Determines the compactness type of an axis considering the size of a track + */ +const getAxisNarrowType = ( + c: 'x' | 'y', + orientation: 'horizontal' | 'vertical' = 'horizontal', + width: number, + height: number +) => { + const narrowSizeThreshold = 400; + const narrowerSizeThreshold = 200; + + if (orientation === 'horizontal') { + if ((c === 'x' && width <= narrowerSizeThreshold) || (c === 'y' && height <= narrowerSizeThreshold)) { + return 'narrower'; + } else if ((c === 'x' && width <= narrowSizeThreshold) || (c === 'y' && height <= narrowSizeThreshold)) { + return 'narrow'; + } else { + return 'regular'; + } + } else { + if ((c === 'x' && height <= narrowerSizeThreshold) || (c === 'y' && width <= narrowerSizeThreshold)) { + return 'narrower'; + } else if ((c === 'x' && height <= narrowSizeThreshold) || (c === 'y' && width <= narrowSizeThreshold)) { + return 'narrow'; + } else { + return 'regular'; + } + } +}; diff --git a/demo/renderer/dataFetcher.ts b/demo/renderer/dataFetcher.ts new file mode 100644 index 000000000..558538e86 --- /dev/null +++ b/demo/renderer/dataFetcher.ts @@ -0,0 +1,22 @@ +import { DataFetcher } from '@higlass/datafetcher'; +import { fakePubSub } from '@higlass/utils'; +import { BigWigDataFetcher, CsvDataFetcher } from '@data-fetchers'; + +export function getDataFetcher(spec: Track) { + if (!('data' in spec)) { + console.warn('No data in the track spec', spec); + } + if (spec.data.type == 'multivec') { + const url = spec.data.url; + const server = url.split('/').slice(0, -2).join('/'); + const tilesetUid = url.split('=').slice(-1)[0]; + console.warn('server', server, 'tilesetUid', tilesetUid); + return new DataFetcher({ server, tilesetUid }, fakePubSub); + } + if (spec.data.type == 'bigwig') { + return new BigWigDataFetcher(spec.data); + } + if (spec.data.type == 'csv') { + return new CsvDataFetcher(spec.data); + } +} diff --git a/demo/renderer/gosling.ts b/demo/renderer/gosling.ts new file mode 100644 index 000000000..9a4d108f9 --- /dev/null +++ b/demo/renderer/gosling.ts @@ -0,0 +1,55 @@ +import { type AxisTrackOptions } from '@gosling-lang/genomic-axis'; + +import { type Track } from '@gosling-lang/gosling-schema'; +import type { CompleteThemeDeep } from '../../src/core/utils/theme'; + +import type { GoslingTrackOptions } from '../../src/tracks/gosling-track/gosling-track'; + +import { getAxisTrackDef } from './axis'; +import { type TrackDef, TrackType } from './main'; + +export function processGoslingTrack( + track: Track, + boundingBox: { x: number; y: number; width: number; height: number }, + theme: Required +): (TrackDef | TrackDef)[] { + const trackDefs: (TrackDef | TrackDef)[] = []; + + const axisTrackOptions = getAxisTrackDef(track, boundingBox, theme); + if (axisTrackOptions) { + trackDefs.push(axisTrackOptions); + } + + const goslingTrackOptions = getGoslingTrackOptions(track, theme); + + trackDefs.push({ + type: TrackType.Gosling, + boundingBox: { ...boundingBox }, + options: goslingTrackOptions + }); + + return trackDefs; +} + +function getGoslingTrackOptions(spec: Track, theme: Required): GoslingTrackOptions { + return { + spec: spec, + id: '9f4abc56-cb8d-4494-a9ca-56086ab28de2', + siblingIds: ['9f4abc56-cb8d-4494-a9ca-56086ab28de2'], + showMousePosition: true, + mousePositionColor: '#000000', + name: spec.title, + labelPosition: 'topLeft', + labelShowResolution: false, + labelColor: 'black', + labelBackgroundColor: 'white', + labelBackgroundOpacity: 0.5, + labelTextOpacity: 1, + labelLeftMargin: 1, + labelTopMargin: 1, + labelRightMargin: 0, + labelBottomMargin: 0, + backgroundColor: 'transparent', + theme + }; +} diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts new file mode 100644 index 000000000..9a76a24ac --- /dev/null +++ b/demo/renderer/main.ts @@ -0,0 +1,119 @@ +import type { PixiManager } from '@pixi-manager'; +import { TextTrack, type TextTrackOptions } from '@gosling-lang/text-track'; +import { DummyTrack, type DummyTrackOptions } from '@gosling-lang/dummy-track'; +import { GoslingTrack } from '@gosling-lang/gosling-track'; +import { AxisTrack, type AxisTrackOptions } from '@gosling-lang/genomic-axis'; +import { signal } from '@preact/signals-core'; + +import { cursor, panZoom } from '@gosling-lang/interactors'; +import type { TrackInfo } from '../../src/compiler/bounding-box'; +import type { CompleteThemeDeep } from '../../src/core/utils/theme'; +import type { GoslingTrackOptions } from '../../src/tracks/gosling-track/gosling-track'; + +import { proccessTextHeader } from './text'; +import { processGoslingTrack } from './gosling'; +import { getDataFetcher } from './dataFetcher'; + +/** + * All the different types of tracks that can be rendered + */ +export enum TrackType { + Text, + Dummy, + Gosling, + Axis, + BrushLinear, + BrushCircular, + Heatmap +} + +/** + * Associate options to each track type + */ +interface TrackOptionsMap { + [TrackType.Text]: TextTrackOptions; + [TrackType.Dummy]: DummyTrackOptions; + [TrackType.Gosling]: GoslingTrackOptions; + [TrackType.Axis]: AxisTrackOptions; + [TrackType.BrushLinear]: any; + [TrackType.BrushCircular]: any; + [TrackType.Heatmap]: any; +} + +/** + * This interface contains all of the information needed to render each track type. + */ +export interface TrackDef { + type: TrackType; + boundingBox: { x: number; y: number; width: number; height: number }; + options: T; +} + +/** + * This is a union of all the different TrackDefs + */ +type TrackDefs = { + [K in keyof TrackOptionsMap]: TrackDef; +}[keyof TrackOptionsMap]; + +/** + * Takes a list of TrackInfos and returns a list of TrackOptions + * @param trackInfos + * @param pixiManager + * @param theme + * @returns + */ +export function createTrackDefs( + trackInfos: TrackInfo[], + pixiManager: PixiManager, + theme: Required +): TrackDefs[] { + const trackDefs: TrackDefs[] = []; + trackInfos.forEach(trackInfo => { + const { track, boundingBox } = trackInfo; + // console.warn('boundingBox', boundingBox); + // const div = pixiManager.makeContainer(boundingBox).overlayDiv; + // div.style.border = '3px solid red'; + // div.innerHTML = track.mark || 'No mark'; + // div.style.textAlign = 'left'; + + // Header marks contain both the title and subtitle + if (track.mark === '_header') { + const textTrackDefs = proccessTextHeader(track, boundingBox, theme); + trackDefs.push(...textTrackDefs); + } else { + const goslingAxisDefs = processGoslingTrack(track, boundingBox, theme); + trackDefs.push(...goslingAxisDefs); + } + }); + return trackDefs; +} + +/** + * Takes a list of track options and renders them on the screen + * @param trackOptions + * @param pixiManager + */ +export function renderTrackDefs(trackOptions: TrackDefs[], pixiManager: PixiManager) { + const domain = signal<[number, number]>([0, 3088269832]); + trackOptions.forEach(trackInfo => { + const { boundingBox, type } = trackInfo; + // console.warn('boundingBox', boundingBox); + // const div = pixiManager.makeContainer(boundingBox).overlayDiv; + // div.style.border = '1px solid black'; + // div.innerHTML = TrackType[type] || 'No mark'; + + if (type === TrackType.Text) { + new TextTrack(trackInfo.options, pixiManager.makeContainer(boundingBox)); + } + if (type === TrackType.Gosling) { + const datafetcher = getDataFetcher(trackInfo.options.spec); + new GoslingTrack(trackInfo.options, datafetcher, pixiManager.makeContainer(boundingBox)).addInteractor( + plot => panZoom(plot, domain) + ); + } + if (type === TrackType.Axis) { + new AxisTrack(trackInfo.options, domain, pixiManager.makeContainer(boundingBox)); + } + }); +} diff --git a/demo/renderer/text.ts b/demo/renderer/text.ts new file mode 100644 index 000000000..bce9d9006 --- /dev/null +++ b/demo/renderer/text.ts @@ -0,0 +1,70 @@ +import { type TextTrackOptions } from '@gosling-lang/text-track'; + +import { type Track } from '@gosling-lang/gosling-schema'; +import type { CompleteThemeDeep } from '../../src/core/utils/theme'; +import { TrackType, type TrackDef } from './main'; + +/** + * Separate the the track with mark "_header" into title and subtitle text tracks + * @param track + * @param boundingBox + * @returns + */ +export function proccessTextHeader( + track: Track, + boundingBox: { x: number; y: number; width: number; height: number }, + theme: Required +): TrackDef[] { + let cumHeight = 0; + const trackDefs: TrackDef[] = []; + if (track.title) { + const textTrackOptions = getTextTrackOptions(track, 'title', theme); + const height = textTrackOptions.fontSize + 6; + trackDefs.push({ + type: TrackType.Text, + boundingBox: { ...boundingBox, height }, + options: textTrackOptions + }); + cumHeight += height; + } + if (track.subtitle) { + const textTrackOptions = getTextTrackOptions(track, 'subtitle', theme); + const height = textTrackOptions.fontSize + 6; + trackDefs.push({ + type: TrackType.Text, + boundingBox: { ...boundingBox, y: boundingBox.y + cumHeight, height }, + options: textTrackOptions + }); + } + return trackDefs; +} + +function getTextTrackOptions( + spec: Track, + type: 'title' | 'subtitle', + theme: Required +): TextTrackOptions { + if (type === 'title') { + return { + backgroundColor: theme.root.titleBackgroundColor, + textColor: theme.root.titleColor, + fontSize: theme.root.titleFontSize ?? 18, + fontWeight: theme.root.titleFontWeight, + fontFamily: theme.root.titleFontFamily, + offsetY: 0, + align: theme.root.titleAlign, + text: spec.title + }; + } else { + return { + backgroundColor: theme.root.subtitleBackgroundColor, + textColor: theme.root.subtitleColor, + fontSize: theme.root.subtitleFontSize ?? 18, + fontWeight: theme.root.subtitleFontWeight, + fontFamily: theme.root.subtitleFontFamily, + offsetY: 0, + align: theme.root.subtitleAlign, + text: spec.subtitle + }; + } +} diff --git a/demo/trackInfoToCanvas.ts b/demo/trackInfoToCanvas.ts deleted file mode 100644 index a002d9a6b..000000000 --- a/demo/trackInfoToCanvas.ts +++ /dev/null @@ -1,400 +0,0 @@ -import type { PixiManager } from '@pixi-manager'; -import { TextTrack, type TextTrackOptions } from '@gosling-lang/text-track'; -import { DummyTrack, type DummyTrackOptions } from '@gosling-lang/dummy-track'; -import { GoslingTrack } from '@gosling-lang/gosling-track'; -import { AxisTrack, type AxisTrackOptions } from '@gosling-lang/genomic-axis'; -import { signal } from '@preact/signals-core'; -import { DataFetcher } from '@higlass/datafetcher'; -import { fakePubSub } from '@higlass/utils'; -import { BigWigDataFetcher } from '@data-fetchers'; -import { cursor, panZoom } from '@gosling-lang/interactors'; -import type { TrackInfo } from '../src/compiler/bounding-box'; -import { - IsChannelDeep, - IsDummyTrack, - IsTemplateTrack, - IsXAxis, - type AxisPosition, - type OverlaidTrack, - type SingleTrack, - type TemplateTrack, - type Track -} from '@gosling-lang/gosling-schema'; -import type { CompleteThemeDeep } from '../src/core/utils/theme'; -import { resolveSuperposedTracks } from '../src/core/utils/overlay'; -import type { GoslingTrackOptions } from '../src/tracks/gosling-track/gosling-track'; -import { HIGLASS_AXIS_SIZE } from '../src/compiler/higlass-model'; - -function getTextTrackOptions( - spec: Track, - type: 'title' | 'subtitle', - theme: Required -): TextTrackOptions { - if (type === 'title') { - return { - backgroundColor: theme.root.titleBackgroundColor, - textColor: theme.root.titleColor, - fontSize: theme.root.titleFontSize ?? 18, - fontWeight: theme.root.titleFontWeight, - fontFamily: theme.root.titleFontFamily, - offsetY: 0, - align: theme.root.titleAlign, - text: spec.title - }; - } else { - return { - backgroundColor: theme.root.subtitleBackgroundColor, - textColor: theme.root.subtitleColor, - fontSize: theme.root.subtitleFontSize ?? 18, - fontWeight: theme.root.subtitleFontWeight, - fontFamily: theme.root.subtitleFontFamily, - offsetY: 0, - align: theme.root.subtitleAlign, - text: spec.subtitle - }; - } -} - -function getGoslingTrackOptions(spec: Track, theme: Required): GoslingTrackOptions { - return { - spec: spec, - id: '9f4abc56-cb8d-4494-a9ca-56086ab28de2', - siblingIds: ['9f4abc56-cb8d-4494-a9ca-56086ab28de2'], - showMousePosition: true, - mousePositionColor: '#000000', - name: spec.title, - labelPosition: 'topLeft', - labelShowResolution: false, - labelColor: 'black', - labelBackgroundColor: 'white', - labelBackgroundOpacity: 0.5, - labelTextOpacity: 1, - labelLeftMargin: 1, - labelTopMargin: 1, - labelRightMargin: 0, - labelBottomMargin: 0, - backgroundColor: 'transparent', - theme - }; -} - -function getDataFetcher(spec: Track) { - if (!('data' in spec)) { - console.warn('No data in the track spec', spec); - } - if (spec.data.type == 'multivec') { - const url = spec.data.url; - const server = url.split('/').slice(0, -2).join('/'); - const tilesetUid = url.split('=').slice(-1)[0]; - console.warn('server', server, 'tilesetUid', tilesetUid); - return new DataFetcher({ server, tilesetUid }, fakePubSub); - } - if (spec.data.type == 'bigwig') { - return new BigWigDataFetcher(spec.data); - } -} - -function getDummyTrackOptions(spec: Track, theme: Required): DummyTrackOptions { - // TODO - return spec; -} - -enum TrackType { - Text, - Dummy, - Gosling, - Axis, - BrushLinear, - BrushCircular, - Heatmap -} - -interface TrackOptionsMap { - [TrackType.Text]: TextTrackOptions; - [TrackType.Dummy]: DummyTrackOptions; - [TrackType.Gosling]: GoslingTrackOptions; - [TrackType.Axis]: AxisTrackOptions; - [TrackType.BrushLinear]: any; - [TrackType.BrushCircular]: any; - [TrackType.Heatmap]: any; -} - -interface TrackDef { - type: TrackType; - boundingBox: { x: number; y: number; width: number; height: number }; - options: T; -} - -type TrackOptions = { - [K in keyof TrackOptionsMap]: TrackDef; -}[keyof TrackOptionsMap]; - -function getAxisPositions(track: Track): { - xAxisPosition: AxisPosition | undefined; - yAxisPosition: AxisPosition | undefined; -} { - if (IsTemplateTrack(track) || IsDummyTrack(track)) { - return { xAxisPosition: undefined, yAxisPosition: undefined }; - } - - const resolvedSpecs = resolveSuperposedTracks(track); - const firstResolvedSpec = resolvedSpecs[0]; - - const hasXAxis = - ('x' in firstResolvedSpec && - firstResolvedSpec.x && - 'axis' in firstResolvedSpec.x && - firstResolvedSpec.x.axis !== 'none' && - firstResolvedSpec.x.type === 'genomic') || - false; - const hasYAxis = - ('y' in firstResolvedSpec && - firstResolvedSpec.y && - 'axis' in firstResolvedSpec.y && - firstResolvedSpec.y.axis !== 'none' && - firstResolvedSpec.y.type === 'genomic') || - false; - - const xAxisPosition = - hasXAxis && IsChannelDeep(firstResolvedSpec.x) ? (firstResolvedSpec.x?.axis as AxisPosition) : undefined; - const yAxisPosition = - hasYAxis && IsChannelDeep(firstResolvedSpec.y) ? (firstResolvedSpec.y?.axis as AxisPosition) : undefined; - - return { - xAxisPosition, - yAxisPosition - }; -} - -/** - * Separate the the track with mark "_header" into title and subtitle text tracks - * @param track - * @param boundingBox - * @returns - */ -function proccessTextHeader( - track: Track, - boundingBox: { x: number; y: number; width: number; height: number }, - theme: Required -): TrackDef[] { - let cumHeight = 0; - const trackInfosProcessed: TrackDef[] = []; - if (track.title) { - const textTrackOptions = getTextTrackOptions(track, 'title', theme); - const height = textTrackOptions.fontSize + 6; - trackInfosProcessed.push({ - type: TrackType.Text, - boundingBox: { ...boundingBox, height }, - options: textTrackOptions - }); - cumHeight += height; - } - if (track.subtitle) { - const textTrackOptions = getTextTrackOptions(track, 'subtitle', theme); - const height = textTrackOptions.fontSize + 6; - trackInfosProcessed.push({ - type: TrackType.Text, - boundingBox: { ...boundingBox, y: boundingBox.y + cumHeight, height }, - options: textTrackOptions - }); - } - return trackInfosProcessed; -} - -/** - * Generates options for the linear axis track - * @param boundingBox Bounding box of the track - * @param position "top" | "bottom" | "left" | "right - */ -function getAxisTrackLinearOptions( - boundingBox: { x: number; y: number; width: number; height: number }, - position: AxisPosition, - theme: Required -): AxisTrackOptions { - const narrowType = getAxisNarrowType('x', 'horizontal', boundingBox.width, boundingBox.height); - const options: AxisTrackOptions = { - innerRadius: 0, - outerRadius: 0, - width: boundingBox.width, - height: boundingBox.height, - startAngle: 0, - endAngle: 0, - layout: 'linear', - assembly: 'hg38', - stroke: 'transparent', // text outline - color: theme.axis.labelColor, - labelMargin: theme.axis.labelMargin, - excludeChrPrefix: theme.axis.labelExcludeChrPrefix, - fontSize: theme.axis.labelFontSize, - fontFamily: theme.axis.labelFontFamily, - fontWeight: theme.axis.labelFontWeight, - tickColor: theme.axis.tickColor, - tickFormat: narrowType === 'narrower' ? 'si' : 'plain', - tickPositions: narrowType === 'regular' ? 'even' : 'ends', - reverseOrientation: position === 'bottom' || position === 'right' ? true : false - }; - return options; -} - -function getAxisTrackCircularOptions( - track: SingleTrack | OverlaidTrack | TemplateTrack, - boundingBox: { x: number; y: number; width: number; height: number }, - position: AxisPosition, - theme: Required -): AxisTrackOptions { - const narrowType = getAxisNarrowType('x', 'horizontal', boundingBox.width, boundingBox.height); - const { startAngle, endAngle, outerRadius } = track; - let { innerRadius } = track; - if (position === 'top') { - innerRadius = outerRadius - 30; - } else if (position === 'left' || position === 'right') { - console.error('Axis position left or right is not supported in circular layout'); - } - - const options: AxisTrackOptions = { - layout: 'circular', - innerRadius, - outerRadius, - width: boundingBox.width, - height: boundingBox.height, - startAngle, - endAngle, - assembly: 'hg38', - stroke: 'transparent', // text outline - color: theme.axis.labelColor, - labelMargin: theme.axis.labelMargin, - excludeChrPrefix: theme.axis.labelExcludeChrPrefix, - fontSize: theme.axis.labelFontSize, - fontFamily: theme.axis.labelFontFamily, - fontWeight: theme.axis.labelFontWeight, - tickColor: theme.axis.tickColor, - tickFormat: narrowType === 'narrower' ? 'si' : 'plain', - tickPositions: narrowType === 'regular' ? 'even' : 'ends', - reverseOrientation: position === 'bottom' || position === 'right' ? true : false - }; - return options; -} - -function processGoslingTrack( - track: Track, - boundingBox: { x: number; y: number; width: number; height: number }, - theme: Required -): (TrackDef | TrackDef)[] { - const trackInfosProcessed: (TrackDef | TrackDef)[] = []; - - const { xAxisPosition, yAxisPosition } = getAxisPositions(track); - if (xAxisPosition) { - if (track.layout === 'linear') { - const isHorizontal = track.orientation === 'horizontal'; - const widthOrHeight = isHorizontal ? 'height' : 'width'; - const axisBbox = { ...boundingBox, [widthOrHeight]: HIGLASS_AXIS_SIZE }; - boundingBox[widthOrHeight] -= axisBbox[widthOrHeight]; - if (xAxisPosition === 'top') { - boundingBox.y += axisBbox.height; - } else if (xAxisPosition === 'bottom') { - axisBbox.y = boundingBox.y + boundingBox.height; - } else if (xAxisPosition === 'right') { - axisBbox.x = boundingBox.x + boundingBox.width; - } else if (xAxisPosition === 'left') { - boundingBox.x += axisBbox.width; - } - trackInfosProcessed.push({ - type: TrackType.Axis, - boundingBox: axisBbox, - options: getAxisTrackLinearOptions(axisBbox, xAxisPosition, theme) - }); - } else if (track.layout === 'circular') { - trackInfosProcessed.push({ - type: TrackType.Axis, - boundingBox: boundingBox, - options: getAxisTrackCircularOptions(track, boundingBox, xAxisPosition, theme) - }); - } - } - - const goslingTrackOptions = getGoslingTrackOptions(track, theme); - - trackInfosProcessed.push({ - type: TrackType.Gosling, - boundingBox: { ...boundingBox }, - options: goslingTrackOptions - }); - - return trackInfosProcessed; -} - -export function trackInfoToTracks( - trackInfos: TrackInfo[], - pixiManager: PixiManager, - theme: Required -) { - const trackInfosProcessed: TrackOptions[] = []; - trackInfos.forEach(trackInfo => { - const { track, boundingBox } = trackInfo; - // console.warn('boundingBox', boundingBox); - // const div = pixiManager.makeContainer(boundingBox).overlayDiv; - // div.style.border = '3px solid red'; - // div.innerHTML = track.mark || 'No mark'; - // div.style.textAlign = 'left'; - - // Header marks contain both the title and subtitle - if (track.mark === '_header') { - const trackOptions = proccessTextHeader(track, boundingBox, theme); - trackInfosProcessed.push(...trackOptions); - } else { - const trackOptions = processGoslingTrack(track, boundingBox, theme); - trackInfosProcessed.push(...trackOptions); - } - }); - - const domain = signal<[number, number]>([0, 3088269832]); - trackInfosProcessed.forEach(trackInfo => { - const { boundingBox, type } = trackInfo; - // console.warn('boundingBox', boundingBox); - // const div = pixiManager.makeContainer(boundingBox).overlayDiv; - // div.style.border = '1px solid black'; - // div.innerHTML = TrackType[type] || 'No mark'; - - if (type === TrackType.Text) { - new TextTrack(trackInfo.options, pixiManager.makeContainer(boundingBox)); - } - if (type === TrackType.Gosling) { - const datafetcher = getDataFetcher(trackInfo.options.spec); - new GoslingTrack(trackInfo.options, datafetcher, pixiManager.makeContainer(boundingBox)).addInteractor( - plot => panZoom(plot, domain) - ); - } - if (type === TrackType.Axis) { - new AxisTrack(trackInfo.options, domain, pixiManager.makeContainer(boundingBox)); - } - }); -} - -// determine the compactness type of an axis considering the size of a track -export const getAxisNarrowType = ( - c: 'x' | 'y', - orientation: 'horizontal' | 'vertical' = 'horizontal', - width: number, - height: number -) => { - const narrowSizeThreshold = 400; - const narrowerSizeThreshold = 200; - - if (orientation === 'horizontal') { - if ((c === 'x' && width <= narrowerSizeThreshold) || (c === 'y' && height <= narrowerSizeThreshold)) { - return 'narrower'; - } else if ((c === 'x' && width <= narrowSizeThreshold) || (c === 'y' && height <= narrowSizeThreshold)) { - return 'narrow'; - } else { - return 'regular'; - } - } else { - if ((c === 'x' && height <= narrowerSizeThreshold) || (c === 'y' && width <= narrowerSizeThreshold)) { - return 'narrower'; - } else if ((c === 'x' && height <= narrowSizeThreshold) || (c === 'y' && width <= narrowSizeThreshold)) { - return 'narrow'; - } else { - return 'regular'; - } - } -}; From 124a9e4eb5a541490072fda4607a1cad02522afd Mon Sep 17 00:00:00 2001 From: etowahadams Date: Tue, 18 Jun 2024 13:19:32 -0400 Subject: [PATCH 048/139] feat: beddb data --- demo/renderer/dataFetcher.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/demo/renderer/dataFetcher.ts b/demo/renderer/dataFetcher.ts index 558538e86..5106cb096 100644 --- a/demo/renderer/dataFetcher.ts +++ b/demo/renderer/dataFetcher.ts @@ -6,11 +6,10 @@ export function getDataFetcher(spec: Track) { if (!('data' in spec)) { console.warn('No data in the track spec', spec); } - if (spec.data.type == 'multivec') { + if (spec.data.type == 'multivec' || spec.data.type == 'beddb') { const url = spec.data.url; const server = url.split('/').slice(0, -2).join('/'); const tilesetUid = url.split('=').slice(-1)[0]; - console.warn('server', server, 'tilesetUid', tilesetUid); return new DataFetcher({ server, tilesetUid }, fakePubSub); } if (spec.data.type == 'bigwig') { From 88a8b9fbe0cf5c9539b31b1bf2ff84ead9875f3f Mon Sep 17 00:00:00 2001 From: etowahadams Date: Tue, 18 Jun 2024 13:20:05 -0400 Subject: [PATCH 049/139] feat: don't show labels on overlay tracks --- demo/renderer/gosling.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/demo/renderer/gosling.ts b/demo/renderer/gosling.ts index 9a4d108f9..95dda0abf 100644 --- a/demo/renderer/gosling.ts +++ b/demo/renderer/gosling.ts @@ -39,7 +39,7 @@ function getGoslingTrackOptions(spec: Track, theme: Required) showMousePosition: true, mousePositionColor: '#000000', name: spec.title, - labelPosition: 'topLeft', + labelPosition: spec.overlayOnPreviousTrack ? 'none' : 'topLeft', labelShowResolution: false, labelColor: 'black', labelBackgroundColor: 'white', @@ -50,6 +50,6 @@ function getGoslingTrackOptions(spec: Track, theme: Required) labelRightMargin: 0, labelBottomMargin: 0, backgroundColor: 'transparent', - theme + theme: theme }; } From 7b68785dd088cc8149e5f002995c13c2421d25f1 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Tue, 18 Jun 2024 13:21:03 -0400 Subject: [PATCH 050/139] refactor: rendering --- demo/renderer/main.ts | 41 ++++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index 9a76a24ac..d5a06c132 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -13,6 +13,7 @@ import type { GoslingTrackOptions } from '../../src/tracks/gosling-track/gosling import { proccessTextHeader } from './text'; import { processGoslingTrack } from './gosling'; import { getDataFetcher } from './dataFetcher'; +import { CsvDataFetcher } from '@data-fetchers'; /** * All the different types of tracks that can be rendered @@ -56,6 +57,16 @@ type TrackDefs = { [K in keyof TrackOptionsMap]: TrackDef; }[keyof TrackOptionsMap]; +export function showTrackInfoPositions(trackInfos: TrackInfo[], pixiManager: PixiManager) { + trackInfos.forEach(trackInfo => { + const { track, boundingBox } = trackInfo; + const div = pixiManager.makeContainer(boundingBox).overlayDiv; + div.style.border = '3px solid red'; + div.innerHTML = track.mark || 'No mark'; + div.style.textAlign = 'left'; + }); +} + /** * Takes a list of TrackInfos and returns a list of TrackOptions * @param trackInfos @@ -63,19 +74,10 @@ type TrackDefs = { * @param theme * @returns */ -export function createTrackDefs( - trackInfos: TrackInfo[], - pixiManager: PixiManager, - theme: Required -): TrackDefs[] { +export function createTrackDefs(trackInfos: TrackInfo[], theme: Required): TrackDefs[] { const trackDefs: TrackDefs[] = []; trackInfos.forEach(trackInfo => { const { track, boundingBox } = trackInfo; - // console.warn('boundingBox', boundingBox); - // const div = pixiManager.makeContainer(boundingBox).overlayDiv; - // div.style.border = '3px solid red'; - // div.innerHTML = track.mark || 'No mark'; - // div.style.textAlign = 'left'; // Header marks contain both the title and subtitle if (track.mark === '_header') { @@ -94,26 +96,27 @@ export function createTrackDefs( * @param trackOptions * @param pixiManager */ -export function renderTrackDefs(trackOptions: TrackDefs[], pixiManager: PixiManager) { - const domain = signal<[number, number]>([0, 3088269832]); - trackOptions.forEach(trackInfo => { - const { boundingBox, type } = trackInfo; +export function renderTrackDefs(trackDefs: TrackDefs[], pixiManager: PixiManager) { + const domain = signal<[number, number]>([491149952, 689445510]); + + trackDefs.forEach(trackDef => { + const { boundingBox, type, options } = trackDef; // console.warn('boundingBox', boundingBox); // const div = pixiManager.makeContainer(boundingBox).overlayDiv; // div.style.border = '1px solid black'; // div.innerHTML = TrackType[type] || 'No mark'; if (type === TrackType.Text) { - new TextTrack(trackInfo.options, pixiManager.makeContainer(boundingBox)); + new TextTrack(options, pixiManager.makeContainer(boundingBox)); } if (type === TrackType.Gosling) { - const datafetcher = getDataFetcher(trackInfo.options.spec); - new GoslingTrack(trackInfo.options, datafetcher, pixiManager.makeContainer(boundingBox)).addInteractor( - plot => panZoom(plot, domain) + const datafetcher = getDataFetcher(options.spec); + new GoslingTrack(options, datafetcher, pixiManager.makeContainer(boundingBox)).addInteractor(plot => + panZoom(plot, domain) ); } if (type === TrackType.Axis) { - new AxisTrack(trackInfo.options, domain, pixiManager.makeContainer(boundingBox)); + new AxisTrack(options, domain, pixiManager.makeContainer(boundingBox)); } }); } From 90455f21b371df08d85f612206ee779dd508c52a Mon Sep 17 00:00:00 2001 From: etowahadams Date: Tue, 18 Jun 2024 13:30:04 -0400 Subject: [PATCH 051/139] feat: corces example --- demo/App.tsx | 1069 +++++++++----------------------------------------- 1 file changed, 194 insertions(+), 875 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index f21a9e870..4436979a0 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -14,7 +14,7 @@ import { getTheme } from '../src/core/utils/theme'; import './App.css'; import type { HiGlassSpec } from '@gosling-lang/higlass-schema'; -import { createTrackDefs, renderTrackDefs } from './renderer/main'; +import { createTrackDefs, renderTrackDefs, showTrackInfoPositions } from './renderer/main'; import type { TrackInfo } from 'src/compiler/bounding-box'; function App() { @@ -44,12 +44,13 @@ function App() { theme: Require ) => { console.warn(trackInfos); - const trackDefs = createTrackDefs(trackInfos, pixiManager, theme); + // showTrackInfoPositions(trackInfos, pixiManager); + const trackDefs = createTrackDefs(trackInfos, theme); renderTrackDefs(trackDefs, pixiManager); }; // Compile the spec - compile(spec, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); + compile(corces, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); }, []); return ( @@ -106,114 +107,172 @@ const spec = { } ] }; -const spec2 = { + +const corces = { + title: 'Single-cell Epigenomic Analysis', + subtitle: 'Corces et al. 2020', layout: 'linear', - xDomain: { chromosome: 'chr3', interval: [52168000, 52890000] }, - arrangement: 'horizontal', + arrangement: 'vertical', views: [ { - arrangement: 'vertical', - views: [ + layout: 'linear', + xDomain: { chromosome: 'chr3' }, + centerRadius: 0.8, + tracks: [ { alignment: 'overlay', - title: 'HiGlass', + title: 'chr3', data: { - url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', - type: 'beddb', - genomicFields: [ - { index: 1, name: 'start' }, - { index: 2, name: 'end' } - ], - valueFields: [ - { index: 5, name: 'strand', type: 'nominal' }, - { index: 3, name: 'name', type: 'nominal' } - ], - exonIntervalFields: [ - { index: 12, name: 'start' }, - { index: 13, name: 'end' } - ] + url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/cytogenetic_band.csv', + type: 'csv', + chromosomeField: 'Chr.', + genomicFields: ['ISCN_start', 'ISCN_stop', 'Basepair_start', 'Basepair_stop'] }, tracks: [ { + mark: 'rect', dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['+'] } + { + type: 'filter', + field: 'Stain', + oneOf: ['acen-1', 'acen-2'], + not: true + } ], - mark: 'triangleRight', - x: { field: 'end', type: 'genomic', axis: 'top' }, - size: { value: 15 } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], - mark: 'text', - text: { field: 'name', type: 'nominal' }, - x: { field: 'start', type: 'genomic' }, - xe: { field: 'end', type: 'genomic' }, - style: { dy: -15 } + color: { + field: 'Density', + type: 'nominal', + domain: ['', '25', '50', '75', '100'], + range: ['white', '#D9D9D9', '#979797', '#636363', 'black'] + }, + size: { value: 20 } }, { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['-'] } - ], - mark: 'triangleLeft', - x: { field: 'start', type: 'genomic' }, - size: { value: 15 }, - style: { align: 'right' } + mark: 'rect', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['gvar'] }], + color: { value: '#A0A0F2' }, + size: { value: 20 } }, { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['exon'] }], - mark: 'rect', - x: { field: 'start', type: 'genomic' }, - size: { value: 15 }, - xe: { field: 'end', type: 'genomic' } + mark: 'triangleRight', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-1'] }], + color: { value: '#B40101' }, + size: { value: 20 } }, { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['+'] } - ], - mark: 'rule', - x: { field: 'start', type: 'genomic' }, - strokeWidth: { value: 3 }, - xe: { field: 'end', type: 'genomic' }, - style: { linePattern: { type: 'triangleRight', size: 5 } } + mark: 'triangleLeft', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-2'] }], + color: { value: '#B40101' }, + size: { value: 20 } }, { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['-'] } - ], - mark: 'rule', - x: { field: 'start', type: 'genomic' }, - strokeWidth: { value: 3 }, - xe: { field: 'end', type: 'genomic' }, - style: { linePattern: { type: 'triangleLeft', size: 5 } } + mark: 'brush', + x: { linkingId: 'detail' }, + color: { value: 'red' }, + opacity: { value: 0.3 }, + strokeWidth: { value: 1 }, + stroke: { value: 'red' } } ], - row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, - color: { - field: 'strand', - type: 'nominal', - domain: ['+', '-'], - range: ['#7585FF', '#FF8A85'] + x: { field: 'Basepair_start', type: 'genomic', axis: 'none' }, + xe: { field: 'Basepair_stop', type: 'genomic' }, + stroke: { value: 'black' }, + strokeWidth: { value: 1 }, + style: { outlineWidth: 0 }, + width: 400, + height: 25 + } + ] + }, + { + xDomain: { chromosome: 'chr3', interval: [52168000, 52890000] }, + linkingId: 'detail', + x: { field: 'position', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative', axis: 'right' }, + style: { outline: '#20102F' }, + width: 400, + height: 40, + tracks: [ + { + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/ExcitatoryNeurons-insertions_bin100_RIPnorm.bw', + type: 'bigwig', + column: 'position', + value: 'peak' }, - visibility: [ - { - operation: 'less-than', - measure: 'width', - threshold: '|xe-x|', - transitionPadding: 10, - target: 'mark' - } - ], - opacity: { value: 0.8 }, - width: 350, - height: 100 + title: 'Excitatory neurons', + mark: 'bar', + color: { value: '#F29B67' } + }, + { + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/InhibitoryNeurons-insertions_bin100_RIPnorm.bw', + type: 'bigwig', + column: 'position', + value: 'peak' + }, + title: 'Inhibitory neurons', + mark: 'bar', + color: { value: '#3DC491' } + }, + { + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/DopaNeurons_Cluster10_AllFrags_projSUNI2_insertions_bin100_RIPnorm.bw', + type: 'bigwig', + column: 'position', + value: 'peak' + }, + title: 'Dopaminergic neurons', + mark: 'bar', + color: { value: '#565C8B' } + }, + { + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/Microglia-insertions_bin100_RIPnorm.bw', + type: 'bigwig', + column: 'position', + value: 'peak' + }, + title: 'Microglia', + mark: 'bar', + color: { value: '#77C0FA' } + }, + { + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/Oligodendrocytes-insertions_bin100_RIPnorm.bw', + type: 'bigwig', + column: 'position', + value: 'peak' + }, + title: 'Oligodendrocytes', + mark: 'bar', + color: { value: '#9B46E5' } + }, + { + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/Astrocytes-insertions_bin100_RIPnorm.bw', + type: 'bigwig', + column: 'position', + value: 'peak' + }, + title: 'Astrocytes', + mark: 'bar', + color: { value: '#D73636' } + }, + { + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/OPCs-insertions_bin100_RIPnorm.bw', + type: 'bigwig', + column: 'position', + value: 'peak' + }, + title: 'OPCs', + mark: 'bar', + color: { value: '#E38ADC' } }, { alignment: 'overlay', - title: 'Corces et al.', + title: 'Genes', data: { url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', type: 'beddb', @@ -230,6 +289,7 @@ const spec2 = { { index: 13, name: 'end' } ] }, + style: { outline: '#20102F' }, tracks: [ { dataTransform: [ @@ -239,8 +299,8 @@ const spec2 = { mark: 'text', text: { field: 'name', type: 'nominal' }, x: { field: 'start', type: 'genomic' }, - xe: { field: 'end', type: 'genomic' }, size: { value: 8 }, + xe: { field: 'end', type: 'genomic' }, style: { textFontSize: 8, dy: -12 } }, { @@ -304,815 +364,74 @@ const spec2 = { target: 'mark' } ], - width: 350, - height: 100 + width: 400, + height: 80 }, { - alignment: 'overlay', - title: 'IGV', + title: 'PLAC-seq (H3K4me3) Nott et al.', data: { - url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=oligodendrocyte-plac-seq-bedpe', type: 'beddb', genomicFields: [ - { index: 1, name: 'start' }, - { index: 2, name: 'end' } - ], - valueFields: [ - { index: 5, name: 'strand', type: 'nominal' }, - { index: 3, name: 'name', type: 'nominal' } - ], - exonIntervalFields: [ - { index: 12, name: 'start' }, - { index: 13, name: 'end' } + { name: 'start', index: 1 }, + { name: 'end', index: 2 } ] }, - tracks: [ - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], - mark: 'text', - text: { field: 'name', type: 'nominal' }, - x: { field: 'start', type: 'genomic', axis: 'top' }, - xe: { field: 'end', type: 'genomic' } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], - mark: 'rect', - x: { field: 'start', type: 'genomic', axis: 'top' }, - size: { value: 15 }, - xe: { field: 'end', type: 'genomic' } - }, - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['-'] } - ], - mark: 'rule', - x: { field: 'start', type: 'genomic', axis: 'top' }, - strokeWidth: { value: 0 }, - xe: { field: 'end', type: 'genomic' }, - color: { value: 'white' }, - opacity: { value: 0.6 }, - style: { linePattern: { type: 'triangleLeft', size: 10 } } - }, - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['+'] } - ], - mark: 'rule', - x: { field: 'start', type: 'genomic', axis: 'top' }, - strokeWidth: { value: 0 }, - xe: { field: 'end', type: 'genomic' }, - color: { value: 'white' }, - opacity: { value: 0.6 }, - style: { linePattern: { type: 'triangleRight', size: 10 } } - } - ], - row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, - color: { value: '#0900B1' }, - visibility: [ - { - operation: 'less-than', - measure: 'width', - threshold: '|xe-x|', - transitionPadding: 10, - target: 'mark' - } - ], - width: 350, - height: 100 - } - ] - }, - { - arrangement: 'vertical', - views: [ + mark: 'withinLink', + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + y: { flip: true }, + strokeWidth: { value: 1 }, + color: { value: 'none' }, + stroke: { value: '#F97E2A' }, + opacity: { value: 0.1 }, + overlayOnPreviousTrack: false, + width: 400, + height: 60 + }, { - alignment: 'overlay', - title: 'Cyverse-QUBES', + title: '', data: { - url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=microglia-plac-seq-bedpe', type: 'beddb', genomicFields: [ - { index: 1, name: 'start' }, - { index: 2, name: 'end' } - ], - valueFields: [ - { index: 5, name: 'strand', type: 'nominal' }, - { index: 3, name: 'name', type: 'nominal' } - ], - exonIntervalFields: [ - { index: 12, name: 'start' }, - { index: 13, name: 'end' } + { name: 'start', index: 1 }, + { name: 'end', index: 2 } ] }, - tracks: [ - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], - mark: 'text', - text: { field: 'name', type: 'nominal' }, - x: { field: 'start', type: 'genomic' }, - xe: { field: 'end', type: 'genomic' }, - color: { value: 'black' } - }, - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['+'] } - ], - mark: 'triangleRight', - x: { field: 'end', type: 'genomic', axis: 'top' }, - color: { value: '#999999' } - }, - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['-'] } - ], - mark: 'triangleLeft', - x: { field: 'start', type: 'genomic', axis: 'top' }, - color: { value: '#999999' }, - style: { align: 'right' } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], - mark: 'rect', - x: { field: 'start', type: 'genomic', axis: 'top' }, - xe: { field: 'end', type: 'genomic' }, - color: { value: 'lightgray' } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], - mark: 'rule', - x: { field: 'start', type: 'genomic', axis: 'top' }, - strokeWidth: { value: 5 }, - xe: { field: 'end', type: 'genomic' }, - color: { value: 'gray' } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['exon'] }], - mark: 'rect', - x: { field: 'start', type: 'genomic', axis: 'top' }, - xe: { field: 'end', type: 'genomic' }, - color: { value: '#E2A6F5' }, - stroke: { value: '#BB57C9' }, - strokeWidth: { value: 1 } - } - ], - row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, - visibility: [ - { - operation: 'less-than', - measure: 'width', - threshold: '|xe-x|', - transitionPadding: 10, - target: 'mark' - } - ], - size: { value: 15 }, - width: 350, - height: 100 + mark: 'withinLink', + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + y: { flip: true }, + strokeWidth: { value: 1 }, + color: { value: 'none' }, + stroke: { value: '#50ADF9' }, + opacity: { value: 0.1 }, + overlayOnPreviousTrack: true, + width: 400, + height: 60 }, { - alignment: 'overlay', - title: 'GmGDV', + title: '', data: { - url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=neuron-plac-seq-bedpe', type: 'beddb', genomicFields: [ - { index: 1, name: 'start' }, - { index: 2, name: 'end' } - ], - valueFields: [ - { index: 5, name: 'strand', type: 'nominal' }, - { index: 3, name: 'name', type: 'nominal' } - ], - exonIntervalFields: [ - { index: 12, name: 'start' }, - { index: 13, name: 'end' } - ] - }, - tracks: [ - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], - mark: 'text', - text: { field: 'name', type: 'nominal' }, - x: { field: 'start', type: 'genomic', axis: 'top' }, - xe: { field: 'end', type: 'genomic' }, - style: { dy: -14 } - }, - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['+'] } - ], - mark: 'triangleRight', - x: { field: 'end', type: 'genomic', axis: 'top' }, - size: { value: 15 } - }, - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['-'] } - ], - mark: 'triangleLeft', - x: { field: 'start', type: 'genomic', axis: 'top' }, - size: { value: 15 }, - style: { align: 'right' } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['exon'] }], - mark: 'rect', - x: { field: 'start', type: 'genomic', axis: 'top' }, - size: { value: 10 }, - xe: { field: 'end', type: 'genomic' } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], - mark: 'rule', - x: { field: 'start', type: 'genomic', axis: 'top' }, - strokeWidth: { value: 3 }, - xe: { field: 'end', type: 'genomic' } - } - ], - row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, - color: { - field: 'strand', - type: 'nominal', - domain: ['+', '-'], - range: ['blue', 'red'] - }, - visibility: [ - { - operation: 'less-than', - measure: 'width', - threshold: '|xe-x|', - transitionPadding: 10, - target: 'mark' - } - ], - width: 350, - height: 100 - }, - { - alignment: 'overlay', - data: { - url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', - type: 'beddb', - genomicFields: [ - { index: 1, name: 'start' }, - { index: 2, name: 'end' } - ], - valueFields: [ - { index: 5, name: 'strand', type: 'nominal' }, - { index: 3, name: 'name', type: 'nominal' } - ], - exonIntervalFields: [ - { index: 12, name: 'start' }, - { index: 13, name: 'end' } + { name: 'start', index: 1 }, + { name: 'end', index: 2 } ] }, - tracks: [ - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], - mark: 'text', - text: { field: 'name', type: 'nominal' }, - x: { field: 'start', type: 'genomic', axis: 'top' }, - color: { value: 'black' }, - xe: { field: 'end', type: 'genomic' } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], - mark: 'rect', - x: { field: 'start', type: 'genomic', axis: 'top' }, - xe: { field: 'end', type: 'genomic' }, - color: { value: '#666666' } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['exon'] }], - mark: 'rect', - x: { field: 'start', type: 'genomic', axis: 'top' }, - xe: { field: 'end', type: 'genomic' }, - color: { value: '#FF6666' } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['intron'] }], - mark: 'rect', - x: { field: 'start', type: 'genomic', axis: 'top' }, - xe: { field: 'end', type: 'genomic' }, - color: { value: '#99FEFF' } - } - ], - size: { value: 30 }, - row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, - stroke: { value: '#777777' }, + mark: 'withinLink', + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + y: { flip: true }, strokeWidth: { value: 1 }, - visibility: [ - { - operation: 'less-than', - measure: 'width', - threshold: '|xe-x|', - transitionPadding: 10, - target: 'mark' - } - ], - width: 350, - height: 100 - } - ] - } - ] -}; -const spec3 = { - title: 'Basic Marks: bar', - subtitle: 'Tutorial Examples', - orientation: 'horizontal', - tracks: [ - { - layout: 'linear', - width: 180, - height: 800, - data: { - url: 'https://resgen.io/api/v1/tileset_info/?d=UvVPeLHuRDiYA3qwFlm7xQ', - type: 'multivec', - row: 'sample', - column: 'position', - value: 'peak', - categories: ['sample 1'], - binSize: 5 - }, - mark: 'bar', - x: { field: 'start', type: 'genomic', axis: 'bottom' }, - xe: { field: 'end', type: 'genomic' }, - y: { field: 'peak', type: 'quantitative', axis: 'right' }, - size: { value: 5 } - } - ] -}; - -const spec4 = { - static: true, - layout: 'linear', - centerRadius: 0.2, - arrangement: 'parallel', - views: [ - { - xDomain: { chromosome: 'chr1' }, - tracks: [ - { - data: { - url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=cistrome-multivec', - type: 'multivec', - row: 'sample', - column: 'position', - value: 'peak', - categories: ['sample 1', 'sample 2', 'sample 3', 'sample 4'] - }, - mark: 'area', - x: { field: 'position', type: 'genomic' }, - y: { field: 'peak', type: 'quantitative' }, - color: { field: 'sample', type: 'nominal' }, - width: 1000, - height: 30 - }, - { - alignment: 'overlay', - data: { - url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/cytogenetic_band.csv', - type: 'csv', - chromosomeField: 'Chr.', - genomicFields: ['ISCN_start', 'ISCN_stop', 'Basepair_start', 'Basepair_stop'] - }, - tracks: [ - { - mark: 'text', - dataTransform: [ - { - type: 'filter', - field: 'Stain', - oneOf: ['acen-1', 'acen-2'], - not: true - } - ], - text: { field: 'Band', type: 'nominal' }, - color: { value: 'black' }, - visibility: [ - { - operation: 'less-than', - measure: 'width', - threshold: '|xe-x|', - transitionPadding: 10, - target: 'mark' - } - ] - }, - { - mark: 'rect', - dataTransform: [ - { - type: 'filter', - field: 'Stain', - oneOf: ['acen-1', 'acen-2'], - not: true - } - ], - color: { - field: 'Density', - type: 'nominal', - domain: ['', '25', '50', '75', '100'], - range: ['white', '#D9D9D9', '#979797', '#636363', 'black'] - } - }, - { - mark: 'rect', - dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['gvar'] }], - color: { value: '#A0A0F2' } - }, - { - mark: 'triangleRight', - dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-1'] }], - color: { value: '#B40101' } - }, - { - mark: 'triangleLeft', - dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-2'] }], - color: { value: '#B40101' } - } - ], - x: { field: 'Basepair_start', type: 'genomic' }, - xe: { field: 'Basepair_stop', type: 'genomic' }, - stroke: { value: 'gray' }, - strokeWidth: { value: 0.5 }, - width: 1000, - height: 20 - } - ] - }, - { - xDomain: { chromosome: 'chr2' }, - tracks: [ - { - data: { - url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=cistrome-multivec', - type: 'multivec', - row: 'sample', - column: 'position', - value: 'peak', - categories: ['sample 1', 'sample 2', 'sample 3', 'sample 4'] - }, - mark: 'area', - x: { field: 'position', type: 'genomic' }, - y: { field: 'peak', type: 'quantitative' }, - color: { field: 'sample', type: 'nominal' }, - width: 970, - height: 30 - }, - { - alignment: 'overlay', - data: { - url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/cytogenetic_band.csv', - type: 'csv', - chromosomeField: 'Chr.', - genomicFields: ['ISCN_start', 'ISCN_stop', 'Basepair_start', 'Basepair_stop'] - }, - tracks: [ - { - mark: 'text', - dataTransform: [ - { - type: 'filter', - field: 'Stain', - oneOf: ['acen-1', 'acen-2'], - not: true - } - ], - text: { field: 'Band', type: 'nominal' }, - color: { value: 'black' }, - visibility: [ - { - operation: 'less-than', - measure: 'width', - threshold: '|xe-x|', - transitionPadding: 10, - target: 'mark' - } - ] - }, - { - mark: 'rect', - dataTransform: [ - { - type: 'filter', - field: 'Stain', - oneOf: ['acen-1', 'acen-2'], - not: true - } - ], - color: { - field: 'Density', - type: 'nominal', - domain: ['', '25', '50', '75', '100'], - range: ['white', '#D9D9D9', '#979797', '#636363', 'black'] - } - }, - { - mark: 'rect', - dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['gvar'] }], - color: { value: '#A0A0F2' } - }, - { - mark: 'triangleRight', - dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-1'] }], - color: { value: '#B40101' } - }, - { - mark: 'triangleLeft', - dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-2'] }], - color: { value: '#B40101' } - } - ], - x: { field: 'Basepair_start', type: 'genomic' }, - xe: { field: 'Basepair_stop', type: 'genomic' }, - stroke: { value: 'gray' }, - strokeWidth: { value: 0.5 }, - width: 970, - height: 20 - } - ] - }, - { - xDomain: { chromosome: 'chr3' }, - tracks: [ - { - data: { - url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=cistrome-multivec', - type: 'multivec', - row: 'sample', - column: 'position', - value: 'peak', - categories: ['sample 1', 'sample 2', 'sample 3', 'sample 4'] - }, - mark: 'area', - x: { field: 'position', type: 'genomic' }, - y: { field: 'peak', type: 'quantitative' }, - color: { field: 'sample', type: 'nominal' }, - width: 800, - height: 30 - }, - { - alignment: 'overlay', - data: { - url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/cytogenetic_band.csv', - type: 'csv', - chromosomeField: 'Chr.', - genomicFields: ['ISCN_start', 'ISCN_stop', 'Basepair_start', 'Basepair_stop'] - }, - tracks: [ - { - mark: 'text', - dataTransform: [ - { - type: 'filter', - field: 'Stain', - oneOf: ['acen-1', 'acen-2'], - not: true - } - ], - text: { field: 'Band', type: 'nominal' }, - color: { value: 'black' }, - visibility: [ - { - operation: 'less-than', - measure: 'width', - threshold: '|xe-x|', - transitionPadding: 10, - target: 'mark' - } - ] - }, - { - mark: 'rect', - dataTransform: [ - { - type: 'filter', - field: 'Stain', - oneOf: ['acen-1', 'acen-2'], - not: true - } - ], - color: { - field: 'Density', - type: 'nominal', - domain: ['', '25', '50', '75', '100'], - range: ['white', '#D9D9D9', '#979797', '#636363', 'black'] - } - }, - { - mark: 'rect', - dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['gvar'] }], - color: { value: '#A0A0F2' } - }, - { - mark: 'triangleRight', - dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-1'] }], - color: { value: '#B40101' } - }, - { - mark: 'triangleLeft', - dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-2'] }], - color: { value: '#B40101' } - } - ], - x: { field: 'Basepair_start', type: 'genomic' }, - xe: { field: 'Basepair_stop', type: 'genomic' }, - stroke: { value: 'gray' }, - strokeWidth: { value: 0.5 }, - width: 800, - height: 20 - } - ] - }, - { - xDomain: { chromosome: 'chr4' }, - tracks: [ - { - data: { - url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=cistrome-multivec', - type: 'multivec', - row: 'sample', - column: 'position', - value: 'peak', - categories: ['sample 1', 'sample 2', 'sample 3', 'sample 4'] - }, - mark: 'area', - x: { field: 'position', type: 'genomic' }, - y: { field: 'peak', type: 'quantitative' }, - color: { field: 'sample', type: 'nominal' }, - width: 770, - height: 30 - }, - { - alignment: 'overlay', - data: { - url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/cytogenetic_band.csv', - type: 'csv', - chromosomeField: 'Chr.', - genomicFields: ['ISCN_start', 'ISCN_stop', 'Basepair_start', 'Basepair_stop'] - }, - tracks: [ - { - mark: 'text', - dataTransform: [ - { - type: 'filter', - field: 'Stain', - oneOf: ['acen-1', 'acen-2'], - not: true - } - ], - text: { field: 'Band', type: 'nominal' }, - color: { value: 'black' }, - visibility: [ - { - operation: 'less-than', - measure: 'width', - threshold: '|xe-x|', - transitionPadding: 10, - target: 'mark' - } - ] - }, - { - mark: 'rect', - dataTransform: [ - { - type: 'filter', - field: 'Stain', - oneOf: ['acen-1', 'acen-2'], - not: true - } - ], - color: { - field: 'Density', - type: 'nominal', - domain: ['', '25', '50', '75', '100'], - range: ['white', '#D9D9D9', '#979797', '#636363', 'black'] - } - }, - { - mark: 'rect', - dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['gvar'] }], - color: { value: '#A0A0F2' } - }, - { - mark: 'triangleRight', - dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-1'] }], - color: { value: '#B40101' } - }, - { - mark: 'triangleLeft', - dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-2'] }], - color: { value: '#B40101' } - } - ], - x: { field: 'Basepair_start', type: 'genomic' }, - xe: { field: 'Basepair_stop', type: 'genomic' }, - stroke: { value: 'gray' }, - strokeWidth: { value: 0.5 }, - width: 770, - height: 20 - } - ] - }, - { - xDomain: { chromosome: 'chr5' }, - tracks: [ - { - data: { - url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=cistrome-multivec', - type: 'multivec', - row: 'sample', - column: 'position', - value: 'peak', - categories: ['sample 1', 'sample 2', 'sample 3', 'sample 4'] - }, - mark: 'area', - x: { field: 'position', type: 'genomic' }, - y: { field: 'peak', type: 'quantitative' }, - color: { field: 'sample', type: 'nominal' }, - width: 740, - height: 30 - }, - { - alignment: 'overlay', - data: { - url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/cytogenetic_band.csv', - type: 'csv', - chromosomeField: 'Chr.', - genomicFields: ['ISCN_start', 'ISCN_stop', 'Basepair_start', 'Basepair_stop'] - }, - tracks: [ - { - mark: 'text', - dataTransform: [ - { - type: 'filter', - field: 'Stain', - oneOf: ['acen-1', 'acen-2'], - not: true - } - ], - text: { field: 'Band', type: 'nominal' }, - color: { value: 'black' }, - visibility: [ - { - operation: 'less-than', - measure: 'width', - threshold: '|xe-x|', - transitionPadding: 10, - target: 'mark' - } - ] - }, - { - mark: 'rect', - dataTransform: [ - { - type: 'filter', - field: 'Stain', - oneOf: ['acen-1', 'acen-2'], - not: true - } - ], - color: { - field: 'Density', - type: 'nominal', - domain: ['', '25', '50', '75', '100'], - range: ['white', '#D9D9D9', '#979797', '#636363', 'black'] - } - }, - { - mark: 'rect', - dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['gvar'] }], - color: { value: '#A0A0F2' } - }, - { - mark: 'triangleRight', - dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-1'] }], - color: { value: '#B40101' } - }, - { - mark: 'triangleLeft', - dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-2'] }], - color: { value: '#B40101' } - } - ], - x: { field: 'Basepair_start', type: 'genomic' }, - xe: { field: 'Basepair_stop', type: 'genomic' }, - stroke: { value: 'gray' }, - strokeWidth: { value: 0.5 }, - width: 740, - height: 20 + color: { value: 'none' }, + stroke: { value: '#7B0EDC' }, + opacity: { value: 0.1 }, + overlayOnPreviousTrack: true, + width: 400, + height: 60 } ] } From a2ff1fe4dcdc44a204c11fafb7bbc032dba7a970 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Tue, 18 Jun 2024 16:31:52 -0400 Subject: [PATCH 052/139] feat: brush linear --- demo/renderer/axis.ts | 43 +++++++++++++++++++------------ demo/renderer/brushLinear.ts | 24 +++++++++++++++++ demo/renderer/gosling.ts | 33 +++++++++++++++++++----- demo/renderer/main.ts | 10 ++++++- src/tracks/brush-linear/index.ts | 1 + src/tracks/gosling-track/index.ts | 4 +-- 6 files changed, 88 insertions(+), 27 deletions(-) create mode 100644 demo/renderer/brushLinear.ts diff --git a/demo/renderer/axis.ts b/demo/renderer/axis.ts index 106ade7a8..7e074bf9f 100644 --- a/demo/renderer/axis.ts +++ b/demo/renderer/axis.ts @@ -25,39 +25,48 @@ export function getAxisTrackDef( track: SingleTrack | OverlaidTrack | TemplateTrack, boundingBox: { x: number; y: number; width: number; height: number }, theme: Required -): TrackDef | undefined { +): [trackBbox: { x: number; y: number; width: number; height: number }, TrackDef | undefined] { const { xAxisPosition, yAxisPosition } = getAxisPositions(track); + // This is a copy of the original bounding box. It will be modified if an axis is added + const trackBbox = { ...boundingBox }; if (xAxisPosition) { if (track.layout === 'linear') { const isHorizontal = track.orientation === 'horizontal'; const widthOrHeight = isHorizontal ? 'height' : 'width'; - const axisBbox = { ...boundingBox, [widthOrHeight]: HIGLASS_AXIS_SIZE }; - boundingBox[widthOrHeight] -= axisBbox[widthOrHeight]; + const axisBbox = { ...trackBbox, [widthOrHeight]: HIGLASS_AXIS_SIZE }; + trackBbox[widthOrHeight] -= axisBbox[widthOrHeight]; if (xAxisPosition === 'top') { - boundingBox.y += axisBbox.height; + trackBbox.y += axisBbox.height; } else if (xAxisPosition === 'bottom') { - axisBbox.y = boundingBox.y + boundingBox.height; + axisBbox.y = trackBbox.y + trackBbox.height; } else if (xAxisPosition === 'right') { - axisBbox.x = boundingBox.x + boundingBox.width; + axisBbox.x = trackBbox.x + trackBbox.width; } else if (xAxisPosition === 'left') { - boundingBox.x += axisBbox.width; + trackBbox.x += axisBbox.width; } - return { - type: TrackType.Axis, - boundingBox: axisBbox, - options: getAxisTrackLinearOptions(axisBbox, xAxisPosition, theme) - }; + return [ + trackBbox, + { + type: TrackType.Axis, + boundingBox: axisBbox, + options: getAxisTrackLinearOptions(axisBbox, xAxisPosition, theme) + } + ]; } else if (track.layout === 'circular') { - return { - type: TrackType.Axis, - boundingBox: boundingBox, - options: getAxisTrackCircularOptions(track, boundingBox, xAxisPosition, theme) - }; + return [ + trackBbox, + { + type: TrackType.Axis, + boundingBox: boundingBox, + options: getAxisTrackCircularOptions(track, boundingBox, xAxisPosition, theme) + } + ]; } } if (yAxisPosition) { console.warn('Vertical axis is not supported yet'); } + return [trackBbox, undefined]; } /** diff --git a/demo/renderer/brushLinear.ts b/demo/renderer/brushLinear.ts new file mode 100644 index 000000000..f7cf41243 --- /dev/null +++ b/demo/renderer/brushLinear.ts @@ -0,0 +1,24 @@ +import { type SingleTrack, type Track } from '@gosling-lang/gosling-schema'; +import type { BrushLinearTrackOptions } from '@gosling-lang/brush-linear'; + +export function getBrushTrackOptions(spec: Track) { + if (!spec._overlay) { + return []; + } + + const brushTrackOptions: BrushLinearTrackOptions[] = []; + + spec._overlay.forEach((overlay: SingleTrack) => { + if (overlay.mark === 'brush') { + const options = { + projectionFillColor: spec.color?.value ?? 'red', + projectionStrokeColor: spec.stroke?.value ?? 'red', + projectionFillOpacity: spec.opacity?.value ?? 0.3, + projectionStrokeOpacity: spec.opacity?.value ?? 0.3, + strokeWidth: spec.strokeWidth?.value ?? 1 + }; + brushTrackOptions.push(options); + } + }); + return brushTrackOptions; +} diff --git a/demo/renderer/gosling.ts b/demo/renderer/gosling.ts index 95dda0abf..6c4015928 100644 --- a/demo/renderer/gosling.ts +++ b/demo/renderer/gosling.ts @@ -1,23 +1,32 @@ import { type AxisTrackOptions } from '@gosling-lang/genomic-axis'; -import { type Track } from '@gosling-lang/gosling-schema'; +import { type SingleTrack, type Track } from '@gosling-lang/gosling-schema'; import type { CompleteThemeDeep } from '../../src/core/utils/theme'; -import type { GoslingTrackOptions } from '../../src/tracks/gosling-track/gosling-track'; +import type { GoslingTrackOptions } from '@gosling-lang/gosling-track'; +import type { BrushLinearTrackOptions } from '@gosling-lang/brush-linear'; import { getAxisTrackDef } from './axis'; import { type TrackDef, TrackType } from './main'; +import { getBrushTrackOptions } from './brushLinear'; export function processGoslingTrack( track: Track, boundingBox: { x: number; y: number; width: number; height: number }, theme: Required -): (TrackDef | TrackDef)[] { - const trackDefs: (TrackDef | TrackDef)[] = []; +): (TrackDef | TrackDef | TrackDef)[] { + const trackDefs: ( + | TrackDef + | TrackDef + | TrackDef + )[] = []; - const axisTrackOptions = getAxisTrackDef(track, boundingBox, theme); - if (axisTrackOptions) { - trackDefs.push(axisTrackOptions); + // Adds the title and subtitle tracks + const [newTrackBbox, axisTrackDef] = getAxisTrackDef(track, boundingBox, theme); + if (axisTrackDef) { + trackDefs.push(axisTrackDef); + // modify the bounding box to exclude the axis track + boundingBox = newTrackBbox; } const goslingTrackOptions = getGoslingTrackOptions(track, theme); @@ -28,6 +37,16 @@ export function processGoslingTrack( options: goslingTrackOptions }); + // Add the brush after Gosling track so that it is on top + const brushTrackOptions = getBrushTrackOptions(track); + brushTrackOptions.forEach(brushTrackOption => { + trackDefs.push({ + type: TrackType.BrushLinear, + boundingBox: { ...boundingBox }, + options: brushTrackOption + }); + }); + return trackDefs; } diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index d5a06c132..1cd578d0d 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -3,6 +3,7 @@ import { TextTrack, type TextTrackOptions } from '@gosling-lang/text-track'; import { DummyTrack, type DummyTrackOptions } from '@gosling-lang/dummy-track'; import { GoslingTrack } from '@gosling-lang/gosling-track'; import { AxisTrack, type AxisTrackOptions } from '@gosling-lang/genomic-axis'; +import { BrushLinearTrack, type BrushLinearTrackOptions } from '@gosling-lang/brush-linear'; import { signal } from '@preact/signals-core'; import { cursor, panZoom } from '@gosling-lang/interactors'; @@ -36,7 +37,7 @@ interface TrackOptionsMap { [TrackType.Dummy]: DummyTrackOptions; [TrackType.Gosling]: GoslingTrackOptions; [TrackType.Axis]: AxisTrackOptions; - [TrackType.BrushLinear]: any; + [TrackType.BrushLinear]: BrushLinearTrackOptions; [TrackType.BrushCircular]: any; [TrackType.Heatmap]: any; } @@ -118,5 +119,12 @@ export function renderTrackDefs(trackDefs: TrackDefs[], pixiManager: PixiManager if (type === TrackType.Axis) { new AxisTrack(options, domain, pixiManager.makeContainer(boundingBox)); } + if (type === TrackType.BrushLinear) { + const brushDomain = signal<[number, number]>([543317951, 544039951]); + // console.warn(options); + new BrushLinearTrack(options, brushDomain, pixiManager.makeContainer(boundingBox).overlayDiv).addInteractor( + plot => panZoom(plot, domain) + ); + } }); } diff --git a/src/tracks/brush-linear/index.ts b/src/tracks/brush-linear/index.ts index 3464e4b7f..bdf86b22a 100644 --- a/src/tracks/brush-linear/index.ts +++ b/src/tracks/brush-linear/index.ts @@ -1 +1,2 @@ export { BrushLinearTrack } from './brush-linear-plot'; +export { type BrushLinearTrackOptions } from './brush-linear-track'; diff --git a/src/tracks/gosling-track/index.ts b/src/tracks/gosling-track/index.ts index 3da1b8006..33332821d 100644 --- a/src/tracks/gosling-track/index.ts +++ b/src/tracks/gosling-track/index.ts @@ -1,2 +1,2 @@ -export { GoslingTrackClass, type Tile, type DisplayedLegend } from './gosling-track'; -export { GoslingTrack } from './gosling-track-plot'; \ No newline at end of file +export { GoslingTrackClass, type Tile, type DisplayedLegend, type GoslingTrackOptions } from './gosling-track'; +export { GoslingTrack } from './gosling-track-plot'; From 4298cd3123baa5b4cb61541ce1d78d07d8290c39 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Tue, 18 Jun 2024 17:55:26 -0400 Subject: [PATCH 053/139] feat: simple linked views --- demo/App.tsx | 60 +++++++++++++++++++-- demo/renderer/axis.ts | 2 + demo/renderer/gosling.ts | 2 + demo/renderer/main.ts | 26 +++++---- demo/renderer/text.ts | 2 + src/gosling-schema/gosling.schema.guards.ts | 11 +++- 6 files changed, 90 insertions(+), 13 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index 4436979a0..9eea1685f 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -16,6 +16,10 @@ import './App.css'; import type { HiGlassSpec } from '@gosling-lang/higlass-schema'; import { createTrackDefs, renderTrackDefs, showTrackInfoPositions } from './renderer/main'; import type { TrackInfo } from 'src/compiler/bounding-box'; +import { signal, type Signal } from '@preact/signals-core'; +import type { GoslingSpec } from 'gosling.js'; +import { IsMultipleViews, IsSingleView, type SingleView } from '@gosling-lang/gosling-schema'; +import { computeChromSizes } from '../src/core/utils/assembly'; function App() { const [fps, setFps] = useState(120); @@ -37,20 +41,24 @@ function App() { const callback = ( hg: HiGlassSpec, size, - gs, + gs: GoslingSpec, tracksAndViews, idTable, trackInfos: TrackInfo[], theme: Require ) => { console.warn(trackInfos); + console.warn(tracksAndViews); + console.warn(gs); // showTrackInfoPositions(trackInfos, pixiManager); + const linkedEncodings = getLinkedEncodings(gs); + console.warn(linkedEncodings); const trackDefs = createTrackDefs(trackInfos, theme); - renderTrackDefs(trackDefs, pixiManager); + renderTrackDefs(trackDefs, linkedEncodings, pixiManager); }; // Compile the spec - compile(corces, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); + compile(spec, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); }, []); return ( @@ -64,6 +72,52 @@ function App() { ); } +export interface LinkedEncoding { + linkingId: string; + encoding: 'x'; + signal: Signal; + trackIds: string[]; + brushIds: string[]; +} + +function getLinkedEncodings(gs: GoslingSpec) { + const linkedEncodings: LinkedEncoding[] = []; + + // Base case: single view + if (IsSingleView(gs)) { + const newLink = getSingleViewLinkedEncoding(gs); + return [newLink]; + } + // Recursive case: multiple views + if (IsMultipleViews(gs)) { + gs.views.forEach(view => { + const newLinks = getLinkedEncodings(view); + linkedEncodings.push(...newLinks); + }); + } + return linkedEncodings; +} + +/** + * Links all of the tracks in a single view together + */ +function getSingleViewLinkedEncoding(gs: SingleView) { + const { tracks } = gs; + const end = computeChromSizes(tracks[0].assembly).total; + + const newLink: LinkedEncoding = { + linkingId: gs.id || 'default', + encoding: 'x', + signal: signal([0, end]), + trackIds: [], + brushIds: [] + }; + tracks.forEach(track => { + newLink.trackIds.push(track.id); + }); + return newLink; +} + export default App; const spec = { diff --git a/demo/renderer/axis.ts b/demo/renderer/axis.ts index 7e074bf9f..0e86e23ce 100644 --- a/demo/renderer/axis.ts +++ b/demo/renderer/axis.ts @@ -48,6 +48,7 @@ export function getAxisTrackDef( trackBbox, { type: TrackType.Axis, + trackId: track.id, boundingBox: axisBbox, options: getAxisTrackLinearOptions(axisBbox, xAxisPosition, theme) } @@ -57,6 +58,7 @@ export function getAxisTrackDef( trackBbox, { type: TrackType.Axis, + trackId: track.id, boundingBox: boundingBox, options: getAxisTrackCircularOptions(track, boundingBox, xAxisPosition, theme) } diff --git a/demo/renderer/gosling.ts b/demo/renderer/gosling.ts index 6c4015928..8e4fa5230 100644 --- a/demo/renderer/gosling.ts +++ b/demo/renderer/gosling.ts @@ -33,6 +33,7 @@ export function processGoslingTrack( trackDefs.push({ type: TrackType.Gosling, + trackId: track.id, boundingBox: { ...boundingBox }, options: goslingTrackOptions }); @@ -42,6 +43,7 @@ export function processGoslingTrack( brushTrackOptions.forEach(brushTrackOption => { trackDefs.push({ type: TrackType.BrushLinear, + trackId: track.id, boundingBox: { ...boundingBox }, options: brushTrackOption }); diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index 1cd578d0d..6363b7d62 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -4,7 +4,7 @@ import { DummyTrack, type DummyTrackOptions } from '@gosling-lang/dummy-track'; import { GoslingTrack } from '@gosling-lang/gosling-track'; import { AxisTrack, type AxisTrackOptions } from '@gosling-lang/genomic-axis'; import { BrushLinearTrack, type BrushLinearTrackOptions } from '@gosling-lang/brush-linear'; -import { signal } from '@preact/signals-core'; +import { Signal, signal } from '@preact/signals-core'; import { cursor, panZoom } from '@gosling-lang/interactors'; import type { TrackInfo } from '../../src/compiler/bounding-box'; @@ -14,7 +14,7 @@ import type { GoslingTrackOptions } from '../../src/tracks/gosling-track/gosling import { proccessTextHeader } from './text'; import { processGoslingTrack } from './gosling'; import { getDataFetcher } from './dataFetcher'; -import { CsvDataFetcher } from '@data-fetchers'; +import type { LinkedEncoding } from '../App'; /** * All the different types of tracks that can be rendered @@ -47,6 +47,7 @@ interface TrackOptionsMap { */ export interface TrackDef { type: TrackType; + trackId: string; boundingBox: { x: number; y: number; width: number; height: number }; options: T; } @@ -97,29 +98,26 @@ export function createTrackDefs(trackInfos: TrackInfo[], theme: Required([491149952, 689445510]); - +export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedEncoding[], pixiManager: PixiManager) { trackDefs.forEach(trackDef => { const { boundingBox, type, options } = trackDef; - // console.warn('boundingBox', boundingBox); - // const div = pixiManager.makeContainer(boundingBox).overlayDiv; - // div.style.border = '1px solid black'; - // div.innerHTML = TrackType[type] || 'No mark'; if (type === TrackType.Text) { new TextTrack(options, pixiManager.makeContainer(boundingBox)); } if (type === TrackType.Gosling) { + const domain = getXDomainSignal(trackDef.trackId, linkedEncodings); const datafetcher = getDataFetcher(options.spec); new GoslingTrack(options, datafetcher, pixiManager.makeContainer(boundingBox)).addInteractor(plot => panZoom(plot, domain) ); } if (type === TrackType.Axis) { + const domain = getXDomainSignal(trackDef.trackId, linkedEncodings); new AxisTrack(options, domain, pixiManager.makeContainer(boundingBox)); } if (type === TrackType.BrushLinear) { + const domain = getXDomainSignal(trackDef.trackId, linkedEncodings); const brushDomain = signal<[number, number]>([543317951, 544039951]); // console.warn(options); new BrushLinearTrack(options, brushDomain, pixiManager.makeContainer(boundingBox).overlayDiv).addInteractor( @@ -128,3 +126,13 @@ export function renderTrackDefs(trackDefs: TrackDefs[], pixiManager: PixiManager } }); } + +function getXDomainSignal(trackDefId: string, linkedEncodings: LinkedEncoding[]): Signal<[number, number]> { + const linkedEncoding = linkedEncodings.find(link => link.trackIds.includes(trackDefId)); + + if (!linkedEncoding) { + console.error(`No linked encoding found for track ${trackDefId}`); + return signal<[number, number]>([0, 30000000]); + } + return linkedEncoding!.signal; +} diff --git a/demo/renderer/text.ts b/demo/renderer/text.ts index bce9d9006..30e2781c2 100644 --- a/demo/renderer/text.ts +++ b/demo/renderer/text.ts @@ -22,6 +22,7 @@ export function proccessTextHeader( const height = textTrackOptions.fontSize + 6; trackDefs.push({ type: TrackType.Text, + trackId: 'title', boundingBox: { ...boundingBox, height }, options: textTrackOptions }); @@ -32,6 +33,7 @@ export function proccessTextHeader( const height = textTrackOptions.fontSize + 6; trackDefs.push({ type: TrackType.Text, + trackId: 'subtitle', boundingBox: { ...boundingBox, y: boundingBox.y + cumHeight, height }, options: textTrackOptions }); diff --git a/src/gosling-schema/gosling.schema.guards.ts b/src/gosling-schema/gosling.schema.guards.ts index 21ace53cc..0a5029f28 100644 --- a/src/gosling-schema/gosling.schema.guards.ts +++ b/src/gosling-schema/gosling.schema.guards.ts @@ -32,7 +32,8 @@ import type { TemplateTrack, MouseEventsDeep, DataTransform, - DummyTrack + DummyTrack, + MultipleViews } from './gosling.schema'; import { SUPPORTED_CHANNELS } from '../core/mark'; import { @@ -131,6 +132,14 @@ export function IsTemplateTrack(track: Partial): track is TemplateTrack { return 'template' in track; } +export function IsSingleView(view: unknown): view is SingleView { + return isObject(view) && 'tracks' in view; +} + +export function IsMultipleViews(view: unknown): view is MultipleViews { + return isObject(view) && 'views' in view; +} + /** * Is this a vertical rule, i.e., y genomic axis? */ From 58b095f24f434e7882e233c5cb966de9b33355c8 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Tue, 18 Jun 2024 22:41:03 -0400 Subject: [PATCH 054/139] feat: linked brush --- demo/App.tsx | 52 +---------- demo/renderer/linkedEncoding.ts | 147 ++++++++++++++++++++++++++++++++ demo/renderer/main.ts | 14 ++- 3 files changed, 161 insertions(+), 52 deletions(-) create mode 100644 demo/renderer/linkedEncoding.ts diff --git a/demo/App.tsx b/demo/App.tsx index 9eea1685f..d65792b98 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -16,10 +16,8 @@ import './App.css'; import type { HiGlassSpec } from '@gosling-lang/higlass-schema'; import { createTrackDefs, renderTrackDefs, showTrackInfoPositions } from './renderer/main'; import type { TrackInfo } from 'src/compiler/bounding-box'; -import { signal, type Signal } from '@preact/signals-core'; import type { GoslingSpec } from 'gosling.js'; -import { IsMultipleViews, IsSingleView, type SingleView } from '@gosling-lang/gosling-schema'; -import { computeChromSizes } from '../src/core/utils/assembly'; +import { getLinkedEncodings } from './renderer/linkedEncoding'; function App() { const [fps, setFps] = useState(120); @@ -58,7 +56,7 @@ function App() { }; // Compile the spec - compile(spec, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); + compile(corces, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); }, []); return ( @@ -72,52 +70,6 @@ function App() { ); } -export interface LinkedEncoding { - linkingId: string; - encoding: 'x'; - signal: Signal; - trackIds: string[]; - brushIds: string[]; -} - -function getLinkedEncodings(gs: GoslingSpec) { - const linkedEncodings: LinkedEncoding[] = []; - - // Base case: single view - if (IsSingleView(gs)) { - const newLink = getSingleViewLinkedEncoding(gs); - return [newLink]; - } - // Recursive case: multiple views - if (IsMultipleViews(gs)) { - gs.views.forEach(view => { - const newLinks = getLinkedEncodings(view); - linkedEncodings.push(...newLinks); - }); - } - return linkedEncodings; -} - -/** - * Links all of the tracks in a single view together - */ -function getSingleViewLinkedEncoding(gs: SingleView) { - const { tracks } = gs; - const end = computeChromSizes(tracks[0].assembly).total; - - const newLink: LinkedEncoding = { - linkingId: gs.id || 'default', - encoding: 'x', - signal: signal([0, end]), - trackIds: [], - brushIds: [] - }; - tracks.forEach(track => { - newLink.trackIds.push(track.id); - }); - return newLink; -} - export default App; const spec = { diff --git a/demo/renderer/linkedEncoding.ts b/demo/renderer/linkedEncoding.ts new file mode 100644 index 000000000..8982220d1 --- /dev/null +++ b/demo/renderer/linkedEncoding.ts @@ -0,0 +1,147 @@ +import { IsMultipleViews, IsSingleView, type Assembly, type SingleView } from '@gosling-lang/gosling-schema'; +import { GenomicPositionHelper, computeChromSizes } from '../../src/core/utils/assembly'; +import { signal, type Signal } from '@preact/signals-core'; +import type { GoslingSpec } from 'gosling.js'; + +/** + * This is the information needed to link tracks together + */ +export interface LinkedEncoding { + linkingId: string; + encoding: 'x'; + signal: Signal; + trackIds: string[]; + brushIds: string[]; +} + +/** + * Info collected from the GoslingSpec that is needed to associate brushes with tracks + */ +interface BrushInfo { + trackId: string; + linkingId: string; +} + +/** + * Info collected from the GoslingSpec that is needed to link tracks together + * The brushIds are added after the fact + */ +interface BrushAndEncoding { + linkedEncodings: Omit[]; + brushes: BrushInfo[]; +} + +/** + * Extracts the linked encodings from a GoslingSpec + */ +export function getLinkedEncodings(gs: GoslingSpec) { + // First, we traverse the gosling spec to find all the linked tracks and brushes + const { linkedEncodings, brushes } = getLinedFeaturesRecursive(gs) as { + linkedEncodings: LinkedEncoding[]; + brushes: BrushInfo[]; + }; + // We need to associate the brushes with the linked encodings + linkedEncodings.forEach(le => { + le.brushIds = []; + }); + brushes.forEach(brush => { + const { trackId, linkingId } = brush; + linkedEncodings.forEach(le => { + if (le.linkingId === linkingId) { + le.brushIds.push(trackId); + } + }); + }); + return linkedEncodings; +} + +/** + * Traverses the gosling spec to find all the linked tracks and brushes + */ +function getLinedFeaturesRecursive(gs: GoslingSpec): BrushAndEncoding { + // Base case: single view + if (IsSingleView(gs)) { + const linkedEncodings = getSingleViewLinkedEncoding(gs); + const brushes = getSingleViewBrushes(gs); + return { linkedEncodings: [linkedEncodings], brushes }; + } + const linked: BrushAndEncoding = { linkedEncodings: [], brushes: [] }; + // Recursive case: multiple views + if (IsMultipleViews(gs)) { + gs.views.forEach(view => { + const newLinks = getLinedFeaturesRecursive(view); + linked.linkedEncodings.push(...newLinks.linkedEncodings); + linked.brushes.push(...newLinks.brushes); + }); + } + return linked; +} + +/** + * Extracts the linkingId from tracks that have a brush overlay + */ +function getSingleViewBrushes(gs: SingleView): BrushInfo[] { + const { tracks } = gs; + const brushes: BrushInfo[] = []; + tracks.forEach(track => { + if (!('_overlay' in track)) return; + track._overlay!.forEach(overlay => { + if (overlay.mark === 'brush') { + brushes.push({ trackId: track.id, linkingId: overlay.x.linkingId }); + } + }); + }); + return brushes; +} + +/** + * Links all of the tracks in a single view together + */ +function getSingleViewLinkedEncoding(gs: SingleView) { + const { tracks, xDomain, assembly } = gs; + const domain = getDomain(xDomain, assembly); + + const newLink: Omit = { + linkingId: gs.linkingId || '', + encoding: 'x', + signal: signal(domain), + trackIds: [] + }; + tracks.forEach(track => { + newLink.trackIds.push(track.id); + }); + return newLink; +} + +/** + * For a given xDomain and Assembly, return the the absolute domain [start, end] + */ +function getDomain(xDomain: GoslingSpec['xDomain'], assembly?: Assembly): [number, number] { + let domain = [0, 0] as [number, number]; + if (!xDomain) { + domain = [0, computeChromSizes(assembly).total]; + } else { + const position = createDomainString(xDomain); + const manager = GenomicPositionHelper.fromString(position); + domain = manager.toAbsoluteCoordinates(assembly, 0); + } + return domain; +} + +/** + * Generates a string representation of the xDomain + */ +function createDomainString(xDomain: GoslingSpec['xDomain']) { + if (typeof xDomain === 'string') { + return xDomain; + } + let position = ''; + if (typeof xDomain === 'string') { + position = xDomain; + } else if (typeof xDomain === 'object' && 'chromosome' in xDomain && !('interval' in xDomain)) { + position = xDomain.chromosome; + } else if (typeof xDomain === 'object' && 'chromosome' in xDomain && 'interval' in xDomain) { + position = `${xDomain.chromosome}:${xDomain.interval[0]}-${xDomain.interval[1]}`; + } + return position; +} diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index 6363b7d62..33d81f1dc 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -118,8 +118,8 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE } if (type === TrackType.BrushLinear) { const domain = getXDomainSignal(trackDef.trackId, linkedEncodings); - const brushDomain = signal<[number, number]>([543317951, 544039951]); - // console.warn(options); + const brushDomain = getBrushSignal(trackDef.trackId, linkedEncodings); + new BrushLinearTrack(options, brushDomain, pixiManager.makeContainer(boundingBox).overlayDiv).addInteractor( plot => panZoom(plot, domain) ); @@ -127,6 +127,16 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE }); } +function getBrushSignal(trackDefId: string, linkedEncodings: LinkedEncoding[]): Signal<[number, number]> { + const linkedEncoding = linkedEncodings.find(link => link.brushIds.includes(trackDefId)); + + if (!linkedEncoding) { + console.error(`No linked encoding found for track ${trackDefId}`); + return signal<[number, number]>([0, 30000000]); + } + return linkedEncoding!.signal; +} + function getXDomainSignal(trackDefId: string, linkedEncodings: LinkedEncoding[]): Signal<[number, number]> { const linkedEncoding = linkedEncodings.find(link => link.trackIds.includes(trackDefId)); From d0e459107b1ff22b75051e2707992a889a9f6e11 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Thu, 20 Jun 2024 15:24:51 -0400 Subject: [PATCH 055/139] feat: refactored linking --- demo/App.tsx | 608 +++++++++++++++++++++++++++++++- demo/renderer/linkedEncoding.ts | 97 +++-- 2 files changed, 669 insertions(+), 36 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index d65792b98..fbcba0a73 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -75,7 +75,7 @@ export default App; const spec = { title: 'Basic Marks: line', subtitle: 'Tutorial Examples', - layout: 'circular', + layout: 'linear', tracks: [ { layout: 'circular', @@ -443,3 +443,609 @@ const corces = { } ] }; + +const genes = { + layout: 'linear', + xDomain: { chromosome: 'chr3', interval: [52168000, 52890000] }, + arrangement: 'horizontal', + views: [ + { + arrangement: 'vertical', + views: [ + { + alignment: 'overlay', + title: 'HiGlass', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ], + exonIntervalFields: [ + { index: 12, name: 'start' }, + { index: 13, name: 'end' } + ] + }, + tracks: [ + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['+'] } + ], + mark: 'triangleRight', + x: { field: 'end', type: 'genomic', axis: 'top' }, + size: { value: 15 } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'text', + text: { field: 'name', type: 'nominal' }, + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + style: { dy: -15 } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['-'] } + ], + mark: 'triangleLeft', + x: { field: 'start', type: 'genomic' }, + size: { value: 15 }, + style: { align: 'right' } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['exon'] }], + mark: 'rect', + x: { field: 'start', type: 'genomic' }, + size: { value: 15 }, + xe: { field: 'end', type: 'genomic' } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['+'] } + ], + mark: 'rule', + x: { field: 'start', type: 'genomic' }, + strokeWidth: { value: 3 }, + xe: { field: 'end', type: 'genomic' }, + style: { linePattern: { type: 'triangleRight', size: 5 } } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['-'] } + ], + mark: 'rule', + x: { field: 'start', type: 'genomic' }, + strokeWidth: { value: 3 }, + xe: { field: 'end', type: 'genomic' }, + style: { linePattern: { type: 'triangleLeft', size: 5 } } + } + ], + row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, + color: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'], + range: ['#7585FF', '#FF8A85'] + }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ], + opacity: { value: 0.8 }, + width: 350, + height: 100 + }, + { + alignment: 'overlay', + title: 'Corces et al.', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ], + exonIntervalFields: [ + { index: 12, name: 'start' }, + { index: 13, name: 'end' } + ] + }, + tracks: [ + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['+'] } + ], + mark: 'text', + text: { field: 'name', type: 'nominal' }, + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + size: { value: 8 }, + style: { textFontSize: 8, dy: -12 } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['-'] } + ], + mark: 'text', + text: { field: 'name', type: 'nominal' }, + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + size: { value: 8 }, + style: { textFontSize: 8, dy: 10 } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['+'] } + ], + mark: 'rect', + x: { field: 'end', type: 'genomic' }, + size: { value: 7 } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['-'] } + ], + mark: 'rect', + x: { field: 'start', type: 'genomic' }, + size: { value: 7 } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['exon'] }], + mark: 'rect', + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + size: { value: 14 } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'rule', + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + strokeWidth: { value: 3 } + } + ], + row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, + color: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'], + range: ['#012DB8', '#BE1E2C'] + }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ], + width: 350, + height: 100 + }, + { + alignment: 'overlay', + title: 'IGV', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ], + exonIntervalFields: [ + { index: 12, name: 'start' }, + { index: 13, name: 'end' } + ] + }, + tracks: [ + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'text', + text: { field: 'name', type: 'nominal' }, + x: { field: 'start', type: 'genomic', axis: 'top' }, + xe: { field: 'end', type: 'genomic' } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'rect', + x: { field: 'start', type: 'genomic', axis: 'top' }, + size: { value: 15 }, + xe: { field: 'end', type: 'genomic' } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['-'] } + ], + mark: 'rule', + x: { field: 'start', type: 'genomic', axis: 'top' }, + strokeWidth: { value: 0 }, + xe: { field: 'end', type: 'genomic' }, + color: { value: 'white' }, + opacity: { value: 0.6 }, + style: { linePattern: { type: 'triangleLeft', size: 10 } } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['+'] } + ], + mark: 'rule', + x: { field: 'start', type: 'genomic', axis: 'top' }, + strokeWidth: { value: 0 }, + xe: { field: 'end', type: 'genomic' }, + color: { value: 'white' }, + opacity: { value: 0.6 }, + style: { linePattern: { type: 'triangleRight', size: 10 } } + } + ], + row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, + color: { value: '#0900B1' }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ], + width: 350, + height: 100 + } + ] + }, + { + arrangement: 'vertical', + views: [ + { + alignment: 'overlay', + title: 'Cyverse-QUBES', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ], + exonIntervalFields: [ + { index: 12, name: 'start' }, + { index: 13, name: 'end' } + ] + }, + tracks: [ + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'text', + text: { field: 'name', type: 'nominal' }, + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + color: { value: 'black' } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['+'] } + ], + mark: 'triangleRight', + x: { field: 'end', type: 'genomic', axis: 'top' }, + color: { value: '#999999' } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['-'] } + ], + mark: 'triangleLeft', + x: { field: 'start', type: 'genomic', axis: 'top' }, + color: { value: '#999999' }, + style: { align: 'right' } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'rect', + x: { field: 'start', type: 'genomic', axis: 'top' }, + xe: { field: 'end', type: 'genomic' }, + color: { value: 'lightgray' } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'rule', + x: { field: 'start', type: 'genomic', axis: 'top' }, + strokeWidth: { value: 5 }, + xe: { field: 'end', type: 'genomic' }, + color: { value: 'gray' } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['exon'] }], + mark: 'rect', + x: { field: 'start', type: 'genomic', axis: 'top' }, + xe: { field: 'end', type: 'genomic' }, + color: { value: '#E2A6F5' }, + stroke: { value: '#BB57C9' }, + strokeWidth: { value: 1 } + } + ], + row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ], + size: { value: 15 }, + width: 350, + height: 100 + }, + { + alignment: 'overlay', + title: 'GmGDV', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ], + exonIntervalFields: [ + { index: 12, name: 'start' }, + { index: 13, name: 'end' } + ] + }, + tracks: [ + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'text', + text: { field: 'name', type: 'nominal' }, + x: { field: 'start', type: 'genomic', axis: 'top' }, + xe: { field: 'end', type: 'genomic' }, + style: { dy: -14 } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['+'] } + ], + mark: 'triangleRight', + x: { field: 'end', type: 'genomic', axis: 'top' }, + size: { value: 15 } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['-'] } + ], + mark: 'triangleLeft', + x: { field: 'start', type: 'genomic', axis: 'top' }, + size: { value: 15 }, + style: { align: 'right' } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['exon'] }], + mark: 'rect', + x: { field: 'start', type: 'genomic', axis: 'top' }, + size: { value: 10 }, + xe: { field: 'end', type: 'genomic' } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'rule', + x: { field: 'start', type: 'genomic', axis: 'top' }, + strokeWidth: { value: 3 }, + xe: { field: 'end', type: 'genomic' } + } + ], + row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, + color: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'], + range: ['blue', 'red'] + }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ], + width: 350, + height: 100 + }, + { + alignment: 'overlay', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ], + exonIntervalFields: [ + { index: 12, name: 'start' }, + { index: 13, name: 'end' } + ] + }, + tracks: [ + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'text', + text: { field: 'name', type: 'nominal' }, + x: { field: 'start', type: 'genomic', axis: 'top' }, + color: { value: 'black' }, + xe: { field: 'end', type: 'genomic' } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'rect', + x: { field: 'start', type: 'genomic', axis: 'top' }, + xe: { field: 'end', type: 'genomic' }, + color: { value: '#666666' } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['exon'] }], + mark: 'rect', + x: { field: 'start', type: 'genomic', axis: 'top' }, + xe: { field: 'end', type: 'genomic' }, + color: { value: '#FF6666' } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['intron'] }], + mark: 'rect', + x: { field: 'start', type: 'genomic', axis: 'top' }, + xe: { field: 'end', type: 'genomic' }, + color: { value: '#99FEFF' } + } + ], + size: { value: 30 }, + row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, + stroke: { value: '#777777' }, + strokeWidth: { value: 1 }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ], + width: 350, + height: 100 + } + ] + } + ] +}; + +const linkingTest = { + title: 'Basic Marks: line', + subtitle: 'Tutorial Examples', + views: [ + { + tracks: [ + { + layout: 'linear', + width: 800, + height: 180, + data: { + url: 'https://resgen.io/api/v1/tileset_info/?d=UvVPeLHuRDiYA3qwFlm7xQ', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1'] + }, + mark: 'line', + x: { field: 'position', type: 'genomic', axis: 'bottom', linkingId: 'test' }, + y: { field: 'peak', type: 'quantitative', axis: 'right' }, + size: { value: 2 } + } + ] + }, + { + linkingId: 'test', + tracks: [ + { + layout: 'linear', + width: 800, + height: 180, + data: { + url: 'https://resgen.io/api/v1/tileset_info/?d=UvVPeLHuRDiYA3qwFlm7xQ', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1'] + }, + mark: 'line', + x: { field: 'position', type: 'genomic', axis: 'bottom' }, + y: { field: 'peak', type: 'quantitative', axis: 'right' }, + size: { value: 2 } + } + ] + }, + { + tracks: [ + { + layout: 'linear', + width: 800, + height: 180, + data: { + url: 'https://resgen.io/api/v1/tileset_info/?d=UvVPeLHuRDiYA3qwFlm7xQ', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1'] + }, + mark: 'line', + x: { field: 'position', type: 'genomic', axis: 'bottom' }, + y: { field: 'peak', type: 'quantitative', axis: 'right' }, + size: { value: 2 } + }, + { + layout: 'linear', + width: 800, + height: 180, + data: { + url: 'https://resgen.io/api/v1/tileset_info/?d=UvVPeLHuRDiYA3qwFlm7xQ', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1'] + }, + mark: 'line', + x: { field: 'position', type: 'genomic', axis: 'bottom', linkingId: 'test' }, + y: { field: 'peak', type: 'quantitative', axis: 'right' }, + size: { value: 2 } + } + ] + } + ] +}; diff --git a/demo/renderer/linkedEncoding.ts b/demo/renderer/linkedEncoding.ts index 8982220d1..b6c46a0b1 100644 --- a/demo/renderer/linkedEncoding.ts +++ b/demo/renderer/linkedEncoding.ts @@ -2,6 +2,7 @@ import { IsMultipleViews, IsSingleView, type Assembly, type SingleView } from '@ import { GenomicPositionHelper, computeChromSizes } from '../../src/core/utils/assembly'; import { signal, type Signal } from '@preact/signals-core'; import type { GoslingSpec } from 'gosling.js'; +import { TrackType } from './main'; /** * This is the information needed to link tracks together @@ -13,22 +14,40 @@ export interface LinkedEncoding { trackIds: string[]; brushIds: string[]; } +/** + * This is information extracted from the Gosling spec. + * Is is the linking that is defined at the view level. + */ +export interface ViewLink { + linkingId?: string; + encoding: 'x'; + trackIds: string[]; + signal: Signal; +} /** - * Info collected from the GoslingSpec that is needed to associate brushes with tracks + * This is information extracted from the Gosling spec. + * It is the x-linking defined at the track level (opposed to the view level) */ -interface BrushInfo { - trackId: string; +interface TrackLink { + encoding: 'x'; linkingId: string; + trackId: string; + trackType: TrackType; } /** * Info collected from the GoslingSpec that is needed to link tracks together * The brushIds are added after the fact */ -interface BrushAndEncoding { - linkedEncodings: Omit[]; - brushes: BrushInfo[]; +interface LinkInfo { + trackLinks: TrackLink[]; + viewLinks: ViewLink[]; +} + +function filterLinkedTracksByType(trackType: TrackType, linkingId: string | undefined, trackLinks: TrackLink[]) { + if (!linkingId) return []; + return trackLinks.filter(trackLink => trackLink.linkingId === linkingId && trackLink.trackType === trackType); } /** @@ -36,21 +55,19 @@ interface BrushAndEncoding { */ export function getLinkedEncodings(gs: GoslingSpec) { // First, we traverse the gosling spec to find all the linked tracks and brushes - const { linkedEncodings, brushes } = getLinedFeaturesRecursive(gs) as { - linkedEncodings: LinkedEncoding[]; - brushes: BrushInfo[]; - }; - // We need to associate the brushes with the linked encodings - linkedEncodings.forEach(le => { - le.brushIds = []; - }); - brushes.forEach(brush => { - const { trackId, linkingId } = brush; - linkedEncodings.forEach(le => { - if (le.linkingId === linkingId) { - le.brushIds.push(trackId); - } - }); + const { trackLinks, viewLinks } = getLinedFeaturesRecursive(gs); + // We combine the tracks and views that are linked together + const linkedEncodings = viewLinks.map(viewLink => { + const linkedBrushes = filterLinkedTracksByType(TrackType.BrushLinear, viewLink.linkingId, trackLinks); + console.warn(linkedBrushes) + const linkedTracks = filterLinkedTracksByType(TrackType.Gosling, viewLink.linkingId, trackLinks); + return { + linkingId: viewLink.linkingId, + encoding: viewLink.encoding, + signal: viewLink.signal, + trackIds: [...viewLink.trackIds, ...linkedTracks.map(track => track.trackId)], + brushIds: linkedBrushes.map(brush => brush.trackId) + } as LinkedEncoding; }); return linkedEncodings; } @@ -58,20 +75,20 @@ export function getLinkedEncodings(gs: GoslingSpec) { /** * Traverses the gosling spec to find all the linked tracks and brushes */ -function getLinedFeaturesRecursive(gs: GoslingSpec): BrushAndEncoding { +function getLinedFeaturesRecursive(gs: GoslingSpec): LinkInfo { // Base case: single view if (IsSingleView(gs)) { - const linkedEncodings = getSingleViewLinkedEncoding(gs); - const brushes = getSingleViewBrushes(gs); - return { linkedEncodings: [linkedEncodings], brushes }; + const viewLinks = getSingleViewLinks(gs); + const trackLinks = getSingleViewTrackLinks(gs); + return { viewLinks: [viewLinks], trackLinks }; } - const linked: BrushAndEncoding = { linkedEncodings: [], brushes: [] }; + const linked: LinkInfo = { viewLinks: [], trackLinks: [] }; // Recursive case: multiple views if (IsMultipleViews(gs)) { gs.views.forEach(view => { const newLinks = getLinedFeaturesRecursive(view); - linked.linkedEncodings.push(...newLinks.linkedEncodings); - linked.brushes.push(...newLinks.brushes); + linked.viewLinks.push(...newLinks.viewLinks); + linked.trackLinks.push(...newLinks.trackLinks); }); } return linked; @@ -80,33 +97,43 @@ function getLinedFeaturesRecursive(gs: GoslingSpec): BrushAndEncoding { /** * Extracts the linkingId from tracks that have a brush overlay */ -function getSingleViewBrushes(gs: SingleView): BrushInfo[] { +function getSingleViewTrackLinks(gs: SingleView): TrackLink[] { const { tracks } = gs; - const brushes: BrushInfo[] = []; + const trackLinks: TrackLink[] = []; tracks.forEach(track => { + if ('x' in track && track.x && 'linkingId' in track.x) { + trackLinks.push({ + trackId: track.id, + linkingId: track.x.linkingId, + trackType: TrackType.Gosling, + encoding: 'x' + }); + } if (!('_overlay' in track)) return; track._overlay!.forEach(overlay => { if (overlay.mark === 'brush') { - brushes.push({ trackId: track.id, linkingId: overlay.x.linkingId }); + const trackType = gs.layout === 'linear' ? TrackType.BrushLinear : TrackType.BrushCircular; + trackLinks.push({ trackId: track.id, linkingId: overlay.x.linkingId, trackType, encoding: 'x' }); } }); }); - return brushes; + return trackLinks; } /** * Links all of the tracks in a single view together */ -function getSingleViewLinkedEncoding(gs: SingleView) { +function getSingleViewLinks(gs: SingleView): ViewLink { const { tracks, xDomain, assembly } = gs; const domain = getDomain(xDomain, assembly); - const newLink: Omit = { - linkingId: gs.linkingId || '', + const newLink: ViewLink = { + linkingId: gs.linkingId, encoding: 'x', signal: signal(domain), trackIds: [] }; + // Add each track to the link tracks.forEach(track => { newLink.trackIds.push(track.id); }); From 9fa84fe753e2c7dd718b9d7fa2d2281475f64afd Mon Sep 17 00:00:00 2001 From: etowahadams Date: Thu, 20 Jun 2024 16:30:24 -0400 Subject: [PATCH 056/139] fix: linkedEncoding --- demo/App.tsx | 8 ++++---- demo/renderer/gosling.ts | 2 +- demo/renderer/linkedEncoding.ts | 5 ++++- src/compiler/spec-preprocess.ts | 4 ++-- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index fbcba0a73..d1b7d877b 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -971,7 +971,7 @@ const linkingTest = { { layout: 'linear', width: 800, - height: 180, + height: 100, data: { url: 'https://resgen.io/api/v1/tileset_info/?d=UvVPeLHuRDiYA3qwFlm7xQ', type: 'multivec', @@ -993,7 +993,7 @@ const linkingTest = { { layout: 'linear', width: 800, - height: 180, + height: 100, data: { url: 'https://resgen.io/api/v1/tileset_info/?d=UvVPeLHuRDiYA3qwFlm7xQ', type: 'multivec', @@ -1014,7 +1014,7 @@ const linkingTest = { { layout: 'linear', width: 800, - height: 180, + height: 100, data: { url: 'https://resgen.io/api/v1/tileset_info/?d=UvVPeLHuRDiYA3qwFlm7xQ', type: 'multivec', @@ -1031,7 +1031,7 @@ const linkingTest = { { layout: 'linear', width: 800, - height: 180, + height: 100, data: { url: 'https://resgen.io/api/v1/tileset_info/?d=UvVPeLHuRDiYA3qwFlm7xQ', type: 'multivec', diff --git a/demo/renderer/gosling.ts b/demo/renderer/gosling.ts index 8e4fa5230..b210b993f 100644 --- a/demo/renderer/gosling.ts +++ b/demo/renderer/gosling.ts @@ -60,7 +60,7 @@ function getGoslingTrackOptions(spec: Track, theme: Required) showMousePosition: true, mousePositionColor: '#000000', name: spec.title, - labelPosition: spec.overlayOnPreviousTrack ? 'none' : 'topLeft', + labelPosition: spec.overlayOnPreviousTrack || spec.title === undefined ? 'none' : 'topLeft', labelShowResolution: false, labelColor: 'black', labelBackgroundColor: 'white', diff --git a/demo/renderer/linkedEncoding.ts b/demo/renderer/linkedEncoding.ts index b6c46a0b1..0a74d4f89 100644 --- a/demo/renderer/linkedEncoding.ts +++ b/demo/renderer/linkedEncoding.ts @@ -59,7 +59,6 @@ export function getLinkedEncodings(gs: GoslingSpec) { // We combine the tracks and views that are linked together const linkedEncodings = viewLinks.map(viewLink => { const linkedBrushes = filterLinkedTracksByType(TrackType.BrushLinear, viewLink.linkingId, trackLinks); - console.warn(linkedBrushes) const linkedTracks = filterLinkedTracksByType(TrackType.Gosling, viewLink.linkingId, trackLinks); return { linkingId: viewLink.linkingId, @@ -135,6 +134,10 @@ function getSingleViewLinks(gs: SingleView): ViewLink { }; // Add each track to the link tracks.forEach(track => { + // If the track is already linked, we don't need to add it again + if ('x' in track && track.x && 'linkingId' in track.x && track.x.linkingId) { + return; + } newLink.trackIds.push(track.id); }); return newLink; diff --git a/src/compiler/spec-preprocess.ts b/src/compiler/spec-preprocess.ts index 67cca4962..af26c0a78 100644 --- a/src/compiler/spec-preprocess.ts +++ b/src/compiler/spec-preprocess.ts @@ -314,7 +314,7 @@ export function traverseToFixSpecDownstream(spec: GoslingSpec | SingleView, pare * Link tracks in a single view */ if ((IsSingleTrack(track) || IsOverlaidTrack(track)) && IsChannelDeep(track.x) && !track.x.linkingId) { - track.x.linkingId = spec.linkingId ?? linkID; + track.x.linkingId = spec.linkingId; } else if (IsOverlaidTrack(track)) { let isAdded = false; track._overlay.forEach(o => { @@ -322,7 +322,7 @@ export function traverseToFixSpecDownstream(spec: GoslingSpec | SingleView, pare if (IsChannelDeep(o.x) && !o.x.linkingId) { // TODO: Is this safe? - o.x.linkingId = spec.linkingId ?? linkID; + o.x.linkingId = spec.linkingId; isAdded = true; } }); From ba16d2aa31ada54be81951843a6a805d3850160b Mon Sep 17 00:00:00 2001 From: etowahadams Date: Thu, 20 Jun 2024 16:45:27 -0400 Subject: [PATCH 057/139] refactor: clean up circular brush --- demo/examples/circular-brush-example.ts | 4 ++-- demo/renderer/main.ts | 5 +++-- src/missing-types.d.ts | 7 +++++++ src/tracks/brush-circular/brush-circular-plot.ts | 10 +++++----- src/tracks/brush-circular/brush-circular.ts | 10 +++++----- src/tracks/brush-circular/index.ts | 4 ++-- 6 files changed, 24 insertions(+), 16 deletions(-) diff --git a/demo/examples/circular-brush-example.ts b/demo/examples/circular-brush-example.ts index 25c03a818..c6ba4b2c0 100644 --- a/demo/examples/circular-brush-example.ts +++ b/demo/examples/circular-brush-example.ts @@ -1,5 +1,5 @@ import { PixiManager } from '@pixi-manager'; -import { CircularBrushTrack } from '@gosling-lang/brush-circular'; +import { BrushCircularTrack } from '@gosling-lang/brush-circular'; import { signal } from '@preact/signals-core'; export function addCircularBrush(pixiManager: PixiManager) { @@ -20,7 +20,7 @@ export function addCircularBrush(pixiManager: PixiManager) { axisPositionHorizontal: 'left' }; - new CircularBrushTrack( + new BrushCircularTrack( circularBrushTrackOptions, circularDomain, detailedDomain, diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index 33d81f1dc..3cc02351b 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -14,7 +14,8 @@ import type { GoslingTrackOptions } from '../../src/tracks/gosling-track/gosling import { proccessTextHeader } from './text'; import { processGoslingTrack } from './gosling'; import { getDataFetcher } from './dataFetcher'; -import type { LinkedEncoding } from '../App'; +import type { LinkedEncoding } from './linkedEncoding'; +import type { BrushCircularTrackOptions } from '@gosling-lang/brush-circular'; /** * All the different types of tracks that can be rendered @@ -38,7 +39,7 @@ interface TrackOptionsMap { [TrackType.Gosling]: GoslingTrackOptions; [TrackType.Axis]: AxisTrackOptions; [TrackType.BrushLinear]: BrushLinearTrackOptions; - [TrackType.BrushCircular]: any; + [TrackType.BrushCircular]: BrushCircularTrackOptions; [TrackType.Heatmap]: any; } diff --git a/src/missing-types.d.ts b/src/missing-types.d.ts index 76089ba0f..f312fb169 100644 --- a/src/missing-types.d.ts +++ b/src/missing-types.d.ts @@ -588,6 +588,13 @@ declare module '@higlass/tracks' { svgElement: SVGElement; } + interface ViewportTrackerHorizontalContext extends SVGTrackContext { + registerViewportChanged: (uid: string, callback: (viewportXScale: ScaleLinear, viewportYScale: ScaleLinear) => void) => void; + removeViewportChanged: (uid: string) => void; + setDomainsCallback: (xDomain: [number, number], yDomain: [number, number]) => void; + projectionXDomain: [number, number]; // The domain of the brush + } + /* eslint-disable-next-line @typescript-eslint/ban-types */ type LiteralUnion = T | (U & {}); diff --git a/src/tracks/brush-circular/brush-circular-plot.ts b/src/tracks/brush-circular/brush-circular-plot.ts index 227a4e155..0e42e70ea 100644 --- a/src/tracks/brush-circular/brush-circular-plot.ts +++ b/src/tracks/brush-circular/brush-circular-plot.ts @@ -1,7 +1,7 @@ import { CircularBrushTrackClass, - type CircularBrushTrackOptions, - type CircularBrushTrackContext + type BrushCircularTrackOptions, + type BrushCircularTrackContext } from './brush-circular'; import { scaleLinear } from 'd3-scale'; import { ZoomTransform, type D3ZoomEvent, zoom } from 'd3-zoom'; @@ -9,14 +9,14 @@ import { select } from 'd3-selection'; import { type Signal, effect } from '@preact/signals-core'; import { zoomWheelBehavior } from '../utils'; -export class CircularBrushTrack extends CircularBrushTrackClass { +export class BrushCircularTrack extends CircularBrushTrackClass { xDomain: Signal; xBrushDomain: Signal; zoomStartScale = scaleLinear(); // This is the scale that we use to store the domain when the user starts zooming #element: HTMLElement; // This is the div that we're going to apply the zoom behavior to constructor( - options: CircularBrushTrackOptions, + options: BrushCircularTrackOptions, xDomain: Signal<[number, number]>, xBrushDomain: Signal<[number, number]>, overlayDiv: HTMLElement @@ -31,7 +31,7 @@ export class CircularBrushTrack extends CircularBrushTrackClass { overlayDiv.appendChild(svgElement); // Setup the context object - const context: CircularBrushTrackContext = { + const context: BrushCircularTrackContext = { id: 'test', svgElement: svgElement, getTheme: () => 'light', diff --git a/src/tracks/brush-circular/brush-circular.ts b/src/tracks/brush-circular/brush-circular.ts index c4483f989..38b3f1a52 100644 --- a/src/tracks/brush-circular/brush-circular.ts +++ b/src/tracks/brush-circular/brush-circular.ts @@ -12,9 +12,9 @@ type CircularBrushData = { cursor: string; }; -export type CircularBrushTrackContext = ViewportTrackerHorizontalContext; +export type BrushCircularTrackContext = ViewportTrackerHorizontalContext; -export interface CircularBrushTrackOptions { +export interface BrushCircularTrackOptions { innerRadius: number; outerRadius: number; startAngle: number; @@ -26,7 +26,7 @@ export interface CircularBrushTrackOptions { projectionStrokeOpacity: number; strokeWidth: number; } -const defaultOptions: CircularBrushTrackOptions = { +const defaultOptions: BrushCircularTrackOptions = { innerRadius: 100, outerRadius: 200, startAngle: 0, @@ -38,7 +38,7 @@ const defaultOptions: CircularBrushTrackOptions = { projectionStrokeOpacity: 0.7, strokeWidth: 1 }; -export class CircularBrushTrackClass extends SVGTrack { +export class CircularBrushTrackClass extends SVGTrack { circularBrushData: CircularBrushData[]; prevExtent: [number, number]; uid: string; @@ -52,7 +52,7 @@ export class CircularBrushTrackClass extends SVGTrack gBrush: Selection; startEvent: any; - constructor(context: CircularBrushTrackContext, options: CircularBrushTrackOptions) { + constructor(context: BrushCircularTrackContext, options: BrushCircularTrackOptions) { super(context, options); // context, options const { registerViewportChanged, removeViewportChanged, setDomainsCallback } = context; diff --git a/src/tracks/brush-circular/index.ts b/src/tracks/brush-circular/index.ts index 90b645a5d..7f97039f7 100644 --- a/src/tracks/brush-circular/index.ts +++ b/src/tracks/brush-circular/index.ts @@ -1,2 +1,2 @@ -export { CircularBrushTrack } from './brush-circular-plot'; -export type { CircularBrushTrackOptions, CircularBrushTrackContext } from './brush-circular'; +export { BrushCircularTrack } from './brush-circular-plot'; +export type { BrushCircularTrackContext, BrushCircularTrackOptions } from './brush-circular'; From 11775fdb97f7efeb627c145838362b8b05738340 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Fri, 21 Jun 2024 15:38:09 -0400 Subject: [PATCH 058/139] feat: dual circular brush plot --- demo/App.tsx | 696 +++++------------- demo/examples/circular-brush-example.ts | 13 +- demo/renderer/brushLinear.ts | 78 +- demo/renderer/gosling.ts | 15 +- demo/renderer/linkedEncoding.ts | 118 ++- demo/renderer/main.ts | 15 +- src/compiler/spec-preprocess.ts | 2 + src/pixi-manager/pixi-manager.ts | 21 +- .../brush-circular/brush-circular-plot.ts | 69 +- src/tracks/brush-linear/brush-linear-plot.ts | 14 +- 10 files changed, 420 insertions(+), 621 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index d1b7d877b..7bc8749e6 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -50,13 +50,14 @@ function App() { console.warn(gs); // showTrackInfoPositions(trackInfos, pixiManager); const linkedEncodings = getLinkedEncodings(gs); - console.warn(linkedEncodings); + console.warn('linkedEncodings', linkedEncodings); const trackDefs = createTrackDefs(trackInfos, theme); + console.warn('trackDefs', trackDefs); renderTrackDefs(trackDefs, linkedEncodings, pixiManager); }; // Compile the spec - compile(corces, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); + compile(doubleBrush, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); }, []); return ( @@ -444,524 +445,6 @@ const corces = { ] }; -const genes = { - layout: 'linear', - xDomain: { chromosome: 'chr3', interval: [52168000, 52890000] }, - arrangement: 'horizontal', - views: [ - { - arrangement: 'vertical', - views: [ - { - alignment: 'overlay', - title: 'HiGlass', - data: { - url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', - type: 'beddb', - genomicFields: [ - { index: 1, name: 'start' }, - { index: 2, name: 'end' } - ], - valueFields: [ - { index: 5, name: 'strand', type: 'nominal' }, - { index: 3, name: 'name', type: 'nominal' } - ], - exonIntervalFields: [ - { index: 12, name: 'start' }, - { index: 13, name: 'end' } - ] - }, - tracks: [ - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['+'] } - ], - mark: 'triangleRight', - x: { field: 'end', type: 'genomic', axis: 'top' }, - size: { value: 15 } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], - mark: 'text', - text: { field: 'name', type: 'nominal' }, - x: { field: 'start', type: 'genomic' }, - xe: { field: 'end', type: 'genomic' }, - style: { dy: -15 } - }, - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['-'] } - ], - mark: 'triangleLeft', - x: { field: 'start', type: 'genomic' }, - size: { value: 15 }, - style: { align: 'right' } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['exon'] }], - mark: 'rect', - x: { field: 'start', type: 'genomic' }, - size: { value: 15 }, - xe: { field: 'end', type: 'genomic' } - }, - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['+'] } - ], - mark: 'rule', - x: { field: 'start', type: 'genomic' }, - strokeWidth: { value: 3 }, - xe: { field: 'end', type: 'genomic' }, - style: { linePattern: { type: 'triangleRight', size: 5 } } - }, - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['-'] } - ], - mark: 'rule', - x: { field: 'start', type: 'genomic' }, - strokeWidth: { value: 3 }, - xe: { field: 'end', type: 'genomic' }, - style: { linePattern: { type: 'triangleLeft', size: 5 } } - } - ], - row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, - color: { - field: 'strand', - type: 'nominal', - domain: ['+', '-'], - range: ['#7585FF', '#FF8A85'] - }, - visibility: [ - { - operation: 'less-than', - measure: 'width', - threshold: '|xe-x|', - transitionPadding: 10, - target: 'mark' - } - ], - opacity: { value: 0.8 }, - width: 350, - height: 100 - }, - { - alignment: 'overlay', - title: 'Corces et al.', - data: { - url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', - type: 'beddb', - genomicFields: [ - { index: 1, name: 'start' }, - { index: 2, name: 'end' } - ], - valueFields: [ - { index: 5, name: 'strand', type: 'nominal' }, - { index: 3, name: 'name', type: 'nominal' } - ], - exonIntervalFields: [ - { index: 12, name: 'start' }, - { index: 13, name: 'end' } - ] - }, - tracks: [ - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['+'] } - ], - mark: 'text', - text: { field: 'name', type: 'nominal' }, - x: { field: 'start', type: 'genomic' }, - xe: { field: 'end', type: 'genomic' }, - size: { value: 8 }, - style: { textFontSize: 8, dy: -12 } - }, - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['-'] } - ], - mark: 'text', - text: { field: 'name', type: 'nominal' }, - x: { field: 'start', type: 'genomic' }, - xe: { field: 'end', type: 'genomic' }, - size: { value: 8 }, - style: { textFontSize: 8, dy: 10 } - }, - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['+'] } - ], - mark: 'rect', - x: { field: 'end', type: 'genomic' }, - size: { value: 7 } - }, - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['-'] } - ], - mark: 'rect', - x: { field: 'start', type: 'genomic' }, - size: { value: 7 } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['exon'] }], - mark: 'rect', - x: { field: 'start', type: 'genomic' }, - xe: { field: 'end', type: 'genomic' }, - size: { value: 14 } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], - mark: 'rule', - x: { field: 'start', type: 'genomic' }, - xe: { field: 'end', type: 'genomic' }, - strokeWidth: { value: 3 } - } - ], - row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, - color: { - field: 'strand', - type: 'nominal', - domain: ['+', '-'], - range: ['#012DB8', '#BE1E2C'] - }, - visibility: [ - { - operation: 'less-than', - measure: 'width', - threshold: '|xe-x|', - transitionPadding: 10, - target: 'mark' - } - ], - width: 350, - height: 100 - }, - { - alignment: 'overlay', - title: 'IGV', - data: { - url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', - type: 'beddb', - genomicFields: [ - { index: 1, name: 'start' }, - { index: 2, name: 'end' } - ], - valueFields: [ - { index: 5, name: 'strand', type: 'nominal' }, - { index: 3, name: 'name', type: 'nominal' } - ], - exonIntervalFields: [ - { index: 12, name: 'start' }, - { index: 13, name: 'end' } - ] - }, - tracks: [ - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], - mark: 'text', - text: { field: 'name', type: 'nominal' }, - x: { field: 'start', type: 'genomic', axis: 'top' }, - xe: { field: 'end', type: 'genomic' } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], - mark: 'rect', - x: { field: 'start', type: 'genomic', axis: 'top' }, - size: { value: 15 }, - xe: { field: 'end', type: 'genomic' } - }, - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['-'] } - ], - mark: 'rule', - x: { field: 'start', type: 'genomic', axis: 'top' }, - strokeWidth: { value: 0 }, - xe: { field: 'end', type: 'genomic' }, - color: { value: 'white' }, - opacity: { value: 0.6 }, - style: { linePattern: { type: 'triangleLeft', size: 10 } } - }, - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['+'] } - ], - mark: 'rule', - x: { field: 'start', type: 'genomic', axis: 'top' }, - strokeWidth: { value: 0 }, - xe: { field: 'end', type: 'genomic' }, - color: { value: 'white' }, - opacity: { value: 0.6 }, - style: { linePattern: { type: 'triangleRight', size: 10 } } - } - ], - row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, - color: { value: '#0900B1' }, - visibility: [ - { - operation: 'less-than', - measure: 'width', - threshold: '|xe-x|', - transitionPadding: 10, - target: 'mark' - } - ], - width: 350, - height: 100 - } - ] - }, - { - arrangement: 'vertical', - views: [ - { - alignment: 'overlay', - title: 'Cyverse-QUBES', - data: { - url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', - type: 'beddb', - genomicFields: [ - { index: 1, name: 'start' }, - { index: 2, name: 'end' } - ], - valueFields: [ - { index: 5, name: 'strand', type: 'nominal' }, - { index: 3, name: 'name', type: 'nominal' } - ], - exonIntervalFields: [ - { index: 12, name: 'start' }, - { index: 13, name: 'end' } - ] - }, - tracks: [ - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], - mark: 'text', - text: { field: 'name', type: 'nominal' }, - x: { field: 'start', type: 'genomic' }, - xe: { field: 'end', type: 'genomic' }, - color: { value: 'black' } - }, - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['+'] } - ], - mark: 'triangleRight', - x: { field: 'end', type: 'genomic', axis: 'top' }, - color: { value: '#999999' } - }, - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['-'] } - ], - mark: 'triangleLeft', - x: { field: 'start', type: 'genomic', axis: 'top' }, - color: { value: '#999999' }, - style: { align: 'right' } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], - mark: 'rect', - x: { field: 'start', type: 'genomic', axis: 'top' }, - xe: { field: 'end', type: 'genomic' }, - color: { value: 'lightgray' } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], - mark: 'rule', - x: { field: 'start', type: 'genomic', axis: 'top' }, - strokeWidth: { value: 5 }, - xe: { field: 'end', type: 'genomic' }, - color: { value: 'gray' } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['exon'] }], - mark: 'rect', - x: { field: 'start', type: 'genomic', axis: 'top' }, - xe: { field: 'end', type: 'genomic' }, - color: { value: '#E2A6F5' }, - stroke: { value: '#BB57C9' }, - strokeWidth: { value: 1 } - } - ], - row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, - visibility: [ - { - operation: 'less-than', - measure: 'width', - threshold: '|xe-x|', - transitionPadding: 10, - target: 'mark' - } - ], - size: { value: 15 }, - width: 350, - height: 100 - }, - { - alignment: 'overlay', - title: 'GmGDV', - data: { - url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', - type: 'beddb', - genomicFields: [ - { index: 1, name: 'start' }, - { index: 2, name: 'end' } - ], - valueFields: [ - { index: 5, name: 'strand', type: 'nominal' }, - { index: 3, name: 'name', type: 'nominal' } - ], - exonIntervalFields: [ - { index: 12, name: 'start' }, - { index: 13, name: 'end' } - ] - }, - tracks: [ - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], - mark: 'text', - text: { field: 'name', type: 'nominal' }, - x: { field: 'start', type: 'genomic', axis: 'top' }, - xe: { field: 'end', type: 'genomic' }, - style: { dy: -14 } - }, - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['+'] } - ], - mark: 'triangleRight', - x: { field: 'end', type: 'genomic', axis: 'top' }, - size: { value: 15 } - }, - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['-'] } - ], - mark: 'triangleLeft', - x: { field: 'start', type: 'genomic', axis: 'top' }, - size: { value: 15 }, - style: { align: 'right' } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['exon'] }], - mark: 'rect', - x: { field: 'start', type: 'genomic', axis: 'top' }, - size: { value: 10 }, - xe: { field: 'end', type: 'genomic' } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], - mark: 'rule', - x: { field: 'start', type: 'genomic', axis: 'top' }, - strokeWidth: { value: 3 }, - xe: { field: 'end', type: 'genomic' } - } - ], - row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, - color: { - field: 'strand', - type: 'nominal', - domain: ['+', '-'], - range: ['blue', 'red'] - }, - visibility: [ - { - operation: 'less-than', - measure: 'width', - threshold: '|xe-x|', - transitionPadding: 10, - target: 'mark' - } - ], - width: 350, - height: 100 - }, - { - alignment: 'overlay', - data: { - url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', - type: 'beddb', - genomicFields: [ - { index: 1, name: 'start' }, - { index: 2, name: 'end' } - ], - valueFields: [ - { index: 5, name: 'strand', type: 'nominal' }, - { index: 3, name: 'name', type: 'nominal' } - ], - exonIntervalFields: [ - { index: 12, name: 'start' }, - { index: 13, name: 'end' } - ] - }, - tracks: [ - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], - mark: 'text', - text: { field: 'name', type: 'nominal' }, - x: { field: 'start', type: 'genomic', axis: 'top' }, - color: { value: 'black' }, - xe: { field: 'end', type: 'genomic' } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], - mark: 'rect', - x: { field: 'start', type: 'genomic', axis: 'top' }, - xe: { field: 'end', type: 'genomic' }, - color: { value: '#666666' } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['exon'] }], - mark: 'rect', - x: { field: 'start', type: 'genomic', axis: 'top' }, - xe: { field: 'end', type: 'genomic' }, - color: { value: '#FF6666' } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['intron'] }], - mark: 'rect', - x: { field: 'start', type: 'genomic', axis: 'top' }, - xe: { field: 'end', type: 'genomic' }, - color: { value: '#99FEFF' } - } - ], - size: { value: 30 }, - row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, - stroke: { value: '#777777' }, - strokeWidth: { value: 1 }, - visibility: [ - { - operation: 'less-than', - measure: 'width', - threshold: '|xe-x|', - transitionPadding: 10, - target: 'mark' - } - ], - width: 350, - height: 100 - } - ] - } - ] -}; - const linkingTest = { title: 'Basic Marks: line', subtitle: 'Tutorial Examples', @@ -1049,3 +532,176 @@ const linkingTest = { } ] }; + +const doubleBrush = { + arrangement: 'vertical', + views: [ + { + static: true, + layout: 'circular', + alignment: 'stack', + tracks: [ + { + id: 'overview track', + alignment: 'overlay', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=cistrome-multivec', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1', 'sample 2', 'sample 3', 'sample 4'], + binSize: 4 + }, + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative' }, + row: { field: 'sample', type: 'nominal' }, + color: { field: 'sample', type: 'nominal' }, + stroke: { value: 'black' }, + strokeWidth: { value: 0.3 }, + tracks: [ + { mark: 'bar' }, + { + mark: 'brush', + x: { linkingId: 'detail-1' }, + color: { value: 'blue' } + }, + { + mark: 'brush', + x: { linkingId: 'detail-2' }, + color: { value: 'red' } + } + ], + style: { outlineWidth: 0 }, + width: 500, + height: 100 + }, + { + data: { + type: 'csv', + url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/rearrangements.bulk.1639.simple.filtered.pub', + headerNames: [ + 'chr1', + 'p1s', + 'p1e', + 'chr2', + 'p2s', + 'p2e', + 'type', + 'id', + 'f1', + 'f2', + 'f3', + 'f4', + 'f5', + 'f6' + ], + separator: '\t', + genomicFieldsToConvert: [ + { chromosomeField: 'chr1', genomicFields: ['p1s', 'p1e'] }, + { chromosomeField: 'chr2', genomicFields: ['p2s', 'p2e'] } + ] + }, + dataTransform: [ + { + type: 'filter', + field: 'chr1', + oneOf: ['1', '16', '14', '9', '6', '5', '3'] + }, + { + type: 'filter', + field: 'chr2', + oneOf: ['1', '16', '14', '9', '6', '5', '3'] + } + ], + mark: 'withinLink', + x: { field: 'p1s', type: 'genomic' }, + xe: { field: 'p1e', type: 'genomic' }, + x1: { field: 'p2s', type: 'genomic' }, + x1e: { field: 'p2e', type: 'genomic' }, + stroke: { + field: 'type', + type: 'nominal', + domain: ['deletion', 'inversion', 'translocation', 'tandem-duplication'] + }, + strokeWidth: { value: 0.8 }, + opacity: { value: 0.15 }, + width: 500, + height: 100 + } + ] + }, + { + spacing: 10, + arrangement: 'horizontal', + views: [ + { + tracks: [ + { + id: 'detail-1', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=cistrome-multivec', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1', 'sample 2', 'sample 3', 'sample 4'], + binSize: 4 + }, + mark: 'bar', + x: { + field: 'start', + type: 'genomic', + linkingId: 'detail-1', + domain: { chromosome: 'chr5' } + }, + xe: { field: 'end', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative' }, + row: { field: 'sample', type: 'nominal' }, + color: { field: 'sample', type: 'nominal' }, + stroke: { value: 'black' }, + strokeWidth: { value: 0.3 }, + style: { background: 'blue' }, + width: 245, + height: 150 + } + ] + }, + { + tracks: [ + { + id: 'detail-2', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=cistrome-multivec', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1', 'sample 2', 'sample 3', 'sample 4'], + binSize: 4 + }, + mark: 'bar', + x: { + field: 'start', + type: 'genomic', + domain: { chromosome: 'chr16' }, + linkingId: 'detail-2' + }, + xe: { field: 'end', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative' }, + row: { field: 'sample', type: 'nominal' }, + color: { field: 'sample', type: 'nominal', legend: true }, + stroke: { value: 'black' }, + strokeWidth: { value: 0.3 }, + style: { background: 'red' }, + width: 245, + height: 150 + } + ] + } + ], + style: { backgroundOpacity: 0.1 } + } + ] +}; diff --git a/demo/examples/circular-brush-example.ts b/demo/examples/circular-brush-example.ts index c6ba4b2c0..65bacc1c9 100644 --- a/demo/examples/circular-brush-example.ts +++ b/demo/examples/circular-brush-example.ts @@ -1,11 +1,13 @@ import { PixiManager } from '@pixi-manager'; import { BrushCircularTrack } from '@gosling-lang/brush-circular'; import { signal } from '@preact/signals-core'; +import { panZoom } from '@gosling-lang/interactors'; export function addCircularBrush(pixiManager: PixiManager) { const pos0 = { x: 10, y: 100, width: 250, height: 250 }; const circularDomain = signal<[number, number]>([0, 248956422]); const detailedDomain = signal<[number, number]>([160000000, 200000000]); + const detailedDomain2 = signal<[number, number]>([0, 100000000]); const circularBrushTrackOptions = { projectionFillColor: 'gray', @@ -20,10 +22,17 @@ export function addCircularBrush(pixiManager: PixiManager) { axisPositionHorizontal: 'left' }; + const overlayDiv = pixiManager.makeContainer(pos0).overlayDiv; + new BrushCircularTrack( circularBrushTrackOptions, - circularDomain, detailedDomain, pixiManager.makeContainer(pos0).overlayDiv - ); + ).addInteractor(plot => panZoom(plot, circularDomain)); + + new BrushCircularTrack( + circularBrushTrackOptions, + detailedDomain2, + pixiManager.makeContainer(pos0).overlayDiv + ).addInteractor(plot => panZoom(plot, circularDomain)); } diff --git a/demo/renderer/brushLinear.ts b/demo/renderer/brushLinear.ts index f7cf41243..aeb0af866 100644 --- a/demo/renderer/brushLinear.ts +++ b/demo/renderer/brushLinear.ts @@ -1,24 +1,70 @@ import { type SingleTrack, type Track } from '@gosling-lang/gosling-schema'; import type { BrushLinearTrackOptions } from '@gosling-lang/brush-linear'; +import type { BrushCircularTrackOptions } from '@gosling-lang/brush-circular'; +import { type TrackDef, TrackType } from './main'; -export function getBrushTrackOptions(spec: Track) { - if (!spec._overlay) { - return []; - } - - const brushTrackOptions: BrushLinearTrackOptions[] = []; +export function getBrushTrackDefs( + spec: Track, + boundingBox: { x: number; y: number; width: number; height: number } +): TrackDef[] | TrackDef[] { + const trackDefs: TrackDef[] = []; + // If we have a linear layout, we use the BrushLinearTrack + if (!spec._overlay) return []; spec._overlay.forEach((overlay: SingleTrack) => { - if (overlay.mark === 'brush') { - const options = { - projectionFillColor: spec.color?.value ?? 'red', - projectionStrokeColor: spec.stroke?.value ?? 'red', - projectionFillOpacity: spec.opacity?.value ?? 0.3, - projectionStrokeOpacity: spec.opacity?.value ?? 0.3, - strokeWidth: spec.strokeWidth?.value ?? 1 - }; - brushTrackOptions.push(options); + if (overlay.mark !== 'brush') return; + + if (spec.layout === 'linear') { + const options = getBrushLinearOptions(spec); + trackDefs.push({ + type: TrackType.BrushLinear, + trackId: overlay.id, + boundingBox: { ...boundingBox }, + options + }); + } else if (spec.layout === 'circular') { + // If we have a circular layout, we use the BrushCircularTrack + const options = getBrushCircularOptions(spec); + trackDefs.push({ + type: TrackType.BrushCircular, + trackId: overlay.id, + boundingBox: { ...boundingBox }, + options + }); } }); - return brushTrackOptions; + return trackDefs; +} + +/** + * Get the options for a BrushLinearTrack + */ +function getBrushLinearOptions(spec: Track): BrushLinearTrackOptions { + const options = { + projectionFillColor: spec.color?.value ?? 'red', + projectionStrokeColor: spec.stroke?.value ?? 'red', + projectionFillOpacity: spec.opacity?.value ?? 0.3, + projectionStrokeOpacity: spec.opacity?.value ?? 0.3, + strokeWidth: spec.strokeWidth?.value ?? 1 + }; + return options; +} + +/** + * Get the options for a BrushCircularTrack + */ +function getBrushCircularOptions(spec: Track): BrushCircularTrackOptions { + const options = { + projectionFillColor: spec.color?.value ?? 'red', + projectionStrokeColor: 'black', + projectionFillOpacity: 0.3, + projectionStrokeOpacity: 0.3, + strokeWidth: 0.3, + startAngle: spec.startAngle ?? 7.2, + endAngle: spec.endAngle ?? 352.8, + innerRadius: spec.innerRadius ?? 151.08695652173913, + outerRadius: spec.outerRadius ?? 250, + axisPositionHorizontal: 'left' + }; + return options; } diff --git a/demo/renderer/gosling.ts b/demo/renderer/gosling.ts index b210b993f..3ff111d93 100644 --- a/demo/renderer/gosling.ts +++ b/demo/renderer/gosling.ts @@ -8,7 +8,7 @@ import type { BrushLinearTrackOptions } from '@gosling-lang/brush-linear'; import { getAxisTrackDef } from './axis'; import { type TrackDef, TrackType } from './main'; -import { getBrushTrackOptions } from './brushLinear'; +import { getBrushTrackDefs } from './brushLinear'; export function processGoslingTrack( track: Track, @@ -29,8 +29,8 @@ export function processGoslingTrack( boundingBox = newTrackBbox; } + // Add the Gosling track const goslingTrackOptions = getGoslingTrackOptions(track, theme); - trackDefs.push({ type: TrackType.Gosling, trackId: track.id, @@ -39,14 +39,9 @@ export function processGoslingTrack( }); // Add the brush after Gosling track so that it is on top - const brushTrackOptions = getBrushTrackOptions(track); - brushTrackOptions.forEach(brushTrackOption => { - trackDefs.push({ - type: TrackType.BrushLinear, - trackId: track.id, - boundingBox: { ...boundingBox }, - options: brushTrackOption - }); + const brushTrackDefs = getBrushTrackDefs(track, boundingBox); + brushTrackDefs.forEach(brushTrackDef => { + trackDefs.push(brushTrackDef); }); return trackDefs; diff --git a/demo/renderer/linkedEncoding.ts b/demo/renderer/linkedEncoding.ts index 0a74d4f89..119b55977 100644 --- a/demo/renderer/linkedEncoding.ts +++ b/demo/renderer/linkedEncoding.ts @@ -3,6 +3,7 @@ import { GenomicPositionHelper, computeChromSizes } from '../../src/core/utils/a import { signal, type Signal } from '@preact/signals-core'; import type { GoslingSpec } from 'gosling.js'; import { TrackType } from './main'; +import type { Link } from 'react-router-dom'; /** * This is the information needed to link tracks together @@ -14,6 +15,31 @@ export interface LinkedEncoding { trackIds: string[]; brushIds: string[]; } + +const example1: LinkedEncoding = { + linkingId: 'detail-1', + encoding: 'x', + signal: signal([0, 100]), + trackIds: ['track1', 'track2', ''], + brushIds: ['brush1'] +}; + +const example2: LinkedEncoding = { + linkingId: 'detail-2', + encoding: 'x', + signal: signal([0, 100]), + trackIds: ['track2'], + brushIds: ['brush2'] +}; + +const example3: LinkedEncoding = { + linkingId: undefined, + encoding: 'x', + signal: signal([0, 100]), + trackIds: ['overview track', 'brush1', 'brush2'], + brushIds: [] +}; + /** * This is information extracted from the Gosling spec. * Is is the linking that is defined at the view level. @@ -34,6 +60,7 @@ interface TrackLink { linkingId: string; trackId: string; trackType: TrackType; + signal?: Signal; // Some encodings have a "domain" property that can be used to create a signal } /** @@ -45,17 +72,13 @@ interface LinkInfo { viewLinks: ViewLink[]; } -function filterLinkedTracksByType(trackType: TrackType, linkingId: string | undefined, trackLinks: TrackLink[]) { - if (!linkingId) return []; - return trackLinks.filter(trackLink => trackLink.linkingId === linkingId && trackLink.trackType === trackType); -} - /** * Extracts the linked encodings from a GoslingSpec */ export function getLinkedEncodings(gs: GoslingSpec) { // First, we traverse the gosling spec to find all the linked tracks and brushes const { trackLinks, viewLinks } = getLinedFeaturesRecursive(gs); + console.warn('trackLinks', trackLinks); // We combine the tracks and views that are linked together const linkedEncodings = viewLinks.map(viewLink => { const linkedBrushes = filterLinkedTracksByType(TrackType.BrushLinear, viewLink.linkingId, trackLinks); @@ -68,9 +91,70 @@ export function getLinkedEncodings(gs: GoslingSpec) { brushIds: linkedBrushes.map(brush => brush.trackId) } as LinkedEncoding; }); + // Combine trackLinks that do not belong to any viewLink + const unlinkedTracks = trackLinks.filter( + trackLink => !linkedEncodings.some(link => trackLink.linkingId === link.linkingId) + ); + linkedEncodings.push(...combineUnlinkedTracks(unlinkedTracks)); + + return linkedEncodings.filter(link => link.trackIds.length > 0 || link.brushIds.length > 0); +} + +/** + * This function takes a list of unlinked tracks and combines them into linked encodings + * This can happen when a track uses the "domain" property + */ +function combineUnlinkedTracks(unlinkedTracks: TrackLink[]): LinkedEncoding[] { + console.warn('unlinkedTracks', unlinkedTracks); + const linkedEncodings: LinkedEncoding[] = []; + unlinkedTracks.forEach(trackLink => { + const existingLink = linkedEncodings.find(link => link.linkingId === trackLink.linkingId); + if (existingLink) { + if (isBrushTrack(trackLink.trackType)) { + existingLink.brushIds.push(trackLink.trackId); + } else { + existingLink.trackIds.push(trackLink.trackId); + } + if (trackLink.signal) { + existingLink.signal = trackLink.signal; + } + } else { + const newLink = { + linkingId: trackLink.linkingId, + encoding: trackLink.encoding + } as LinkedEncoding; + + if (isBrushTrack(trackLink.trackType)) { + newLink.brushIds = [trackLink.trackId]; + newLink.trackIds = []; + } else { + newLink.trackIds = [trackLink.trackId]; + newLink.brushIds = []; + } + if (trackLink.signal) { + newLink.signal = trackLink.signal; + } + linkedEncodings.push(newLink); + } + }); + console.warn('linkedEncodings from unlinked', linkedEncodings); return linkedEncodings; } +/** + * Helper function to determine if a track is a brush track + */ +function isBrushTrack(trackType: TrackType) { + return trackType === TrackType.BrushLinear || trackType === TrackType.BrushCircular; +} +/** + * Helper function to filter the linked tracks by type + */ +function filterLinkedTracksByType(trackType: TrackType, linkingId: string | undefined, trackLinks: TrackLink[]) { + if (!linkingId) return []; + return trackLinks.filter(trackLink => trackLink.linkingId === linkingId && trackLink.trackType === trackType); +} + /** * Traverses the gosling spec to find all the linked tracks and brushes */ @@ -101,18 +185,25 @@ function getSingleViewTrackLinks(gs: SingleView): TrackLink[] { const trackLinks: TrackLink[] = []; tracks.forEach(track => { if ('x' in track && track.x && 'linkingId' in track.x) { - trackLinks.push({ + const trackLink = { trackId: track.id, linkingId: track.x.linkingId, trackType: TrackType.Gosling, encoding: 'x' - }); + } as TrackLink; + // If the track has a domain, we create a signal and add it to the trackLink + if (track.x.domain !== undefined) { + const { assembly } = gs; + const domain = getDomain(track.x.domain, assembly); + trackLink.signal = signal(domain); + } + trackLinks.push(trackLink); } if (!('_overlay' in track)) return; track._overlay!.forEach(overlay => { if (overlay.mark === 'brush') { const trackType = gs.layout === 'linear' ? TrackType.BrushLinear : TrackType.BrushCircular; - trackLinks.push({ trackId: track.id, linkingId: overlay.x.linkingId, trackType, encoding: 'x' }); + trackLinks.push({ trackId: overlay.id, linkingId: overlay.x.linkingId, trackType, encoding: 'x' }); } }); }); @@ -135,9 +226,18 @@ function getSingleViewLinks(gs: SingleView): ViewLink { // Add each track to the link tracks.forEach(track => { // If the track is already linked, we don't need to add it again - if ('x' in track && track.x && 'linkingId' in track.x && track.x.linkingId) { + if ('x' in track && track.x && 'linkingId' in track.x && track.x?.linkingId) { return; } + // Add overlaid brush tracks to the link + if ('_overlay' in track) { + track._overlay?.forEach(overlay => { + if (overlay.mark === 'brush') { + newLink.trackIds.push(overlay.id); + } + }); + } + newLink.trackIds.push(track.id); }); return newLink; diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index 3cc02351b..012d166b1 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -15,7 +15,7 @@ import { proccessTextHeader } from './text'; import { processGoslingTrack } from './gosling'; import { getDataFetcher } from './dataFetcher'; import type { LinkedEncoding } from './linkedEncoding'; -import type { BrushCircularTrackOptions } from '@gosling-lang/brush-circular'; +import { BrushCircularTrack, type BrushCircularTrackOptions } from '@gosling-lang/brush-circular'; /** * All the different types of tracks that can be rendered @@ -109,9 +109,10 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE if (type === TrackType.Gosling) { const domain = getXDomainSignal(trackDef.trackId, linkedEncodings); const datafetcher = getDataFetcher(options.spec); - new GoslingTrack(options, datafetcher, pixiManager.makeContainer(boundingBox)).addInteractor(plot => - panZoom(plot, domain) - ); + const gosPlot = new GoslingTrack(options, datafetcher, pixiManager.makeContainer(boundingBox)) + if (!options.spec.static) { + gosPlot.addInteractor(plot => panZoom(plot, domain)); + } } if (type === TrackType.Axis) { const domain = getXDomainSignal(trackDef.trackId, linkedEncodings); @@ -125,6 +126,12 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE plot => panZoom(plot, domain) ); } + if (type === TrackType.BrushCircular) { + const domain = getXDomainSignal(trackDef.trackId, linkedEncodings); + const brushDomain = getBrushSignal(trackDef.trackId, linkedEncodings); + + new BrushCircularTrack(options, brushDomain, pixiManager.makeContainer(boundingBox).overlayDiv); + } }); } diff --git a/src/compiler/spec-preprocess.ts b/src/compiler/spec-preprocess.ts index af26c0a78..8cc541b0b 100644 --- a/src/compiler/spec-preprocess.ts +++ b/src/compiler/spec-preprocess.ts @@ -264,8 +264,10 @@ export function traverseToFixSpecDownstream(spec: GoslingSpec | SingleView, pare track._overlay = track._overlay.filter(overlayTrack => { return !('type' in overlayTrack && overlayTrack.type == 'dummy-track'); }); + // Add a unique ID to each overlay track track._overlay.forEach(o => { o.style = getStyleOverridden(track.style, o.style); + o.id = `overlay-${uuid().slice(0, 8)}`; }); } diff --git a/src/pixi-manager/pixi-manager.ts b/src/pixi-manager/pixi-manager.ts index ed47e034b..676603878 100644 --- a/src/pixi-manager/pixi-manager.ts +++ b/src/pixi-manager/pixi-manager.ts @@ -3,9 +3,17 @@ import * as PIXI from 'pixi.js'; /** * A wrapper class for PIXI.Application */ + +interface BoundingBox { + x: number; + y: number; + width: number; + height: number; +} export class PixiManager { app: PIXI.Application; containerElement: HTMLDivElement; + createdContainers: Map = new Map(); constructor(width: number, height: number, container: HTMLDivElement, fps: (fps: number) => void) { this.app = new PIXI.Application({ @@ -38,7 +46,7 @@ export class PixiManager { * @param position * @returns */ - makeContainer(position: { x: number; y: number; width: number; height: number }): { + makeContainer(position: BoundingBox): { pixiContainer: PIXI.Container; overlayDiv: HTMLDivElement; } { @@ -46,8 +54,15 @@ export class PixiManager { pContainer.position.set(position.x, position.y); this.app.stage.addChild(pContainer); - const plotDiv = createOverlayElement(position); - this.containerElement.appendChild(plotDiv); + let plotDiv: HTMLDivElement; + const positionString = JSON.stringify(position); + if (this.createdContainers.has(positionString)) { + plotDiv = this.createdContainers.get(positionString)!; + } else { + plotDiv = createOverlayElement(position); + this.createdContainers.set(positionString, plotDiv); + this.containerElement.appendChild(plotDiv); + } return { pixiContainer: pContainer, overlayDiv: plotDiv }; } diff --git a/src/tracks/brush-circular/brush-circular-plot.ts b/src/tracks/brush-circular/brush-circular-plot.ts index 0e42e70ea..cc3aa2fe0 100644 --- a/src/tracks/brush-circular/brush-circular-plot.ts +++ b/src/tracks/brush-circular/brush-circular-plot.ts @@ -6,29 +6,31 @@ import { import { scaleLinear } from 'd3-scale'; import { ZoomTransform, type D3ZoomEvent, zoom } from 'd3-zoom'; import { select } from 'd3-selection'; -import { type Signal, effect } from '@preact/signals-core'; +import { type Signal, effect, signal } from '@preact/signals-core'; import { zoomWheelBehavior } from '../utils'; export class BrushCircularTrack extends CircularBrushTrackClass { xDomain: Signal; xBrushDomain: Signal; zoomStartScale = scaleLinear(); // This is the scale that we use to store the domain when the user starts zooming - #element: HTMLElement; // This is the div that we're going to apply the zoom behavior to + domOverlay: HTMLElement; // This is the div that we're going to apply the zoom behavior to constructor( options: BrushCircularTrackOptions, - xDomain: Signal<[number, number]>, xBrushDomain: Signal<[number, number]>, - overlayDiv: HTMLElement + domOverlay: HTMLElement, + xDomain = signal<[number, number]>([0, 3088269832]) ) { - const height = overlayDiv.clientHeight; - const width = overlayDiv.clientWidth; - // Create a new svg element. The brush will be drawn on this element - const svgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svgElement.style.width = `${width}px`; - svgElement.style.height = `${height}px`; - // Add it to the overlay div - overlayDiv.appendChild(svgElement); + const height = domOverlay.clientHeight; + const width = domOverlay.clientWidth; + // If there is already an svg element, use it. Otherwise, create a new one + const existingSvgElement = domOverlay.querySelector('svg'); + const svgElement = existingSvgElement || document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + if (!existingSvgElement) { + svgElement.style.width = `${width}px`; + svgElement.style.height = `${height}px`; + domOverlay.appendChild(svgElement); + } // Setup the context object const context: BrushCircularTrackContext = { @@ -45,7 +47,7 @@ export class BrushCircularTrack extends CircularBrushTrackClass { this.xDomain = xDomain; this.xBrushDomain = xBrushDomain; - this.#element = overlayDiv; + this.domOverlay = domOverlay; // Now we need to initialize all of the properties that would normally be set by HiGlassComponent this.setDimensions([width, height]); this.setPosition([0, 0]); @@ -57,7 +59,6 @@ export class BrushCircularTrack extends CircularBrushTrackClass { this.refScalesChanged(refXScale, refYScale); // Draw and add the zoom behavior this.draw(); - this.#addZoom(); // When the brush signal changes, we want to update the brush effect(() => { @@ -66,42 +67,8 @@ export class BrushCircularTrack extends CircularBrushTrackClass { }); } - #addZoom(): void { - // This function will be called every time the user zooms - const zoomed = (event: D3ZoomEvent) => { - const newXDomain = event.transform.rescaleX(this.zoomStartScale).domain(); - this.xDomain.value = newXDomain; - }; - - // Create the zoom behavior - const zoomBehavior = zoom() - .wheelDelta(zoomWheelBehavior) - .filter(event => { - // We don't want to zoom if the user is dragging a brush - const isRect = event.target.tagName === 'rect'; - const isMousedown = event.type === 'mousedown'; - const isDraggingBrush = isRect && isMousedown; - // Here are the default filters - const defaultFilter = (!event.ctrlKey || event.type === 'wheel') && !event.button; - // Use the default filter and our custom filter - return defaultFilter && !isDraggingBrush; - }) - // @ts-expect-error We need to reset the transform when the user stops zooming - .on('end', () => (this.#element.__zoom = new ZoomTransform(1, 0, 0))) - .on('start', () => { - this.zoomStartScale.domain(this.xDomain.value).range([0, this.#element.clientWidth]); - }) - .on('zoom', zoomed.bind(this)); - - // Apply the zoom behavior to the overlay div - select(this.#element).call(zoomBehavior); - - // This scale will always have the same range, but the domain will change in the effect - const baseScale = scaleLinear().domain(this.xDomain.value).range([0, this.#element.clientWidth]); - // Every time the domain gets changed we want to update the zoom - effect(() => { - const newScale = baseScale.domain(this.xDomain.value); - this.zoomed(newScale, this._refYScale); - }); + addInteractor(interactor: (plot: BrushCircularTrack) => void) { + interactor(this); + return this; // For chaining } } diff --git a/src/tracks/brush-linear/brush-linear-plot.ts b/src/tracks/brush-linear/brush-linear-plot.ts index e6e22569e..7decc0012 100644 --- a/src/tracks/brush-linear/brush-linear-plot.ts +++ b/src/tracks/brush-linear/brush-linear-plot.ts @@ -19,12 +19,14 @@ export class BrushLinearTrack extends BrushLinearTrackClass Date: Fri, 21 Jun 2024 16:10:46 -0400 Subject: [PATCH 059/139] feat: linked circle --- demo/App.tsx | 85 ++++++++++++++++++- demo/renderer/linkedEncoding.ts | 38 ++------- demo/renderer/main.ts | 2 +- src/tracks/brush-linear/brush-linear-plot.ts | 1 + .../gosling-track/gosling-track-plot.ts | 14 ++- 5 files changed, 105 insertions(+), 35 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index 7bc8749e6..82c3e1392 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -57,7 +57,7 @@ function App() { }; // Compile the spec - compile(doubleBrush, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); + compile(visualLinking, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); }, []); return ( @@ -705,3 +705,86 @@ const doubleBrush = { } ] }; + +const visualLinking = { + title: 'Visual Linking', + subtitle: 'Change the position and range of brushes to update the detail view on the bottom', + arrangement: 'vertical', + centerRadius: 0.4, + views: [ + { + spacing: 40, + arrangement: 'horizontal', + views: [ + { + spacing: 5, + static: true, + layout: 'circular', + xDomain: { chromosome: 'chr1' }, + alignment: 'overlay', + tracks: [{ mark: 'bar' }, { mark: 'brush', x: { linkingId: 'detail' } }], + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=cistrome-multivec', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1', 'sample 2', 'sample 3', 'sample 4'] + }, + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative' }, + row: { field: 'sample', type: 'nominal' }, + color: { field: 'sample', type: 'nominal' }, + width: 250, + height: 130 + }, + { + layout: 'linear', + xDomain: { chromosome: 'chr1' }, + alignment: 'overlay', + tracks: [{ mark: 'bar' }, { mark: 'brush', x: { linkingId: 'detail' } }], + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=cistrome-multivec', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1', 'sample 2', 'sample 3', 'sample 4'] + }, + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative' }, + row: { field: 'sample', type: 'nominal' }, + color: { field: 'sample', type: 'nominal' }, + width: 400, + height: 200 + } + ] + }, + { + layout: 'linear', + xDomain: { chromosome: 'chr1', interval: [160000000, 200000000] }, + linkingId: 'detail', + tracks: [ + { + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=cistrome-multivec', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1', 'sample 2', 'sample 3', 'sample 4'] + }, + mark: 'bar', + x: { field: 'position', type: 'genomic', axis: 'top' }, + y: { field: 'peak', type: 'quantitative' }, + row: { field: 'sample', type: 'nominal' }, + color: { field: 'sample', type: 'nominal' }, + width: 690, + height: 200 + } + ] + } + ] +}; diff --git a/demo/renderer/linkedEncoding.ts b/demo/renderer/linkedEncoding.ts index 119b55977..4086eae6e 100644 --- a/demo/renderer/linkedEncoding.ts +++ b/demo/renderer/linkedEncoding.ts @@ -16,30 +16,6 @@ export interface LinkedEncoding { brushIds: string[]; } -const example1: LinkedEncoding = { - linkingId: 'detail-1', - encoding: 'x', - signal: signal([0, 100]), - trackIds: ['track1', 'track2', ''], - brushIds: ['brush1'] -}; - -const example2: LinkedEncoding = { - linkingId: 'detail-2', - encoding: 'x', - signal: signal([0, 100]), - trackIds: ['track2'], - brushIds: ['brush2'] -}; - -const example3: LinkedEncoding = { - linkingId: undefined, - encoding: 'x', - signal: signal([0, 100]), - trackIds: ['overview track', 'brush1', 'brush2'], - brushIds: [] -}; - /** * This is information extracted from the Gosling spec. * Is is the linking that is defined at the view level. @@ -79,10 +55,14 @@ export function getLinkedEncodings(gs: GoslingSpec) { // First, we traverse the gosling spec to find all the linked tracks and brushes const { trackLinks, viewLinks } = getLinedFeaturesRecursive(gs); console.warn('trackLinks', trackLinks); - // We combine the tracks and views that are linked together + // We associate tracks the other tracks they are linked with const linkedEncodings = viewLinks.map(viewLink => { - const linkedBrushes = filterLinkedTracksByType(TrackType.BrushLinear, viewLink.linkingId, trackLinks); - const linkedTracks = filterLinkedTracksByType(TrackType.Gosling, viewLink.linkingId, trackLinks); + const linkedBrushes = filterLinkedTracksByType( + [TrackType.BrushLinear, TrackType.BrushCircular], + viewLink.linkingId, + trackLinks + ); + const linkedTracks = filterLinkedTracksByType([TrackType.Gosling], viewLink.linkingId, trackLinks); return { linkingId: viewLink.linkingId, encoding: viewLink.encoding, @@ -150,9 +130,9 @@ function isBrushTrack(trackType: TrackType) { /** * Helper function to filter the linked tracks by type */ -function filterLinkedTracksByType(trackType: TrackType, linkingId: string | undefined, trackLinks: TrackLink[]) { +function filterLinkedTracksByType(trackType: TrackType[], linkingId: string | undefined, trackLinks: TrackLink[]) { if (!linkingId) return []; - return trackLinks.filter(trackLink => trackLink.linkingId === linkingId && trackLink.trackType === trackType); + return trackLinks.filter(trackLink => trackLink.linkingId === linkingId && trackType.includes(trackLink.trackType)); } /** diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index 012d166b1..67327c5e2 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -130,7 +130,7 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE const domain = getXDomainSignal(trackDef.trackId, linkedEncodings); const brushDomain = getBrushSignal(trackDef.trackId, linkedEncodings); - new BrushCircularTrack(options, brushDomain, pixiManager.makeContainer(boundingBox).overlayDiv); + new BrushCircularTrack(options, brushDomain, pixiManager.makeContainer(boundingBox).overlayDiv, domain); } }); } diff --git a/src/tracks/brush-linear/brush-linear-plot.ts b/src/tracks/brush-linear/brush-linear-plot.ts index 7decc0012..eb0d458f6 100644 --- a/src/tracks/brush-linear/brush-linear-plot.ts +++ b/src/tracks/brush-linear/brush-linear-plot.ts @@ -19,6 +19,7 @@ export class BrushLinearTrack extends BrushLinearTrackClass {}, pubSub: fakePubSub, isValueScaleLocked: () => false, - svgElement: colorbarDiv, + svgElement: svgElement, isShowGlobalMousePosition: () => false }; From 65f0a31cc5b87eccce27af9d7757ee7827208898 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Fri, 21 Jun 2024 17:20:51 -0400 Subject: [PATCH 060/139] fix: brushing --- demo/App.tsx | 1530 ++++++++++++++++++++++++++++++- demo/renderer/linkedEncoding.ts | 17 +- demo/renderer/main.ts | 2 +- 3 files changed, 1540 insertions(+), 9 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index 82c3e1392..20f162140 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -27,7 +27,7 @@ function App() { const plotElement = document.getElementById('plot') as HTMLDivElement; plotElement.innerHTML = ''; // Initialize the PixiManager. This will be used to get containers and overlay divs for the plots - const pixiManager = new PixiManager(1000, 600, plotElement, setFps); + const pixiManager = new PixiManager(1000, 1200, plotElement, setFps); // addTextTrack(pixiManager); // addDummyTrack(pixiManager); // addCircularBrush(pixiManager); @@ -57,7 +57,7 @@ function App() { }; // Compile the spec - compile(visualLinking, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); + compile(doubleBrush, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); }, []); return ( @@ -788,3 +788,1529 @@ const visualLinking = { } ] }; + +const test = { + static: true, + layout: 'linear', + centerRadius: 0.2, + arrangement: 'parallel', + views: [ + { + xDomain: { chromosome: 'chr1' }, + tracks: [ + { + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=cistrome-multivec', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1', 'sample 2', 'sample 3', 'sample 4'] + }, + mark: 'area', + x: { field: 'position', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative' }, + color: { field: 'sample', type: 'nominal' }, + width: 1000, + height: 30 + }, + { + alignment: 'overlay', + data: { + url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/cytogenetic_band.csv', + type: 'csv', + chromosomeField: 'Chr.', + genomicFields: ['ISCN_start', 'ISCN_stop', 'Basepair_start', 'Basepair_stop'] + }, + tracks: [ + { + mark: 'text', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen-1', 'acen-2'], + not: true + } + ], + text: { field: 'Band', type: 'nominal' }, + color: { value: 'black' }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ] + }, + { + mark: 'rect', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen-1', 'acen-2'], + not: true + } + ], + color: { + field: 'Density', + type: 'nominal', + domain: ['', '25', '50', '75', '100'], + range: ['white', '#D9D9D9', '#979797', '#636363', 'black'] + } + }, + { + mark: 'rect', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['gvar'] }], + color: { value: '#A0A0F2' } + }, + { + mark: 'triangleRight', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-1'] }], + color: { value: '#B40101' } + }, + { + mark: 'triangleLeft', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-2'] }], + color: { value: '#B40101' } + } + ], + x: { field: 'Basepair_start', type: 'genomic' }, + xe: { field: 'Basepair_stop', type: 'genomic' }, + stroke: { value: 'gray' }, + strokeWidth: { value: 0.5 }, + width: 1000, + height: 20 + } + ] + }, + { + xDomain: { chromosome: 'chr2' }, + tracks: [ + { + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=cistrome-multivec', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1', 'sample 2', 'sample 3', 'sample 4'] + }, + mark: 'area', + x: { field: 'position', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative' }, + color: { field: 'sample', type: 'nominal' }, + width: 970, + height: 30 + }, + { + alignment: 'overlay', + data: { + url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/cytogenetic_band.csv', + type: 'csv', + chromosomeField: 'Chr.', + genomicFields: ['ISCN_start', 'ISCN_stop', 'Basepair_start', 'Basepair_stop'] + }, + tracks: [ + { + mark: 'text', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen-1', 'acen-2'], + not: true + } + ], + text: { field: 'Band', type: 'nominal' }, + color: { value: 'black' }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ] + }, + { + mark: 'rect', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen-1', 'acen-2'], + not: true + } + ], + color: { + field: 'Density', + type: 'nominal', + domain: ['', '25', '50', '75', '100'], + range: ['white', '#D9D9D9', '#979797', '#636363', 'black'] + } + }, + { + mark: 'rect', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['gvar'] }], + color: { value: '#A0A0F2' } + }, + { + mark: 'triangleRight', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-1'] }], + color: { value: '#B40101' } + }, + { + mark: 'triangleLeft', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-2'] }], + color: { value: '#B40101' } + } + ], + x: { field: 'Basepair_start', type: 'genomic' }, + xe: { field: 'Basepair_stop', type: 'genomic' }, + stroke: { value: 'gray' }, + strokeWidth: { value: 0.5 }, + width: 970, + height: 20 + } + ] + }, + { + xDomain: { chromosome: 'chr3' }, + tracks: [ + { + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=cistrome-multivec', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1', 'sample 2', 'sample 3', 'sample 4'] + }, + mark: 'area', + x: { field: 'position', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative' }, + color: { field: 'sample', type: 'nominal' }, + width: 800, + height: 30 + }, + { + alignment: 'overlay', + data: { + url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/cytogenetic_band.csv', + type: 'csv', + chromosomeField: 'Chr.', + genomicFields: ['ISCN_start', 'ISCN_stop', 'Basepair_start', 'Basepair_stop'] + }, + tracks: [ + { + mark: 'text', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen-1', 'acen-2'], + not: true + } + ], + text: { field: 'Band', type: 'nominal' }, + color: { value: 'black' }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ] + }, + { + mark: 'rect', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen-1', 'acen-2'], + not: true + } + ], + color: { + field: 'Density', + type: 'nominal', + domain: ['', '25', '50', '75', '100'], + range: ['white', '#D9D9D9', '#979797', '#636363', 'black'] + } + }, + { + mark: 'rect', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['gvar'] }], + color: { value: '#A0A0F2' } + }, + { + mark: 'triangleRight', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-1'] }], + color: { value: '#B40101' } + }, + { + mark: 'triangleLeft', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-2'] }], + color: { value: '#B40101' } + } + ], + x: { field: 'Basepair_start', type: 'genomic' }, + xe: { field: 'Basepair_stop', type: 'genomic' }, + stroke: { value: 'gray' }, + strokeWidth: { value: 0.5 }, + width: 800, + height: 20 + } + ] + }, + { + xDomain: { chromosome: 'chr4' }, + tracks: [ + { + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=cistrome-multivec', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1', 'sample 2', 'sample 3', 'sample 4'] + }, + mark: 'area', + x: { field: 'position', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative' }, + color: { field: 'sample', type: 'nominal' }, + width: 770, + height: 30 + }, + { + alignment: 'overlay', + data: { + url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/cytogenetic_band.csv', + type: 'csv', + chromosomeField: 'Chr.', + genomicFields: ['ISCN_start', 'ISCN_stop', 'Basepair_start', 'Basepair_stop'] + }, + tracks: [ + { + mark: 'text', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen-1', 'acen-2'], + not: true + } + ], + text: { field: 'Band', type: 'nominal' }, + color: { value: 'black' }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ] + }, + { + mark: 'rect', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen-1', 'acen-2'], + not: true + } + ], + color: { + field: 'Density', + type: 'nominal', + domain: ['', '25', '50', '75', '100'], + range: ['white', '#D9D9D9', '#979797', '#636363', 'black'] + } + }, + { + mark: 'rect', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['gvar'] }], + color: { value: '#A0A0F2' } + }, + { + mark: 'triangleRight', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-1'] }], + color: { value: '#B40101' } + }, + { + mark: 'triangleLeft', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-2'] }], + color: { value: '#B40101' } + } + ], + x: { field: 'Basepair_start', type: 'genomic' }, + xe: { field: 'Basepair_stop', type: 'genomic' }, + stroke: { value: 'gray' }, + strokeWidth: { value: 0.5 }, + width: 770, + height: 20 + } + ] + }, + { + xDomain: { chromosome: 'chr5' }, + tracks: [ + { + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=cistrome-multivec', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1', 'sample 2', 'sample 3', 'sample 4'] + }, + mark: 'area', + x: { field: 'position', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative' }, + color: { field: 'sample', type: 'nominal' }, + width: 740, + height: 30 + }, + { + alignment: 'overlay', + data: { + url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/cytogenetic_band.csv', + type: 'csv', + chromosomeField: 'Chr.', + genomicFields: ['ISCN_start', 'ISCN_stop', 'Basepair_start', 'Basepair_stop'] + }, + tracks: [ + { + mark: 'text', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen-1', 'acen-2'], + not: true + } + ], + text: { field: 'Band', type: 'nominal' }, + color: { value: 'black' }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ] + }, + { + mark: 'rect', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen-1', 'acen-2'], + not: true + } + ], + color: { + field: 'Density', + type: 'nominal', + domain: ['', '25', '50', '75', '100'], + range: ['white', '#D9D9D9', '#979797', '#636363', 'black'] + } + }, + { + mark: 'rect', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['gvar'] }], + color: { value: '#A0A0F2' } + }, + { + mark: 'triangleRight', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-1'] }], + color: { value: '#B40101' } + }, + { + mark: 'triangleLeft', + dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen-2'] }], + color: { value: '#B40101' } + } + ], + x: { field: 'Basepair_start', type: 'genomic' }, + xe: { field: 'Basepair_stop', type: 'genomic' }, + stroke: { value: 'gray' }, + strokeWidth: { value: 0.5 }, + width: 740, + height: 20 + } + ] + } + ] +}; + +const MSA = { + description: 'reference: https://dash.plotly.com/dash-bio/alignmentchart', + zoomLimits: [1, 396], + xDomain: { interval: [350, 396] }, + assembly: 'unknown', + style: { outline: 'lightgray' }, + views: [ + { + linkingId: '-', + spacing: 30, + tracks: [ + { + title: 'Gap', + data: { + url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/alignment_viewer_p53.gap.csv', + type: 'csv', + genomicFields: ['pos'], + sampleLength: 99999 + }, + mark: 'bar', + x: { field: 'start', type: 'genomic', axis: 'none' }, + xe: { field: 'end', type: 'genomic', axis: 'none' }, + y: { field: 'gap', type: 'quantitative', axis: 'right' }, + color: { value: 'gray' }, + stroke: { value: 'white' }, + strokeWidth: { value: 0 }, + width: 800, + height: 100 + }, + { + title: 'Conservation', + data: { + url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/alignment_viewer_p53.conservation.csv', + type: 'csv', + genomicFields: ['pos'], + sampleLength: 99999 + }, + mark: 'bar', + x: { field: 'start', type: 'genomic', axis: 'none' }, + xe: { field: 'end', type: 'genomic', axis: 'none' }, + y: { + field: 'conservation', + type: 'quantitative', + axis: 'right' + }, + color: { field: 'conservation', type: 'quantitative' }, + stroke: { value: 'white' }, + strokeWidth: { value: 0 }, + width: 800, + height: 150 + }, + { + alignment: 'overlay', + data: { + url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/alignment_viewer_p53.fasta.csv', + type: 'csv', + genomicFields: ['pos'], + sampleLength: 99999 + }, + tracks: [ + { mark: 'rect' }, + { + mark: 'text', + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + color: { value: 'black' }, + size: { value: 12 }, + visibility: [ + { + measure: 'zoomLevel', + target: 'track', + threshold: 10, + operation: 'LT', + transitionPadding: 100 + } + ] + } + ], + x: { field: 'pos', type: 'genomic', axis: 'bottom' }, + row: { field: 'name', type: 'nominal', legend: true }, + color: { + field: 'base', + type: 'nominal', + range: [ + '#d60000', + '#018700', + '#b500ff', + '#05acc6', + '#97ff00', + '#ffa52f', + '#ff8ec8', + '#79525e', + '#00fdcf', + '#afa5ff', + '#93ac83', + '#9a6900', + '#366962', + '#d3008c', + '#fdf490', + '#c86e66', + '#9ee2ff', + '#00c846', + '#a877ac', + '#b8ba01' + ], + legend: true + }, + stroke: { value: 'white' }, + strokeWidth: { value: 0 }, + text: { field: 'base', type: 'nominal' }, + width: 800, + height: 500 + } + ] + }, + { + static: true, + xDomain: { interval: [0, 396] }, + alignment: 'overlay', + tracks: [ + { + data: { + url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/alignment_viewer_p53.fasta.csv', + type: 'csv', + genomicFields: ['pos'], + sampleLength: 99999 + }, + mark: 'rect', + x: { field: 'pos', type: 'genomic', axis: 'none' }, + row: { field: 'name', type: 'nominal', legend: false }, + color: { + field: 'base', + type: 'nominal', + range: [ + '#d60000', + '#018700', + '#b500ff', + '#05acc6', + '#97ff00', + '#ffa52f', + '#ff8ec8', + '#79525e', + '#00fdcf', + '#afa5ff', + '#93ac83', + '#9a6900', + '#366962', + '#d3008c', + '#fdf490', + '#c86e66', + '#9ee2ff', + '#00c846', + '#a877ac', + '#b8ba01' + ], + legend: false + }, + stroke: { value: 'white' }, + strokeWidth: { value: 0 }, + text: { field: 'base', type: 'nominal' }, + width: 800, + height: 150 + }, + { + data: { + url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/alignment_viewer_p53.conservation.csv', + type: 'csv', + genomicFields: ['pos'], + sampleLength: 99999 + }, + mark: 'bar', + x: { field: 'start', type: 'genomic', axis: 'none' }, + xe: { field: 'end', type: 'genomic', axis: 'none' }, + y: { + field: 'conservation', + type: 'quantitative', + axis: 'none' + }, + color: { field: 'conservation', type: 'quantitative' }, + stroke: { value: 'white' }, + strokeWidth: { value: 0 }, + width: 800, + height: 150 + }, + { + data: { + url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/alignment_viewer_p53.gap.csv', + type: 'csv', + genomicFields: ['pos'], + sampleLength: 99999 + }, + mark: 'bar', + x: { field: 'start', type: 'genomic', axis: 'none' }, + xe: { field: 'end', type: 'genomic', axis: 'none' }, + y: { field: 'gap', type: 'quantitative', axis: 'none' }, + color: { value: 'gray' }, + stroke: { value: 'white' }, + strokeWidth: { value: 0 }, + width: 800, + height: 150 + }, + { + mark: 'brush', + x: { linkingId: '-' }, + color: { value: 'black' }, + stroke: { value: 'black' }, + strokeWidth: { value: 1 }, + opacity: { value: 0.3 } + } + ], + width: 800, + height: 150 + } + ] +}; + +const cancer = { + title: 'Breast Cancer Variant (Staaf et al. 2019)', + subtitle: 'Genetic characteristics of RAD51C- and PALB2-altered TNBCs', + layout: 'linear', + arrangement: 'vertical', + centerRadius: 0.5, + assembly: 'hg19', + spacing: 40, + style: { + outlineWidth: 1, + outline: 'lightgray', + enableSmoothPath: false + }, + views: [ + { + arrangement: 'vertical', + views: [ + { + xOffset: 190, + layout: 'circular', + spacing: 1, + tracks: [ + { + title: 'Patient Overview (PD35930a)', + alignment: 'overlay', + data: { + url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/UCSC.HG38.Human.CytoBandIdeogram.csv', + type: 'csv', + chromosomeField: 'Chromosome', + genomicFields: ['chromStart', 'chromEnd'] + }, + tracks: [ + { mark: 'rect' }, + { + mark: 'brush', + x: { linkingId: 'mid-scale' }, + strokeWidth: { value: 1.5 }, + stroke: { value: '#0070DC' }, + color: { value: '#AFD8FF' }, + opacity: { value: 0.5 } + } + ], + color: { + field: 'Stain', + type: 'nominal', + domain: ['gneg', 'gpos25', 'gpos50', 'gpos75', 'gpos100', 'gvar', 'acen'], + range: ['white', 'lightgray', 'gray', 'gray', 'black', '#7B9CC8', '#DC4542'] + }, + size: { value: 18 }, + x: { field: 'chromStart', type: 'genomic' }, + xe: { field: 'chromEnd', type: 'genomic' }, + stroke: { value: 'gray' }, + strokeWidth: { value: 0.3 }, + width: 500, + height: 100 + }, + { + title: 'Putative Driver', + alignment: 'overlay', + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/SV/driver.df.scanb.complete.csv', + type: 'csv', + chromosomeField: 'Chr', + genomicFields: ['ChrStart', 'ChrEnd'] + }, + dataTransform: [{ type: 'filter', field: 'Sample', oneOf: ['PD35930a'] }], + tracks: [{ mark: 'text' }, { mark: 'triangleBottom', size: { value: 5 } }], + x: { field: 'ChrStart', type: 'genomic' }, + xe: { field: 'ChrEnd', type: 'genomic' }, + text: { field: 'Gene', type: 'nominal' }, + color: { value: 'black' }, + style: { + textFontWeight: 'normal', + dx: -10, + outlineWidth: 0 + }, + width: 500, + height: 40 + }, + { + title: 'LOH', + style: { background: 'lightgray', backgroundOpacity: 0.2 }, + alignment: 'overlay', + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/cancer/cnv.PD35930a.csv', + headerNames: [ + 'id', + 'chr', + 'start', + 'end', + 'total_cn_normal', + 'minor_cp_normal', + 'total_cn_tumor', + 'minor_cn_tumor' + ], + type: 'csv', + chromosomeField: 'chr', + genomicFields: ['start', 'end'] + }, + dataTransform: [{ type: 'filter', field: 'minor_cn_tumor', oneOf: ['0'] }], + tracks: [ + { mark: 'rect' }, + { + mark: 'brush', + x: { linkingId: 'mid-scale' }, + strokeWidth: { value: 1 }, + stroke: { value: '#94C2EF' }, + color: { value: '#AFD8FF' } + } + ], + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + color: { value: '#FB6A4B' }, + width: 620, + height: 40 + }, + { + title: 'Gain', + style: { background: 'lightgray', backgroundOpacity: 0.2 }, + alignment: 'overlay', + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/cancer/cnv.PD35930a.csv', + headerNames: [ + 'id', + 'chr', + 'start', + 'end', + 'total_cn_normal', + 'minor_cp_normal', + 'total_cn_tumor', + 'minor_cn_tumor' + ], + type: 'csv', + chromosomeField: 'chr', + genomicFields: ['start', 'end'] + }, + dataTransform: [ + { + type: 'filter', + field: 'total_cn_tumor', + inRange: [4.5, 900] + } + ], + tracks: [ + { mark: 'rect' }, + { + mark: 'brush', + x: { linkingId: 'mid-scale' }, + strokeWidth: { value: 0 } + } + ], + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + color: { value: '#73C475' }, + width: 500, + height: 40 + }, + { + title: 'Structural Variant', + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/cancer/rearrangement.PD35930a.csv', + type: 'csv', + genomicFieldsToConvert: [ + { + chromosomeField: 'chr1', + genomicFields: ['start1', 'end1'] + }, + { + chromosomeField: 'chr2', + genomicFields: ['start2', 'end2'] + } + ] + }, + mark: 'withinLink', + x: { field: 'start1', type: 'genomic' }, + xe: { field: 'end2', type: 'genomic' }, + color: { + field: 'svclass', + type: 'nominal', + legend: true, + domain: ['tandem-duplication', 'translocation', 'delection', 'inversion'], + range: ['#569C4D', '#4C75A2', '#DA5456', '#EA8A2A'] + }, + stroke: { + field: 'svclass', + type: 'nominal', + domain: ['tandem-duplication', 'translocation', 'delection', 'inversion'], + range: ['#569C4D', '#4C75A2', '#DA5456', '#EA8A2A'] + }, + strokeWidth: { value: 1 }, + opacity: { value: 0.6 }, + style: { legendTitle: 'SV Class' }, + width: 500, + height: 80 + } + ] + }, + { + linkingId: 'mid-scale', + xDomain: { chromosome: 'chr1' }, + layout: 'linear', + tracks: [ + { + style: { + background: '#D7EBFF', + outline: '#8DC1F2', + outlineWidth: 5 + }, + title: 'Ideogram', + alignment: 'overlay', + data: { + url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/UCSC.HG38.Human.CytoBandIdeogram.csv', + type: 'csv', + chromosomeField: 'Chromosome', + genomicFields: ['chromStart', 'chromEnd'] + }, + tracks: [ + { + mark: 'rect', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen'], + not: true + } + ] + }, + { + mark: 'triangleRight', + dataTransform: [ + { type: 'filter', field: 'Stain', oneOf: ['acen'] }, + { type: 'filter', field: 'Name', include: 'q' } + ] + }, + { + mark: 'triangleLeft', + dataTransform: [ + { type: 'filter', field: 'Stain', oneOf: ['acen'] }, + { type: 'filter', field: 'Name', include: 'p' } + ] + }, + { + mark: 'text', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen'], + not: true + } + ], + size: { value: 12 }, + color: { + field: 'Stain', + type: 'nominal', + domain: ['gneg', 'gpos25', 'gpos50', 'gpos75', 'gpos100', 'gvar'], + range: ['black', 'black', 'black', 'black', 'white', 'black'] + }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ] + } + ], + color: { + field: 'Stain', + type: 'nominal', + domain: ['gneg', 'gpos25', 'gpos50', 'gpos75', 'gpos100', 'gvar', 'acen'], + range: ['white', 'lightgray', 'gray', 'gray', 'black', '#7B9CC8', '#DC4542'] + }, + size: { value: 18 }, + x: { field: 'chromStart', type: 'genomic' }, + xe: { field: 'chromEnd', type: 'genomic' }, + text: { field: 'Name', type: 'nominal' }, + stroke: { value: 'gray' }, + strokeWidth: { value: 0.3 }, + width: 500, + height: 30 + }, + { + title: 'Putative Driver', + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/SV/driver.df.scanb.complete.csv', + type: 'csv', + chromosomeField: 'Chr', + genomicFields: ['ChrStart', 'ChrEnd'] + }, + dataTransform: [{ type: 'filter', field: 'Sample', oneOf: ['PD35930a'] }], + mark: 'text', + x: { field: 'ChrStart', type: 'genomic' }, + xe: { field: 'ChrEnd', type: 'genomic' }, + text: { field: 'Gene', type: 'nominal' }, + color: { value: 'black' }, + style: { textFontWeight: 'normal', dx: -10 }, + width: 500, + height: 20 + }, + { + alignment: 'overlay', + title: 'hg38 | Genes', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ], + exonIntervalFields: [ + { index: 12, name: 'start' }, + { index: 13, name: 'end' } + ] + }, + tracks: [ + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['+'] } + ], + mark: 'triangleRight', + x: { field: 'end', type: 'genomic' }, + size: { value: 15 } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'text', + text: { field: 'name', type: 'nominal' }, + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + style: { dy: -15, outline: 'black', outlineWidth: 0 } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['-'] } + ], + mark: 'triangleLeft', + x: { field: 'start', type: 'genomic' }, + size: { value: 15 }, + style: { + align: 'right', + outline: 'black', + outlineWidth: 0 + } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['exon'] }], + mark: 'rect', + x: { field: 'start', type: 'genomic' }, + size: { value: 15 }, + xe: { field: 'end', type: 'genomic' } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['+'] } + ], + mark: 'rule', + x: { field: 'start', type: 'genomic' }, + strokeWidth: { value: 2 }, + xe: { field: 'end', type: 'genomic' }, + style: { + linePattern: { type: 'triangleRight', size: 3.5 }, + outline: 'black', + outlineWidth: 0 + } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['-'] } + ], + mark: 'rule', + x: { field: 'start', type: 'genomic' }, + strokeWidth: { value: 2 }, + xe: { field: 'end', type: 'genomic' }, + style: { + linePattern: { type: 'triangleLeft', size: 3.5 }, + outline: 'black', + outlineWidth: 0 + } + }, + { + mark: 'brush', + x: { linkingId: 'detail-1' }, + strokeWidth: { value: 0 }, + color: { value: 'gray' }, + opacity: { value: 0.3 } + }, + { + mark: 'brush', + x: { linkingId: 'detail-2' }, + strokeWidth: { value: 0 }, + color: { value: 'gray' }, + opacity: { value: 0.3 } + } + ], + row: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'] + }, + color: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'], + range: ['#97A8B2', '#D4C6BA'] + }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ], + width: 400, + height: 100 + }, + { + title: 'LOH', + style: { background: 'lightgray', backgroundOpacity: 0.2 }, + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/cancer/cnv.PD35930a.csv', + headerNames: [ + 'id', + 'chr', + 'start', + 'end', + 'total_cn_normal', + 'minor_cp_normal', + 'total_cn_tumor', + 'minor_cn_tumor' + ], + type: 'csv', + chromosomeField: 'chr', + genomicFields: ['start', 'end'] + }, + dataTransform: [{ type: 'filter', field: 'minor_cn_tumor', oneOf: ['0'] }], + mark: 'rect', + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + color: { value: '#FB6A4B' }, + width: 620, + height: 20 + }, + { + title: 'Gain', + style: { background: 'lightgray', backgroundOpacity: 0.2 }, + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/cancer/cnv.PD35930a.csv', + headerNames: [ + 'id', + 'chr', + 'start', + 'end', + 'total_cn_normal', + 'minor_cp_normal', + 'total_cn_tumor', + 'minor_cn_tumor' + ], + type: 'csv', + chromosomeField: 'chr', + genomicFields: ['start', 'end'] + }, + dataTransform: [ + { + type: 'filter', + field: 'total_cn_tumor', + inRange: [4.5, 900] + } + ], + mark: 'rect', + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + color: { value: '#73C475' }, + width: 500, + height: 20 + }, + { + title: 'Structural Variant', + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/cancer/rearrangement.PD35930a.csv', + type: 'csv', + genomicFieldsToConvert: [ + { + chromosomeField: 'chr1', + genomicFields: ['start1', 'end1'] + }, + { + chromosomeField: 'chr2', + genomicFields: ['start2', 'end2'] + } + ] + }, + alignment: 'overlay', + tracks: [ + { + mark: 'withinLink', + x: { field: 'start1', type: 'genomic' }, + xe: { field: 'end2', type: 'genomic' } + }, + { + mark: 'point', + x: { field: 'start1', type: 'genomic' }, + y: { value: 400 } + }, + { + mark: 'point', + x: { field: 'end2', type: 'genomic' }, + y: { value: 400 } + } + ], + color: { + field: 'svclass', + type: 'nominal', + domain: ['tandem-duplication', 'translocation', 'delection', 'inversion'], + range: ['#569C4D', '#4C75A2', '#DA5456', '#EA8A2A'], + legend: true + }, + stroke: { + field: 'svclass', + type: 'nominal', + domain: ['tandem-duplication', 'translocation', 'delection', 'inversion'], + range: ['#569C4D', '#4C75A2', '#DA5456', '#EA8A2A'] + }, + strokeWidth: { value: 1 }, + opacity: { value: 0.6 }, + size: { value: 4 }, + tooltip: [ + { field: 'start1', type: 'genomic' }, + { field: 'end2', type: 'genomic' }, + { field: 'svclass', type: 'nominal' } + ], + style: { legendTitle: 'SV Class', linkStyle: 'elliptical' }, + width: 1000, + height: 200 + } + ] + } + ] + }, + { + arrangement: 'horizontal', + spacing: 100, + views: [ + { + static: false, + layout: 'linear', + centerRadius: 0.05, + xDomain: { chromosome: 'chr1', interval: [205000, 207000] }, + spacing: 0.01, + tracks: [ + { + alignment: 'overlay', + title: 'example_higlass.bam', + data: { + type: 'bam', + url: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam', + indexUrl: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai', + loadMates: true + }, + mark: 'bar', + tracks: [ + { + dataTransform: [ + { + type: 'coverage', + startField: 'start', + endField: 'end' + } + ], + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + y: { + field: 'coverage', + type: 'quantitative', + axis: 'right' + }, + color: { value: '#C6C6C6' } + } + ], + style: { outlineWidth: 0.5 }, + width: 450, + height: 80 + }, + { + alignment: 'overlay', + title: 'example_higlass.bam', + data: { + type: 'bam', + url: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam', + indexUrl: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai', + loadMates: true, + maxInsertSize: 300 + }, + mark: 'rect', + tracks: [ + { + dataTransform: [ + { + type: 'displace', + method: 'pile', + boundingBox: { + startField: 'start', + endField: 'end', + padding: 5, + isPaddingBP: true + }, + newField: 'pileup-row' + } + ], + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + color: { + field: 'svType', + type: 'nominal', + legend: true, + domain: [ + 'normal read', + 'deletion (+-)', + 'inversion (++)', + 'inversion (--)', + 'duplication (-+)', + 'more than two mates', + 'mates not found within chromosome', + 'clipping' + ], + range: [ + '#C8C8C8', + '#E79F00', + '#029F73', + '#0072B2', + '#CB7AA7', + '#57B4E9', + '#D61E2E', + '#414141' + ] + } + }, + { + dataTransform: [ + { + type: 'displace', + method: 'pile', + boundingBox: { + startField: 'start', + endField: 'end', + padding: 5, + isPaddingBP: true + }, + newField: 'pileup-row' + }, + { + type: 'subjson', + field: 'substitutions', + genomicField: 'pos', + baseGenomicField: 'start', + genomicLengthField: 'length' + }, + { type: 'filter', field: 'type', oneOf: ['S', 'H'] } + ], + x: { field: 'pos_start', type: 'genomic' }, + xe: { field: 'pos_end', type: 'genomic' }, + color: { value: '#414141' } + } + ], + tooltip: [ + { field: 'start', type: 'genomic' }, + { field: 'end', type: 'genomic' }, + { field: 'insertSize', type: 'quantitative' }, + { field: 'svType', type: 'nominal' }, + { field: 'strand', type: 'nominal' }, + { field: 'numMates', type: 'quantitative' }, + { field: 'mateIds', type: 'nominal' } + ], + row: { field: 'pileup-row', type: 'nominal', padding: 0.2 }, + style: { + outlineWidth: 0.5, + legendTitle: 'Insert Size = 300bp' + }, + width: 450, + height: 310 + } + ] + }, + { + static: false, + layout: 'linear', + centerRadius: 0.05, + xDomain: { chromosome: 'chr1', interval: [490000, 496000] }, + spacing: 0.01, + tracks: [ + { + alignment: 'overlay', + title: 'example_higlass.bam', + data: { + type: 'bam', + url: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam', + indexUrl: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai', + loadMates: true + }, + mark: 'bar', + tracks: [ + { + dataTransform: [ + { + type: 'coverage', + startField: 'start', + endField: 'end' + } + ], + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + y: { + field: 'coverage', + type: 'quantitative', + axis: 'right' + }, + color: { value: '#C6C6C6' } + } + ], + style: { outlineWidth: 0.5 }, + width: 450, + height: 80 + }, + { + alignment: 'overlay', + title: 'example_higlass.bam', + data: { + type: 'bam', + url: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam', + indexUrl: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai', + loadMates: true, + maxInsertSize: 300 + }, + mark: 'rect', + tracks: [ + { + dataTransform: [ + { + type: 'displace', + method: 'pile', + boundingBox: { + startField: 'start', + endField: 'end', + padding: 5, + isPaddingBP: true + }, + newField: 'pileup-row' + } + ], + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + color: { + field: 'svType', + type: 'nominal', + legend: true, + domain: [ + 'normal read', + 'deletion (+-)', + 'inversion (++)', + 'inversion (--)', + 'duplication (-+)', + 'more than two mates', + 'mates not found within chromosome', + 'clipping' + ], + range: [ + '#C8C8C8', + '#E79F00', + '#029F73', + '#0072B2', + '#CB7AA7', + '#57B4E9', + '#D61E2E', + '#414141' + ] + } + }, + { + dataTransform: [ + { + type: 'displace', + method: 'pile', + boundingBox: { + startField: 'start', + endField: 'end', + padding: 5, + isPaddingBP: true + }, + newField: 'pileup-row' + }, + { + type: 'subjson', + field: 'substitutions', + genomicField: 'pos', + baseGenomicField: 'start', + genomicLengthField: 'length' + }, + { type: 'filter', field: 'type', oneOf: ['S', 'H'] } + ], + x: { field: 'pos_start', type: 'genomic' }, + xe: { field: 'pos_end', type: 'genomic' }, + color: { value: '#414141' } + } + ], + tooltip: [ + { field: 'start', type: 'genomic' }, + { field: 'end', type: 'genomic' }, + { field: 'insertSize', type: 'quantitative' }, + { field: 'svType', type: 'nominal' }, + { field: 'strand', type: 'nominal' }, + { field: 'numMates', type: 'quantitative' }, + { field: 'mateIds', type: 'nominal' } + ], + row: { field: 'pileup-row', type: 'nominal', padding: 0.2 }, + style: { + outlineWidth: 0.5, + legendTitle: 'Insert Size = 300bp' + }, + width: 450, + height: 310 + } + ] + } + ] + } + ] +}; diff --git a/demo/renderer/linkedEncoding.ts b/demo/renderer/linkedEncoding.ts index 4086eae6e..f12219138 100644 --- a/demo/renderer/linkedEncoding.ts +++ b/demo/renderer/linkedEncoding.ts @@ -3,7 +3,6 @@ import { GenomicPositionHelper, computeChromSizes } from '../../src/core/utils/a import { signal, type Signal } from '@preact/signals-core'; import type { GoslingSpec } from 'gosling.js'; import { TrackType } from './main'; -import type { Link } from 'react-router-dom'; /** * This is the information needed to link tracks together @@ -53,7 +52,7 @@ interface LinkInfo { */ export function getLinkedEncodings(gs: GoslingSpec) { // First, we traverse the gosling spec to find all the linked tracks and brushes - const { trackLinks, viewLinks } = getLinedFeaturesRecursive(gs); + const { trackLinks, viewLinks } = getLinkedFeaturesRecursive(gs); console.warn('trackLinks', trackLinks); // We associate tracks the other tracks they are linked with const linkedEncodings = viewLinks.map(viewLink => { @@ -138,7 +137,7 @@ function filterLinkedTracksByType(trackType: TrackType[], linkingId: string | un /** * Traverses the gosling spec to find all the linked tracks and brushes */ -function getLinedFeaturesRecursive(gs: GoslingSpec): LinkInfo { +function getLinkedFeaturesRecursive(gs: GoslingSpec): LinkInfo { // Base case: single view if (IsSingleView(gs)) { const viewLinks = getSingleViewLinks(gs); @@ -149,7 +148,7 @@ function getLinedFeaturesRecursive(gs: GoslingSpec): LinkInfo { // Recursive case: multiple views if (IsMultipleViews(gs)) { gs.views.forEach(view => { - const newLinks = getLinedFeaturesRecursive(view); + const newLinks = getLinkedFeaturesRecursive(view); linked.viewLinks.push(...newLinks.viewLinks); linked.trackLinks.push(...newLinks.trackLinks); }); @@ -246,11 +245,17 @@ function createDomainString(xDomain: GoslingSpec['xDomain']) { return xDomain; } let position = ''; + const hasOnlyInterval = 'interval' in xDomain && !('chromosome' in xDomain); + const hasOnlyChromosome = 'chromosome' in xDomain && !('interval' in xDomain); + const hasBoth = 'chromosome' in xDomain && 'interval' in xDomain; + if (typeof xDomain === 'string') { position = xDomain; - } else if (typeof xDomain === 'object' && 'chromosome' in xDomain && !('interval' in xDomain)) { + } else if (hasOnlyInterval) { + position = `chr:${xDomain.interval[0]}-${xDomain.interval[1]}`; + } else if (hasOnlyChromosome) { position = xDomain.chromosome; - } else if (typeof xDomain === 'object' && 'chromosome' in xDomain && 'interval' in xDomain) { + } else if (hasBoth) { position = `${xDomain.chromosome}:${xDomain.interval[0]}-${xDomain.interval[1]}`; } return position; diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index 67327c5e2..8d00f2fd1 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -109,7 +109,7 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE if (type === TrackType.Gosling) { const domain = getXDomainSignal(trackDef.trackId, linkedEncodings); const datafetcher = getDataFetcher(options.spec); - const gosPlot = new GoslingTrack(options, datafetcher, pixiManager.makeContainer(boundingBox)) + const gosPlot = new GoslingTrack(options, datafetcher, pixiManager.makeContainer(boundingBox), domain); if (!options.spec.static) { gosPlot.addInteractor(plot => panZoom(plot, domain)); } From 8d66d037e9a2aa1002a8865453b4f898caf90238 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Fri, 21 Jun 2024 17:50:54 -0400 Subject: [PATCH 061/139] feat: identify broken track --- demo/App.tsx | 576 ++++++++++++++++---------------- demo/renderer/linkedEncoding.ts | 8 +- 2 files changed, 295 insertions(+), 289 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index 20f162140..28ceadda0 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -57,7 +57,7 @@ function App() { }; // Compile the spec - compile(doubleBrush, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); + compile(cancer, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); }, []); return ( @@ -1509,7 +1509,7 @@ const cancer = { { mark: 'rect' }, { mark: 'brush', - x: { linkingId: 'mid-scale' }, + x: { linkingId: 'mid-scale'}, strokeWidth: { value: 1.5 }, stroke: { value: '#0070DC' }, color: { value: '#AFD8FF' }, @@ -2025,292 +2025,292 @@ const cancer = { ] } ] - }, - { - arrangement: 'horizontal', - spacing: 100, - views: [ - { - static: false, - layout: 'linear', - centerRadius: 0.05, - xDomain: { chromosome: 'chr1', interval: [205000, 207000] }, - spacing: 0.01, - tracks: [ - { - alignment: 'overlay', - title: 'example_higlass.bam', - data: { - type: 'bam', - url: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam', - indexUrl: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai', - loadMates: true - }, - mark: 'bar', - tracks: [ - { - dataTransform: [ - { - type: 'coverage', - startField: 'start', - endField: 'end' - } - ], - x: { field: 'start', type: 'genomic' }, - xe: { field: 'end', type: 'genomic' }, - y: { - field: 'coverage', - type: 'quantitative', - axis: 'right' - }, - color: { value: '#C6C6C6' } - } - ], - style: { outlineWidth: 0.5 }, - width: 450, - height: 80 - }, - { - alignment: 'overlay', - title: 'example_higlass.bam', - data: { - type: 'bam', - url: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam', - indexUrl: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai', - loadMates: true, - maxInsertSize: 300 - }, - mark: 'rect', - tracks: [ - { - dataTransform: [ - { - type: 'displace', - method: 'pile', - boundingBox: { - startField: 'start', - endField: 'end', - padding: 5, - isPaddingBP: true - }, - newField: 'pileup-row' - } - ], - x: { field: 'start', type: 'genomic' }, - xe: { field: 'end', type: 'genomic' }, - color: { - field: 'svType', - type: 'nominal', - legend: true, - domain: [ - 'normal read', - 'deletion (+-)', - 'inversion (++)', - 'inversion (--)', - 'duplication (-+)', - 'more than two mates', - 'mates not found within chromosome', - 'clipping' - ], - range: [ - '#C8C8C8', - '#E79F00', - '#029F73', - '#0072B2', - '#CB7AA7', - '#57B4E9', - '#D61E2E', - '#414141' - ] - } - }, - { - dataTransform: [ - { - type: 'displace', - method: 'pile', - boundingBox: { - startField: 'start', - endField: 'end', - padding: 5, - isPaddingBP: true - }, - newField: 'pileup-row' - }, - { - type: 'subjson', - field: 'substitutions', - genomicField: 'pos', - baseGenomicField: 'start', - genomicLengthField: 'length' - }, - { type: 'filter', field: 'type', oneOf: ['S', 'H'] } - ], - x: { field: 'pos_start', type: 'genomic' }, - xe: { field: 'pos_end', type: 'genomic' }, - color: { value: '#414141' } - } - ], - tooltip: [ - { field: 'start', type: 'genomic' }, - { field: 'end', type: 'genomic' }, - { field: 'insertSize', type: 'quantitative' }, - { field: 'svType', type: 'nominal' }, - { field: 'strand', type: 'nominal' }, - { field: 'numMates', type: 'quantitative' }, - { field: 'mateIds', type: 'nominal' } - ], - row: { field: 'pileup-row', type: 'nominal', padding: 0.2 }, - style: { - outlineWidth: 0.5, - legendTitle: 'Insert Size = 300bp' - }, - width: 450, - height: 310 - } - ] - }, - { - static: false, - layout: 'linear', - centerRadius: 0.05, - xDomain: { chromosome: 'chr1', interval: [490000, 496000] }, - spacing: 0.01, - tracks: [ - { - alignment: 'overlay', - title: 'example_higlass.bam', - data: { - type: 'bam', - url: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam', - indexUrl: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai', - loadMates: true - }, - mark: 'bar', - tracks: [ - { - dataTransform: [ - { - type: 'coverage', - startField: 'start', - endField: 'end' - } - ], - x: { field: 'start', type: 'genomic' }, - xe: { field: 'end', type: 'genomic' }, - y: { - field: 'coverage', - type: 'quantitative', - axis: 'right' - }, - color: { value: '#C6C6C6' } - } - ], - style: { outlineWidth: 0.5 }, - width: 450, - height: 80 - }, - { - alignment: 'overlay', - title: 'example_higlass.bam', - data: { - type: 'bam', - url: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam', - indexUrl: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai', - loadMates: true, - maxInsertSize: 300 - }, - mark: 'rect', - tracks: [ - { - dataTransform: [ - { - type: 'displace', - method: 'pile', - boundingBox: { - startField: 'start', - endField: 'end', - padding: 5, - isPaddingBP: true - }, - newField: 'pileup-row' - } - ], - x: { field: 'start', type: 'genomic' }, - xe: { field: 'end', type: 'genomic' }, - color: { - field: 'svType', - type: 'nominal', - legend: true, - domain: [ - 'normal read', - 'deletion (+-)', - 'inversion (++)', - 'inversion (--)', - 'duplication (-+)', - 'more than two mates', - 'mates not found within chromosome', - 'clipping' - ], - range: [ - '#C8C8C8', - '#E79F00', - '#029F73', - '#0072B2', - '#CB7AA7', - '#57B4E9', - '#D61E2E', - '#414141' - ] - } - }, - { - dataTransform: [ - { - type: 'displace', - method: 'pile', - boundingBox: { - startField: 'start', - endField: 'end', - padding: 5, - isPaddingBP: true - }, - newField: 'pileup-row' - }, - { - type: 'subjson', - field: 'substitutions', - genomicField: 'pos', - baseGenomicField: 'start', - genomicLengthField: 'length' - }, - { type: 'filter', field: 'type', oneOf: ['S', 'H'] } - ], - x: { field: 'pos_start', type: 'genomic' }, - xe: { field: 'pos_end', type: 'genomic' }, - color: { value: '#414141' } - } - ], - tooltip: [ - { field: 'start', type: 'genomic' }, - { field: 'end', type: 'genomic' }, - { field: 'insertSize', type: 'quantitative' }, - { field: 'svType', type: 'nominal' }, - { field: 'strand', type: 'nominal' }, - { field: 'numMates', type: 'quantitative' }, - { field: 'mateIds', type: 'nominal' } - ], - row: { field: 'pileup-row', type: 'nominal', padding: 0.2 }, - style: { - outlineWidth: 0.5, - legendTitle: 'Insert Size = 300bp' - }, - width: 450, - height: 310 - } - ] - } - ] } + // { + // arrangement: 'horizontal', + // spacing: 100, + // views: [ + // { + // static: false, + // layout: 'linear', + // centerRadius: 0.05, + // xDomain: { chromosome: 'chr1', interval: [205000, 207000] }, + // spacing: 0.01, + // tracks: [ + // { + // alignment: 'overlay', + // title: 'example_higlass.bam', + // data: { + // type: 'bam', + // url: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam', + // indexUrl: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai', + // loadMates: true + // }, + // mark: 'bar', + // tracks: [ + // { + // dataTransform: [ + // { + // type: 'coverage', + // startField: 'start', + // endField: 'end' + // } + // ], + // x: { field: 'start', type: 'genomic' }, + // xe: { field: 'end', type: 'genomic' }, + // y: { + // field: 'coverage', + // type: 'quantitative', + // axis: 'right' + // }, + // color: { value: '#C6C6C6' } + // } + // ], + // style: { outlineWidth: 0.5 }, + // width: 450, + // height: 80 + // }, + // { + // alignment: 'overlay', + // title: 'example_higlass.bam', + // data: { + // type: 'bam', + // url: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam', + // indexUrl: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai', + // loadMates: true, + // maxInsertSize: 300 + // }, + // mark: 'rect', + // tracks: [ + // { + // dataTransform: [ + // { + // type: 'displace', + // method: 'pile', + // boundingBox: { + // startField: 'start', + // endField: 'end', + // padding: 5, + // isPaddingBP: true + // }, + // newField: 'pileup-row' + // } + // ], + // x: { field: 'start', type: 'genomic' }, + // xe: { field: 'end', type: 'genomic' }, + // color: { + // field: 'svType', + // type: 'nominal', + // legend: true, + // domain: [ + // 'normal read', + // 'deletion (+-)', + // 'inversion (++)', + // 'inversion (--)', + // 'duplication (-+)', + // 'more than two mates', + // 'mates not found within chromosome', + // 'clipping' + // ], + // range: [ + // '#C8C8C8', + // '#E79F00', + // '#029F73', + // '#0072B2', + // '#CB7AA7', + // '#57B4E9', + // '#D61E2E', + // '#414141' + // ] + // } + // }, + // { + // dataTransform: [ + // { + // type: 'displace', + // method: 'pile', + // boundingBox: { + // startField: 'start', + // endField: 'end', + // padding: 5, + // isPaddingBP: true + // }, + // newField: 'pileup-row' + // }, + // { + // type: 'subjson', + // field: 'substitutions', + // genomicField: 'pos', + // baseGenomicField: 'start', + // genomicLengthField: 'length' + // }, + // { type: 'filter', field: 'type', oneOf: ['S', 'H'] } + // ], + // x: { field: 'pos_start', type: 'genomic' }, + // xe: { field: 'pos_end', type: 'genomic' }, + // color: { value: '#414141' } + // } + // ], + // tooltip: [ + // { field: 'start', type: 'genomic' }, + // { field: 'end', type: 'genomic' }, + // { field: 'insertSize', type: 'quantitative' }, + // { field: 'svType', type: 'nominal' }, + // { field: 'strand', type: 'nominal' }, + // { field: 'numMates', type: 'quantitative' }, + // { field: 'mateIds', type: 'nominal' } + // ], + // row: { field: 'pileup-row', type: 'nominal', padding: 0.2 }, + // style: { + // outlineWidth: 0.5, + // legendTitle: 'Insert Size = 300bp' + // }, + // width: 450, + // height: 310 + // } + // ] + // }, + // { + // static: false, + // layout: 'linear', + // centerRadius: 0.05, + // xDomain: { chromosome: 'chr1', interval: [490000, 496000] }, + // spacing: 0.01, + // tracks: [ + // { + // alignment: 'overlay', + // title: 'example_higlass.bam', + // data: { + // type: 'bam', + // url: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam', + // indexUrl: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai', + // loadMates: true + // }, + // mark: 'bar', + // tracks: [ + // { + // dataTransform: [ + // { + // type: 'coverage', + // startField: 'start', + // endField: 'end' + // } + // ], + // x: { field: 'start', type: 'genomic' }, + // xe: { field: 'end', type: 'genomic' }, + // y: { + // field: 'coverage', + // type: 'quantitative', + // axis: 'right' + // }, + // color: { value: '#C6C6C6' } + // } + // ], + // style: { outlineWidth: 0.5 }, + // width: 450, + // height: 80 + // }, + // { + // alignment: 'overlay', + // title: 'example_higlass.bam', + // data: { + // type: 'bam', + // url: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam', + // indexUrl: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai', + // loadMates: true, + // maxInsertSize: 300 + // }, + // mark: 'rect', + // tracks: [ + // { + // dataTransform: [ + // { + // type: 'displace', + // method: 'pile', + // boundingBox: { + // startField: 'start', + // endField: 'end', + // padding: 5, + // isPaddingBP: true + // }, + // newField: 'pileup-row' + // } + // ], + // x: { field: 'start', type: 'genomic' }, + // xe: { field: 'end', type: 'genomic' }, + // color: { + // field: 'svType', + // type: 'nominal', + // legend: true, + // domain: [ + // 'normal read', + // 'deletion (+-)', + // 'inversion (++)', + // 'inversion (--)', + // 'duplication (-+)', + // 'more than two mates', + // 'mates not found within chromosome', + // 'clipping' + // ], + // range: [ + // '#C8C8C8', + // '#E79F00', + // '#029F73', + // '#0072B2', + // '#CB7AA7', + // '#57B4E9', + // '#D61E2E', + // '#414141' + // ] + // } + // }, + // { + // dataTransform: [ + // { + // type: 'displace', + // method: 'pile', + // boundingBox: { + // startField: 'start', + // endField: 'end', + // padding: 5, + // isPaddingBP: true + // }, + // newField: 'pileup-row' + // }, + // { + // type: 'subjson', + // field: 'substitutions', + // genomicField: 'pos', + // baseGenomicField: 'start', + // genomicLengthField: 'length' + // }, + // { type: 'filter', field: 'type', oneOf: ['S', 'H'] } + // ], + // x: { field: 'pos_start', type: 'genomic' }, + // xe: { field: 'pos_end', type: 'genomic' }, + // color: { value: '#414141' } + // } + // ], + // tooltip: [ + // { field: 'start', type: 'genomic' }, + // { field: 'end', type: 'genomic' }, + // { field: 'insertSize', type: 'quantitative' }, + // { field: 'svType', type: 'nominal' }, + // { field: 'strand', type: 'nominal' }, + // { field: 'numMates', type: 'quantitative' }, + // { field: 'mateIds', type: 'nominal' } + // ], + // row: { field: 'pileup-row', type: 'nominal', padding: 0.2 }, + // style: { + // outlineWidth: 0.5, + // legendTitle: 'Insert Size = 300bp' + // }, + // width: 450, + // height: 310 + // } + // ] + // } + // ] + // } ] }; diff --git a/demo/renderer/linkedEncoding.ts b/demo/renderer/linkedEncoding.ts index f12219138..ba1994b99 100644 --- a/demo/renderer/linkedEncoding.ts +++ b/demo/renderer/linkedEncoding.ts @@ -182,7 +182,13 @@ function getSingleViewTrackLinks(gs: SingleView): TrackLink[] { track._overlay!.forEach(overlay => { if (overlay.mark === 'brush') { const trackType = gs.layout === 'linear' ? TrackType.BrushLinear : TrackType.BrushCircular; - trackLinks.push({ trackId: overlay.id, linkingId: overlay.x.linkingId, trackType, encoding: 'x' }); + const trackLink = { trackId: overlay.id, linkingId: overlay.x.linkingId, trackType, encoding: 'x' }; + if (overlay.x.domain !== undefined) { + const { assembly } = gs; + const domain = getDomain(overlay.x.domain, assembly); + trackLink.signal = signal(domain); + } + trackLinks.push(trackLink); } }); }); From 01ed2f63f24566095ac58fecff41ac9cf681c019 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Mon, 24 Jun 2024 13:30:35 -0400 Subject: [PATCH 062/139] fix: give correct bounding box for circular --- src/compiler/bounding-box.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/compiler/bounding-box.ts b/src/compiler/bounding-box.ts index 2974af36c..604161e7f 100644 --- a/src/compiler/bounding-box.ts +++ b/src/compiler/bounding-box.ts @@ -337,7 +337,12 @@ function traverseAndCollectTrackInfo( // t.track.startAngle = ((t.boundingBox.x - dx) / cumWidth) * 360; // t.track.endAngle = ((t.boundingBox.x + t.boundingBox.width - dx) / cumWidth) * 360; - t.boundingBox.x = dx + (t.track.xOffset ?? 0); + // If this is the first track, we add the offset of the x position + if (i == 0) { + t.boundingBox.x = dx + (t.track.xOffset ?? 0); + } else { + t.boundingBox.x = dx; + } t.boundingBox.y = dy + (t.track.yOffset ?? 0); // Circular tracks share the same size and position since technically these tracks are being overlaid on top of the others From 7e7bb52b13bb5f4ae51f0fc4a834eb4d4d388096 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Mon, 24 Jun 2024 13:30:42 -0400 Subject: [PATCH 063/139] fix: cancer spec --- demo/App.tsx | 1668 +++++++++++++++++++++++------------------ demo/renderer/main.ts | 40 +- 2 files changed, 956 insertions(+), 752 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index 28ceadda0..cae34b534 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -27,7 +27,7 @@ function App() { const plotElement = document.getElementById('plot') as HTMLDivElement; plotElement.innerHTML = ''; // Initialize the PixiManager. This will be used to get containers and overlay divs for the plots - const pixiManager = new PixiManager(1000, 1200, plotElement, setFps); + const pixiManager = new PixiManager(1000, 1500, plotElement, setFps); // addTextTrack(pixiManager); // addDummyTrack(pixiManager); // addCircularBrush(pixiManager); @@ -57,7 +57,7 @@ function App() { }; // Compile the spec - compile(cancer, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); + compile(linkingTest, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); }, []); return ( @@ -1474,9 +1474,7 @@ const MSA = { ] }; -const cancer = { - title: 'Breast Cancer Variant (Staaf et al. 2019)', - subtitle: 'Genetic characteristics of RAD51C- and PALB2-altered TNBCs', +const cancer_simplify = { layout: 'linear', arrangement: 'vertical', centerRadius: 0.5, @@ -1496,100 +1494,6 @@ const cancer = { layout: 'circular', spacing: 1, tracks: [ - { - title: 'Patient Overview (PD35930a)', - alignment: 'overlay', - data: { - url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/UCSC.HG38.Human.CytoBandIdeogram.csv', - type: 'csv', - chromosomeField: 'Chromosome', - genomicFields: ['chromStart', 'chromEnd'] - }, - tracks: [ - { mark: 'rect' }, - { - mark: 'brush', - x: { linkingId: 'mid-scale'}, - strokeWidth: { value: 1.5 }, - stroke: { value: '#0070DC' }, - color: { value: '#AFD8FF' }, - opacity: { value: 0.5 } - } - ], - color: { - field: 'Stain', - type: 'nominal', - domain: ['gneg', 'gpos25', 'gpos50', 'gpos75', 'gpos100', 'gvar', 'acen'], - range: ['white', 'lightgray', 'gray', 'gray', 'black', '#7B9CC8', '#DC4542'] - }, - size: { value: 18 }, - x: { field: 'chromStart', type: 'genomic' }, - xe: { field: 'chromEnd', type: 'genomic' }, - stroke: { value: 'gray' }, - strokeWidth: { value: 0.3 }, - width: 500, - height: 100 - }, - { - title: 'Putative Driver', - alignment: 'overlay', - data: { - url: 'https://s3.amazonaws.com/gosling-lang.org/data/SV/driver.df.scanb.complete.csv', - type: 'csv', - chromosomeField: 'Chr', - genomicFields: ['ChrStart', 'ChrEnd'] - }, - dataTransform: [{ type: 'filter', field: 'Sample', oneOf: ['PD35930a'] }], - tracks: [{ mark: 'text' }, { mark: 'triangleBottom', size: { value: 5 } }], - x: { field: 'ChrStart', type: 'genomic' }, - xe: { field: 'ChrEnd', type: 'genomic' }, - text: { field: 'Gene', type: 'nominal' }, - color: { value: 'black' }, - style: { - textFontWeight: 'normal', - dx: -10, - outlineWidth: 0 - }, - width: 500, - height: 40 - }, - { - title: 'LOH', - style: { background: 'lightgray', backgroundOpacity: 0.2 }, - alignment: 'overlay', - data: { - url: 'https://s3.amazonaws.com/gosling-lang.org/data/cancer/cnv.PD35930a.csv', - headerNames: [ - 'id', - 'chr', - 'start', - 'end', - 'total_cn_normal', - 'minor_cp_normal', - 'total_cn_tumor', - 'minor_cn_tumor' - ], - type: 'csv', - chromosomeField: 'chr', - genomicFields: ['start', 'end'] - }, - dataTransform: [{ type: 'filter', field: 'minor_cn_tumor', oneOf: ['0'] }], - tracks: [ - { mark: 'rect' }, - { - mark: 'brush', - x: { linkingId: 'mid-scale' }, - strokeWidth: { value: 1 }, - stroke: { value: '#94C2EF' }, - color: { value: '#AFD8FF' } - } - ], - x: { field: 'start', type: 'genomic' }, - xe: { field: 'end', type: 'genomic' }, - color: { value: '#FB6A4B' }, - width: 620, - height: 40 - }, { title: 'Gain', style: { background: 'lightgray', backgroundOpacity: 0.2 }, @@ -1617,14 +1521,7 @@ const cancer = { inRange: [4.5, 900] } ], - tracks: [ - { mark: 'rect' }, - { - mark: 'brush', - x: { linkingId: 'mid-scale' }, - strokeWidth: { value: 0 } - } - ], + tracks: [{ mark: 'rect' }], x: { field: 'start', type: 'genomic' }, xe: { field: 'end', type: 'genomic' }, color: { value: '#73C475' }, @@ -1670,647 +1567,930 @@ const cancer = { height: 80 } ] + } + ] + } + ] +}; + +const cancer = { + "title": "Breast Cancer Variant (Staaf et al. 2019)", + "subtitle": "Genetic characteristics of RAD51C- and PALB2-altered TNBCs", + "layout": "linear", + "arrangement": "vertical", + "centerRadius": 0.5, + "assembly": "hg19", + "spacing": 40, + "style": { + "outlineWidth": 1, + "outline": "lightgray", + "enableSmoothPath": false + }, + "views": [ + { + "arrangement": "vertical", + "views": [ + { + "xOffset": 190, + "layout": "circular", + "spacing": 1, + "tracks": [ + { + "title": "Patient Overview (PD35930a)", + "alignment": "overlay", + "data": { + "url": "https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/UCSC.HG38.Human.CytoBandIdeogram.csv", + "type": "csv", + "chromosomeField": "Chromosome", + "genomicFields": ["chromStart", "chromEnd"] }, - { - linkingId: 'mid-scale', - xDomain: { chromosome: 'chr1' }, - layout: 'linear', - tracks: [ - { - style: { - background: '#D7EBFF', - outline: '#8DC1F2', - outlineWidth: 5 - }, - title: 'Ideogram', - alignment: 'overlay', - data: { - url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/UCSC.HG38.Human.CytoBandIdeogram.csv', - type: 'csv', - chromosomeField: 'Chromosome', - genomicFields: ['chromStart', 'chromEnd'] - }, - tracks: [ - { - mark: 'rect', - dataTransform: [ - { - type: 'filter', - field: 'Stain', - oneOf: ['acen'], - not: true - } - ] - }, - { - mark: 'triangleRight', - dataTransform: [ - { type: 'filter', field: 'Stain', oneOf: ['acen'] }, - { type: 'filter', field: 'Name', include: 'q' } - ] - }, - { - mark: 'triangleLeft', - dataTransform: [ - { type: 'filter', field: 'Stain', oneOf: ['acen'] }, - { type: 'filter', field: 'Name', include: 'p' } - ] - }, - { - mark: 'text', - dataTransform: [ - { - type: 'filter', - field: 'Stain', - oneOf: ['acen'], - not: true - } - ], - size: { value: 12 }, - color: { - field: 'Stain', - type: 'nominal', - domain: ['gneg', 'gpos25', 'gpos50', 'gpos75', 'gpos100', 'gvar'], - range: ['black', 'black', 'black', 'black', 'white', 'black'] - }, - visibility: [ - { - operation: 'less-than', - measure: 'width', - threshold: '|xe-x|', - transitionPadding: 10, - target: 'mark' - } - ] - } - ], - color: { - field: 'Stain', - type: 'nominal', - domain: ['gneg', 'gpos25', 'gpos50', 'gpos75', 'gpos100', 'gvar', 'acen'], - range: ['white', 'lightgray', 'gray', 'gray', 'black', '#7B9CC8', '#DC4542'] - }, - size: { value: 18 }, - x: { field: 'chromStart', type: 'genomic' }, - xe: { field: 'chromEnd', type: 'genomic' }, - text: { field: 'Name', type: 'nominal' }, - stroke: { value: 'gray' }, - strokeWidth: { value: 0.3 }, - width: 500, - height: 30 - }, - { - title: 'Putative Driver', - data: { - url: 'https://s3.amazonaws.com/gosling-lang.org/data/SV/driver.df.scanb.complete.csv', - type: 'csv', - chromosomeField: 'Chr', - genomicFields: ['ChrStart', 'ChrEnd'] - }, - dataTransform: [{ type: 'filter', field: 'Sample', oneOf: ['PD35930a'] }], - mark: 'text', - x: { field: 'ChrStart', type: 'genomic' }, - xe: { field: 'ChrEnd', type: 'genomic' }, - text: { field: 'Gene', type: 'nominal' }, - color: { value: 'black' }, - style: { textFontWeight: 'normal', dx: -10 }, - width: 500, - height: 20 + "tracks": [ + {"mark": "rect"}, + { + "mark": "brush", + "x": {"linkingId": "mid-scale"}, + "strokeWidth": {"value": 1.5}, + "stroke": {"value": "#0070DC"}, + "color": {"value": "#AFD8FF"}, + "opacity": {"value": 0.5} + } + ], + "color": { + "field": "Stain", + "type": "nominal", + "domain": [ + "gneg", + "gpos25", + "gpos50", + "gpos75", + "gpos100", + "gvar", + "acen" + ], + "range": [ + "white", + "lightgray", + "gray", + "gray", + "black", + "#7B9CC8", + "#DC4542" + ] + }, + "size": {"value": 18}, + "x": {"field": "chromStart", "type": "genomic"}, + "xe": {"field": "chromEnd", "type": "genomic"}, + "stroke": {"value": "gray"}, + "strokeWidth": {"value": 0.3}, + "width": 500, + "height": 100 + }, + { + "title": "Putative Driver", + "alignment": "overlay", + "data": { + "url": "https://s3.amazonaws.com/gosling-lang.org/data/SV/driver.df.scanb.complete.csv", + "type": "csv", + "chromosomeField": "Chr", + "genomicFields": ["ChrStart", "ChrEnd"] + }, + "dataTransform": [ + {"type": "filter", "field": "Sample", "oneOf": ["PD35930a"]} + ], + "tracks": [ + {"mark": "text"}, + {"mark": "triangleBottom", "size": {"value": 5}} + ], + "x": {"field": "ChrStart", "type": "genomic"}, + "xe": {"field": "ChrEnd", "type": "genomic"}, + "text": {"field": "Gene", "type": "nominal"}, + "color": {"value": "black"}, + "style": { + "textFontWeight": "normal", + "dx": -10, + "outlineWidth": 0 + }, + "width": 500, + "height": 40 + }, + { + "title": "LOH", + "style": {"background": "lightgray", "backgroundOpacity": 0.2}, + "alignment": "overlay", + "data": { + "url": "https://s3.amazonaws.com/gosling-lang.org/data/cancer/cnv.PD35930a.csv", + "headerNames": [ + "id", + "chr", + "start", + "end", + "total_cn_normal", + "minor_cp_normal", + "total_cn_tumor", + "minor_cn_tumor" + ], + "type": "csv", + "chromosomeField": "chr", + "genomicFields": ["start", "end"] + }, + "dataTransform": [ + {"type": "filter", "field": "minor_cn_tumor", "oneOf": ["0"]} + ], + "tracks": [ + {"mark": "rect"}, + { + "mark": "brush", + "x": {"linkingId": "mid-scale"}, + "strokeWidth": {"value": 1}, + "stroke": {"value": "#94C2EF"}, + "color": {"value": "#AFD8FF"} + } + ], + "x": {"field": "start", "type": "genomic"}, + "xe": {"field": "end", "type": "genomic"}, + "color": {"value": "#FB6A4B"}, + "width": 620, + "height": 40 + }, + { + "title": "Gain", + "style": {"background": "lightgray", "backgroundOpacity": 0.2}, + "alignment": "overlay", + "data": { + "url": "https://s3.amazonaws.com/gosling-lang.org/data/cancer/cnv.PD35930a.csv", + "headerNames": [ + "id", + "chr", + "start", + "end", + "total_cn_normal", + "minor_cp_normal", + "total_cn_tumor", + "minor_cn_tumor" + ], + "type": "csv", + "chromosomeField": "chr", + "genomicFields": ["start", "end"] + }, + "dataTransform": [ + { + "type": "filter", + "field": "total_cn_tumor", + "inRange": [4.5, 900] + } + ], + "tracks": [ + {"mark": "rect"}, + { + "mark": "brush", + "x": {"linkingId": "mid-scale"}, + "strokeWidth": {"value": 0} + } + ], + "x": {"field": "start", "type": "genomic"}, + "xe": {"field": "end", "type": "genomic"}, + "color": {"value": "#73C475"}, + "width": 500, + "height": 40 + }, + { + "title": "Structural Variant", + "data": { + "url": "https://s3.amazonaws.com/gosling-lang.org/data/cancer/rearrangement.PD35930a.csv", + "type": "csv", + "genomicFieldsToConvert": [ + { + "chromosomeField": "chr1", + "genomicFields": ["start1", "end1"] + }, + { + "chromosomeField": "chr2", + "genomicFields": ["start2", "end2"] + } + ] + }, + "mark": "withinLink", + "x": {"field": "start1", "type": "genomic"}, + "xe": {"field": "end2", "type": "genomic"}, + "color": { + "field": "svclass", + "type": "nominal", + "legend": true, + "domain": [ + "tandem-duplication", + "translocation", + "delection", + "inversion" + ], + "range": ["#569C4D", "#4C75A2", "#DA5456", "#EA8A2A"] + }, + "stroke": { + "field": "svclass", + "type": "nominal", + "domain": [ + "tandem-duplication", + "translocation", + "delection", + "inversion" + ], + "range": ["#569C4D", "#4C75A2", "#DA5456", "#EA8A2A"] + }, + "strokeWidth": {"value": 1}, + "opacity": {"value": 0.6}, + "style": {"legendTitle": "SV Class"}, + "width": 500, + "height": 80 + } + ] + }, + { + "linkingId": "mid-scale", + "xDomain": {"chromosome": "chr1"}, + "layout": "linear", + "tracks": [ + { + "style": { + "background": "#D7EBFF", + "outline": "#8DC1F2", + "outlineWidth": 5 + }, + "title": "Ideogram", + "alignment": "overlay", + "data": { + "url": "https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/UCSC.HG38.Human.CytoBandIdeogram.csv", + "type": "csv", + "chromosomeField": "Chromosome", + "genomicFields": ["chromStart", "chromEnd"] + }, + "tracks": [ + { + "mark": "rect", + "dataTransform": [ + { + "type": "filter", + "field": "Stain", + "oneOf": ["acen"], + "not": true + } + ] + }, + { + "mark": "triangleRight", + "dataTransform": [ + {"type": "filter", "field": "Stain", "oneOf": ["acen"]}, + {"type": "filter", "field": "Name", "include": "q"} + ] + }, + { + "mark": "triangleLeft", + "dataTransform": [ + {"type": "filter", "field": "Stain", "oneOf": ["acen"]}, + {"type": "filter", "field": "Name", "include": "p"} + ] + }, + { + "mark": "text", + "dataTransform": [ + { + "type": "filter", + "field": "Stain", + "oneOf": ["acen"], + "not": true + } + ], + "size": {"value": 12}, + "color": { + "field": "Stain", + "type": "nominal", + "domain": [ + "gneg", + "gpos25", + "gpos50", + "gpos75", + "gpos100", + "gvar" + ], + "range": [ + "black", + "black", + "black", + "black", + "white", + "black" + ] + }, + "visibility": [ + { + "operation": "less-than", + "measure": "width", + "threshold": "|xe-x|", + "transitionPadding": 10, + "target": "mark" + } + ] + } + ], + "color": { + "field": "Stain", + "type": "nominal", + "domain": [ + "gneg", + "gpos25", + "gpos50", + "gpos75", + "gpos100", + "gvar", + "acen" + ], + "range": [ + "white", + "lightgray", + "gray", + "gray", + "black", + "#7B9CC8", + "#DC4542" + ] + }, + "size": {"value": 18}, + "x": {"field": "chromStart", "type": "genomic"}, + "xe": {"field": "chromEnd", "type": "genomic"}, + "text": {"field": "Name", "type": "nominal"}, + "stroke": {"value": "gray"}, + "strokeWidth": {"value": 0.3}, + "width": 500, + "height": 30 + }, + { + "title": "Putative Driver", + "data": { + "url": "https://s3.amazonaws.com/gosling-lang.org/data/SV/driver.df.scanb.complete.csv", + "type": "csv", + "chromosomeField": "Chr", + "genomicFields": ["ChrStart", "ChrEnd"] + }, + "dataTransform": [ + {"type": "filter", "field": "Sample", "oneOf": ["PD35930a"]} + ], + "mark": "text", + "x": {"field": "ChrStart", "type": "genomic"}, + "xe": {"field": "ChrEnd", "type": "genomic"}, + "text": {"field": "Gene", "type": "nominal"}, + "color": {"value": "black"}, + "style": {"textFontWeight": "normal", "dx": -10}, + "width": 500, + "height": 20 + }, + { + "alignment": "overlay", + "title": "hg38 | Genes", + "data": { + "url": "https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation", + "type": "beddb", + "genomicFields": [ + {"index": 1, "name": "start"}, + {"index": 2, "name": "end"} + ], + "valueFields": [ + {"index": 5, "name": "strand", "type": "nominal"}, + {"index": 3, "name": "name", "type": "nominal"} + ], + "exonIntervalFields": [ + {"index": 12, "name": "start"}, + {"index": 13, "name": "end"} + ] + }, + "tracks": [ + { + "dataTransform": [ + {"type": "filter", "field": "type", "oneOf": ["gene"]}, + {"type": "filter", "field": "strand", "oneOf": ["+"]} + ], + "mark": "triangleRight", + "x": {"field": "end", "type": "genomic"}, + "size": {"value": 15} + }, + { + "dataTransform": [ + {"type": "filter", "field": "type", "oneOf": ["gene"]} + ], + "mark": "text", + "text": {"field": "name", "type": "nominal"}, + "x": {"field": "start", "type": "genomic"}, + "xe": {"field": "end", "type": "genomic"}, + "style": {"dy": -15, "outline": "black", "outlineWidth": 0} + }, + { + "dataTransform": [ + {"type": "filter", "field": "type", "oneOf": ["gene"]}, + {"type": "filter", "field": "strand", "oneOf": ["-"]} + ], + "mark": "triangleLeft", + "x": {"field": "start", "type": "genomic"}, + "size": {"value": 15}, + "style": { + "align": "right", + "outline": "black", + "outlineWidth": 0 + } + }, + { + "dataTransform": [ + {"type": "filter", "field": "type", "oneOf": ["exon"]} + ], + "mark": "rect", + "x": {"field": "start", "type": "genomic"}, + "size": {"value": 15}, + "xe": {"field": "end", "type": "genomic"} + }, + { + "dataTransform": [ + {"type": "filter", "field": "type", "oneOf": ["gene"]}, + {"type": "filter", "field": "strand", "oneOf": ["+"]} + ], + "mark": "rule", + "x": {"field": "start", "type": "genomic"}, + "strokeWidth": {"value": 2}, + "xe": {"field": "end", "type": "genomic"}, + "style": { + "linePattern": {"type": "triangleRight", "size": 3.5}, + "outline": "black", + "outlineWidth": 0 + } + }, + { + "dataTransform": [ + {"type": "filter", "field": "type", "oneOf": ["gene"]}, + {"type": "filter", "field": "strand", "oneOf": ["-"]} + ], + "mark": "rule", + "x": {"field": "start", "type": "genomic"}, + "strokeWidth": {"value": 2}, + "xe": {"field": "end", "type": "genomic"}, + "style": { + "linePattern": {"type": "triangleLeft", "size": 3.5}, + "outline": "black", + "outlineWidth": 0 + } + }, + { + "mark": "brush", + "x": {"linkingId": "detail-1"}, + "strokeWidth": {"value": 0}, + "color": {"value": "gray"}, + "opacity": {"value": 0.3} + }, + { + "mark": "brush", + "x": {"linkingId": "detail-2"}, + "strokeWidth": {"value": 0}, + "color": {"value": "gray"}, + "opacity": {"value": 0.3} + } + ], + "row": { + "field": "strand", + "type": "nominal", + "domain": ["+", "-"] + }, + "color": { + "field": "strand", + "type": "nominal", + "domain": ["+", "-"], + "range": ["#97A8B2", "#D4C6BA"] + }, + "visibility": [ + { + "operation": "less-than", + "measure": "width", + "threshold": "|xe-x|", + "transitionPadding": 10, + "target": "mark" + } + ], + "width": 400, + "height": 100 + }, + { + "title": "LOH", + "style": {"background": "lightgray", "backgroundOpacity": 0.2}, + "data": { + "url": "https://s3.amazonaws.com/gosling-lang.org/data/cancer/cnv.PD35930a.csv", + "headerNames": [ + "id", + "chr", + "start", + "end", + "total_cn_normal", + "minor_cp_normal", + "total_cn_tumor", + "minor_cn_tumor" + ], + "type": "csv", + "chromosomeField": "chr", + "genomicFields": ["start", "end"] + }, + "dataTransform": [ + {"type": "filter", "field": "minor_cn_tumor", "oneOf": ["0"]} + ], + "mark": "rect", + "x": {"field": "start", "type": "genomic"}, + "xe": {"field": "end", "type": "genomic"}, + "color": {"value": "#FB6A4B"}, + "width": 620, + "height": 20 + }, + { + "title": "Gain", + "style": {"background": "lightgray", "backgroundOpacity": 0.2}, + "data": { + "url": "https://s3.amazonaws.com/gosling-lang.org/data/cancer/cnv.PD35930a.csv", + "headerNames": [ + "id", + "chr", + "start", + "end", + "total_cn_normal", + "minor_cp_normal", + "total_cn_tumor", + "minor_cn_tumor" + ], + "type": "csv", + "chromosomeField": "chr", + "genomicFields": ["start", "end"] + }, + "dataTransform": [ + { + "type": "filter", + "field": "total_cn_tumor", + "inRange": [4.5, 900] + } + ], + "mark": "rect", + "x": {"field": "start", "type": "genomic"}, + "xe": {"field": "end", "type": "genomic"}, + "color": {"value": "#73C475"}, + "width": 500, + "height": 20 + }, + { + "title": "Structural Variant", + "data": { + "url": "https://s3.amazonaws.com/gosling-lang.org/data/cancer/rearrangement.PD35930a.csv", + "type": "csv", + "genomicFieldsToConvert": [ + { + "chromosomeField": "chr1", + "genomicFields": ["start1", "end1"] + }, + { + "chromosomeField": "chr2", + "genomicFields": ["start2", "end2"] + } + ] + }, + "alignment": "overlay", + "tracks": [ + { + "mark": "withinLink", + "x": {"field": "start1", "type": "genomic"}, + "xe": {"field": "end2", "type": "genomic"} + }, + { + "mark": "point", + "x": {"field": "start1", "type": "genomic"}, + "y": {"value": 400} + }, + { + "mark": "point", + "x": {"field": "end2", "type": "genomic"}, + "y": {"value": 400} + } + ], + "color": { + "field": "svclass", + "type": "nominal", + "domain": [ + "tandem-duplication", + "translocation", + "delection", + "inversion" + ], + "range": ["#569C4D", "#4C75A2", "#DA5456", "#EA8A2A"], + "legend": true + }, + "stroke": { + "field": "svclass", + "type": "nominal", + "domain": [ + "tandem-duplication", + "translocation", + "delection", + "inversion" + ], + "range": ["#569C4D", "#4C75A2", "#DA5456", "#EA8A2A"] + }, + "strokeWidth": {"value": 1}, + "opacity": {"value": 0.6}, + "size": {"value": 4}, + "tooltip": [ + {"field": "start1", "type": "genomic"}, + {"field": "end2", "type": "genomic"}, + {"field": "svclass", "type": "nominal"} + ], + "style": {"legendTitle": "SV Class", "linkStyle": "elliptical"}, + "width": 1000, + "height": 200 + } + ] + } + ] + }, + { + "arrangement": "horizontal", + "spacing": 100, + "views": [ + { + "static": false, + "layout": "linear", + "centerRadius": 0.05, + "xDomain": {"chromosome": "chr1", "interval": [205000, 207000]}, + "spacing": 0.01, + "tracks": [ + { + "alignment": "overlay", + "title": "example_higlass.bam", + "data": { + "type": "bam", + "url": "https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam", + "indexUrl": "https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai", + "loadMates": true + }, + "mark": "bar", + "tracks": [ + { + "dataTransform": [ + { + "type": "coverage", + "startField": "start", + "endField": "end" + } + ], + "x": {"field": "start", "type": "genomic"}, + "xe": {"field": "end", "type": "genomic"}, + "y": { + "field": "coverage", + "type": "quantitative", + "axis": "right" + }, + "color": {"value": "#C6C6C6"} + } + ], + "style": {"outlineWidth": 0.5}, + "width": 450, + "height": 80 + }, + { + "alignment": "overlay", + "title": "example_higlass.bam", + "data": { + "type": "bam", + "url": "https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam", + "indexUrl": "https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai", + "loadMates": true, + "maxInsertSize": 300 + }, + "mark": "rect", + "tracks": [ + { + "dataTransform": [ + { + "type": "displace", + "method": "pile", + "boundingBox": { + "startField": "start", + "endField": "end", + "padding": 5, + "isPaddingBP": true }, - { - alignment: 'overlay', - title: 'hg38 | Genes', - data: { - url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', - type: 'beddb', - genomicFields: [ - { index: 1, name: 'start' }, - { index: 2, name: 'end' } - ], - valueFields: [ - { index: 5, name: 'strand', type: 'nominal' }, - { index: 3, name: 'name', type: 'nominal' } - ], - exonIntervalFields: [ - { index: 12, name: 'start' }, - { index: 13, name: 'end' } - ] - }, - tracks: [ - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['+'] } - ], - mark: 'triangleRight', - x: { field: 'end', type: 'genomic' }, - size: { value: 15 } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], - mark: 'text', - text: { field: 'name', type: 'nominal' }, - x: { field: 'start', type: 'genomic' }, - xe: { field: 'end', type: 'genomic' }, - style: { dy: -15, outline: 'black', outlineWidth: 0 } - }, - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['-'] } - ], - mark: 'triangleLeft', - x: { field: 'start', type: 'genomic' }, - size: { value: 15 }, - style: { - align: 'right', - outline: 'black', - outlineWidth: 0 - } - }, - { - dataTransform: [{ type: 'filter', field: 'type', oneOf: ['exon'] }], - mark: 'rect', - x: { field: 'start', type: 'genomic' }, - size: { value: 15 }, - xe: { field: 'end', type: 'genomic' } - }, - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['+'] } - ], - mark: 'rule', - x: { field: 'start', type: 'genomic' }, - strokeWidth: { value: 2 }, - xe: { field: 'end', type: 'genomic' }, - style: { - linePattern: { type: 'triangleRight', size: 3.5 }, - outline: 'black', - outlineWidth: 0 - } - }, - { - dataTransform: [ - { type: 'filter', field: 'type', oneOf: ['gene'] }, - { type: 'filter', field: 'strand', oneOf: ['-'] } - ], - mark: 'rule', - x: { field: 'start', type: 'genomic' }, - strokeWidth: { value: 2 }, - xe: { field: 'end', type: 'genomic' }, - style: { - linePattern: { type: 'triangleLeft', size: 3.5 }, - outline: 'black', - outlineWidth: 0 - } - }, - { - mark: 'brush', - x: { linkingId: 'detail-1' }, - strokeWidth: { value: 0 }, - color: { value: 'gray' }, - opacity: { value: 0.3 } - }, - { - mark: 'brush', - x: { linkingId: 'detail-2' }, - strokeWidth: { value: 0 }, - color: { value: 'gray' }, - opacity: { value: 0.3 } - } - ], - row: { - field: 'strand', - type: 'nominal', - domain: ['+', '-'] - }, - color: { - field: 'strand', - type: 'nominal', - domain: ['+', '-'], - range: ['#97A8B2', '#D4C6BA'] - }, - visibility: [ - { - operation: 'less-than', - measure: 'width', - threshold: '|xe-x|', - transitionPadding: 10, - target: 'mark' - } - ], - width: 400, - height: 100 + "newField": "pileup-row" + } + ], + "x": {"field": "start", "type": "genomic"}, + "xe": {"field": "end", "type": "genomic"}, + "color": { + "field": "svType", + "type": "nominal", + "legend": true, + "domain": [ + "normal read", + "deletion (+-)", + "inversion (++)", + "inversion (--)", + "duplication (-+)", + "more than two mates", + "mates not found within chromosome", + "clipping" + ], + "range": [ + "#C8C8C8", + "#E79F00", + "#029F73", + "#0072B2", + "#CB7AA7", + "#57B4E9", + "#D61E2E", + "#414141" + ] + } + }, + { + "dataTransform": [ + { + "type": "displace", + "method": "pile", + "boundingBox": { + "startField": "start", + "endField": "end", + "padding": 5, + "isPaddingBP": true }, - { - title: 'LOH', - style: { background: 'lightgray', backgroundOpacity: 0.2 }, - data: { - url: 'https://s3.amazonaws.com/gosling-lang.org/data/cancer/cnv.PD35930a.csv', - headerNames: [ - 'id', - 'chr', - 'start', - 'end', - 'total_cn_normal', - 'minor_cp_normal', - 'total_cn_tumor', - 'minor_cn_tumor' - ], - type: 'csv', - chromosomeField: 'chr', - genomicFields: ['start', 'end'] - }, - dataTransform: [{ type: 'filter', field: 'minor_cn_tumor', oneOf: ['0'] }], - mark: 'rect', - x: { field: 'start', type: 'genomic' }, - xe: { field: 'end', type: 'genomic' }, - color: { value: '#FB6A4B' }, - width: 620, - height: 20 + "newField": "pileup-row" + }, + { + "type": "subjson", + "field": "substitutions", + "genomicField": "pos", + "baseGenomicField": "start", + "genomicLengthField": "length" + }, + {"type": "filter", "field": "type", "oneOf": ["S", "H"]} + ], + "x": {"field": "pos_start", "type": "genomic"}, + "xe": {"field": "pos_end", "type": "genomic"}, + "color": {"value": "#414141"} + } + ], + "tooltip": [ + {"field": "start", "type": "genomic"}, + {"field": "end", "type": "genomic"}, + {"field": "insertSize", "type": "quantitative"}, + {"field": "svType", "type": "nominal"}, + {"field": "strand", "type": "nominal"}, + {"field": "numMates", "type": "quantitative"}, + {"field": "mateIds", "type": "nominal"} + ], + "row": {"field": "pileup-row", "type": "nominal", "padding": 0.2}, + "style": { + "outlineWidth": 0.5, + "legendTitle": "Insert Size = 300bp" + }, + "width": 450, + "height": 310 + } + ] + }, + { + "static": false, + "layout": "linear", + "centerRadius": 0.05, + "xDomain": {"chromosome": "chr1", "interval": [490000, 496000]}, + "spacing": 0.01, + "tracks": [ + { + "alignment": "overlay", + "title": "example_higlass.bam", + "data": { + "type": "bam", + "url": "https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam", + "indexUrl": "https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai", + "loadMates": true + }, + "mark": "bar", + "tracks": [ + { + "dataTransform": [ + { + "type": "coverage", + "startField": "start", + "endField": "end" + } + ], + "x": {"field": "start", "type": "genomic"}, + "xe": {"field": "end", "type": "genomic"}, + "y": { + "field": "coverage", + "type": "quantitative", + "axis": "right" + }, + "color": {"value": "#C6C6C6"} + } + ], + "style": {"outlineWidth": 0.5}, + "width": 450, + "height": 80 + }, + { + "alignment": "overlay", + "title": "example_higlass.bam", + "data": { + "type": "bam", + "url": "https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam", + "indexUrl": "https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai", + "loadMates": true, + "maxInsertSize": 300 + }, + "mark": "rect", + "tracks": [ + { + "dataTransform": [ + { + "type": "displace", + "method": "pile", + "boundingBox": { + "startField": "start", + "endField": "end", + "padding": 5, + "isPaddingBP": true }, - { - title: 'Gain', - style: { background: 'lightgray', backgroundOpacity: 0.2 }, - data: { - url: 'https://s3.amazonaws.com/gosling-lang.org/data/cancer/cnv.PD35930a.csv', - headerNames: [ - 'id', - 'chr', - 'start', - 'end', - 'total_cn_normal', - 'minor_cp_normal', - 'total_cn_tumor', - 'minor_cn_tumor' - ], - type: 'csv', - chromosomeField: 'chr', - genomicFields: ['start', 'end'] - }, - dataTransform: [ - { - type: 'filter', - field: 'total_cn_tumor', - inRange: [4.5, 900] - } - ], - mark: 'rect', - x: { field: 'start', type: 'genomic' }, - xe: { field: 'end', type: 'genomic' }, - color: { value: '#73C475' }, - width: 500, - height: 20 + "newField": "pileup-row" + } + ], + "x": {"field": "start", "type": "genomic"}, + "xe": {"field": "end", "type": "genomic"}, + "color": { + "field": "svType", + "type": "nominal", + "legend": true, + "domain": [ + "normal read", + "deletion (+-)", + "inversion (++)", + "inversion (--)", + "duplication (-+)", + "more than two mates", + "mates not found within chromosome", + "clipping" + ], + "range": [ + "#C8C8C8", + "#E79F00", + "#029F73", + "#0072B2", + "#CB7AA7", + "#57B4E9", + "#D61E2E", + "#414141" + ] + } + }, + { + "dataTransform": [ + { + "type": "displace", + "method": "pile", + "boundingBox": { + "startField": "start", + "endField": "end", + "padding": 5, + "isPaddingBP": true }, - { - title: 'Structural Variant', - data: { - url: 'https://s3.amazonaws.com/gosling-lang.org/data/cancer/rearrangement.PD35930a.csv', - type: 'csv', - genomicFieldsToConvert: [ - { - chromosomeField: 'chr1', - genomicFields: ['start1', 'end1'] - }, - { - chromosomeField: 'chr2', - genomicFields: ['start2', 'end2'] - } - ] - }, - alignment: 'overlay', - tracks: [ - { - mark: 'withinLink', - x: { field: 'start1', type: 'genomic' }, - xe: { field: 'end2', type: 'genomic' } - }, - { - mark: 'point', - x: { field: 'start1', type: 'genomic' }, - y: { value: 400 } - }, - { - mark: 'point', - x: { field: 'end2', type: 'genomic' }, - y: { value: 400 } - } - ], - color: { - field: 'svclass', - type: 'nominal', - domain: ['tandem-duplication', 'translocation', 'delection', 'inversion'], - range: ['#569C4D', '#4C75A2', '#DA5456', '#EA8A2A'], - legend: true - }, - stroke: { - field: 'svclass', - type: 'nominal', - domain: ['tandem-duplication', 'translocation', 'delection', 'inversion'], - range: ['#569C4D', '#4C75A2', '#DA5456', '#EA8A2A'] - }, - strokeWidth: { value: 1 }, - opacity: { value: 0.6 }, - size: { value: 4 }, - tooltip: [ - { field: 'start1', type: 'genomic' }, - { field: 'end2', type: 'genomic' }, - { field: 'svclass', type: 'nominal' } - ], - style: { legendTitle: 'SV Class', linkStyle: 'elliptical' }, - width: 1000, - height: 200 - } - ] - } + "newField": "pileup-row" + }, + { + "type": "subjson", + "field": "substitutions", + "genomicField": "pos", + "baseGenomicField": "start", + "genomicLengthField": "length" + }, + {"type": "filter", "field": "type", "oneOf": ["S", "H"]} + ], + "x": {"field": "pos_start", "type": "genomic"}, + "xe": {"field": "pos_end", "type": "genomic"}, + "color": {"value": "#414141"} + } + ], + "tooltip": [ + {"field": "start", "type": "genomic"}, + {"field": "end", "type": "genomic"}, + {"field": "insertSize", "type": "quantitative"}, + {"field": "svType", "type": "nominal"}, + {"field": "strand", "type": "nominal"}, + {"field": "numMates", "type": "quantitative"}, + {"field": "mateIds", "type": "nominal"} + ], + "row": {"field": "pileup-row", "type": "nominal", "padding": 0.2}, + "style": { + "outlineWidth": 0.5, + "legendTitle": "Insert Size = 300bp" + }, + "width": 450, + "height": 310 + } ] - } - // { - // arrangement: 'horizontal', - // spacing: 100, - // views: [ - // { - // static: false, - // layout: 'linear', - // centerRadius: 0.05, - // xDomain: { chromosome: 'chr1', interval: [205000, 207000] }, - // spacing: 0.01, - // tracks: [ - // { - // alignment: 'overlay', - // title: 'example_higlass.bam', - // data: { - // type: 'bam', - // url: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam', - // indexUrl: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai', - // loadMates: true - // }, - // mark: 'bar', - // tracks: [ - // { - // dataTransform: [ - // { - // type: 'coverage', - // startField: 'start', - // endField: 'end' - // } - // ], - // x: { field: 'start', type: 'genomic' }, - // xe: { field: 'end', type: 'genomic' }, - // y: { - // field: 'coverage', - // type: 'quantitative', - // axis: 'right' - // }, - // color: { value: '#C6C6C6' } - // } - // ], - // style: { outlineWidth: 0.5 }, - // width: 450, - // height: 80 - // }, - // { - // alignment: 'overlay', - // title: 'example_higlass.bam', - // data: { - // type: 'bam', - // url: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam', - // indexUrl: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai', - // loadMates: true, - // maxInsertSize: 300 - // }, - // mark: 'rect', - // tracks: [ - // { - // dataTransform: [ - // { - // type: 'displace', - // method: 'pile', - // boundingBox: { - // startField: 'start', - // endField: 'end', - // padding: 5, - // isPaddingBP: true - // }, - // newField: 'pileup-row' - // } - // ], - // x: { field: 'start', type: 'genomic' }, - // xe: { field: 'end', type: 'genomic' }, - // color: { - // field: 'svType', - // type: 'nominal', - // legend: true, - // domain: [ - // 'normal read', - // 'deletion (+-)', - // 'inversion (++)', - // 'inversion (--)', - // 'duplication (-+)', - // 'more than two mates', - // 'mates not found within chromosome', - // 'clipping' - // ], - // range: [ - // '#C8C8C8', - // '#E79F00', - // '#029F73', - // '#0072B2', - // '#CB7AA7', - // '#57B4E9', - // '#D61E2E', - // '#414141' - // ] - // } - // }, - // { - // dataTransform: [ - // { - // type: 'displace', - // method: 'pile', - // boundingBox: { - // startField: 'start', - // endField: 'end', - // padding: 5, - // isPaddingBP: true - // }, - // newField: 'pileup-row' - // }, - // { - // type: 'subjson', - // field: 'substitutions', - // genomicField: 'pos', - // baseGenomicField: 'start', - // genomicLengthField: 'length' - // }, - // { type: 'filter', field: 'type', oneOf: ['S', 'H'] } - // ], - // x: { field: 'pos_start', type: 'genomic' }, - // xe: { field: 'pos_end', type: 'genomic' }, - // color: { value: '#414141' } - // } - // ], - // tooltip: [ - // { field: 'start', type: 'genomic' }, - // { field: 'end', type: 'genomic' }, - // { field: 'insertSize', type: 'quantitative' }, - // { field: 'svType', type: 'nominal' }, - // { field: 'strand', type: 'nominal' }, - // { field: 'numMates', type: 'quantitative' }, - // { field: 'mateIds', type: 'nominal' } - // ], - // row: { field: 'pileup-row', type: 'nominal', padding: 0.2 }, - // style: { - // outlineWidth: 0.5, - // legendTitle: 'Insert Size = 300bp' - // }, - // width: 450, - // height: 310 - // } - // ] - // }, - // { - // static: false, - // layout: 'linear', - // centerRadius: 0.05, - // xDomain: { chromosome: 'chr1', interval: [490000, 496000] }, - // spacing: 0.01, - // tracks: [ - // { - // alignment: 'overlay', - // title: 'example_higlass.bam', - // data: { - // type: 'bam', - // url: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam', - // indexUrl: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai', - // loadMates: true - // }, - // mark: 'bar', - // tracks: [ - // { - // dataTransform: [ - // { - // type: 'coverage', - // startField: 'start', - // endField: 'end' - // } - // ], - // x: { field: 'start', type: 'genomic' }, - // xe: { field: 'end', type: 'genomic' }, - // y: { - // field: 'coverage', - // type: 'quantitative', - // axis: 'right' - // }, - // color: { value: '#C6C6C6' } - // } - // ], - // style: { outlineWidth: 0.5 }, - // width: 450, - // height: 80 - // }, - // { - // alignment: 'overlay', - // title: 'example_higlass.bam', - // data: { - // type: 'bam', - // url: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam', - // indexUrl: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai', - // loadMates: true, - // maxInsertSize: 300 - // }, - // mark: 'rect', - // tracks: [ - // { - // dataTransform: [ - // { - // type: 'displace', - // method: 'pile', - // boundingBox: { - // startField: 'start', - // endField: 'end', - // padding: 5, - // isPaddingBP: true - // }, - // newField: 'pileup-row' - // } - // ], - // x: { field: 'start', type: 'genomic' }, - // xe: { field: 'end', type: 'genomic' }, - // color: { - // field: 'svType', - // type: 'nominal', - // legend: true, - // domain: [ - // 'normal read', - // 'deletion (+-)', - // 'inversion (++)', - // 'inversion (--)', - // 'duplication (-+)', - // 'more than two mates', - // 'mates not found within chromosome', - // 'clipping' - // ], - // range: [ - // '#C8C8C8', - // '#E79F00', - // '#029F73', - // '#0072B2', - // '#CB7AA7', - // '#57B4E9', - // '#D61E2E', - // '#414141' - // ] - // } - // }, - // { - // dataTransform: [ - // { - // type: 'displace', - // method: 'pile', - // boundingBox: { - // startField: 'start', - // endField: 'end', - // padding: 5, - // isPaddingBP: true - // }, - // newField: 'pileup-row' - // }, - // { - // type: 'subjson', - // field: 'substitutions', - // genomicField: 'pos', - // baseGenomicField: 'start', - // genomicLengthField: 'length' - // }, - // { type: 'filter', field: 'type', oneOf: ['S', 'H'] } - // ], - // x: { field: 'pos_start', type: 'genomic' }, - // xe: { field: 'pos_end', type: 'genomic' }, - // color: { value: '#414141' } - // } - // ], - // tooltip: [ - // { field: 'start', type: 'genomic' }, - // { field: 'end', type: 'genomic' }, - // { field: 'insertSize', type: 'quantitative' }, - // { field: 'svType', type: 'nominal' }, - // { field: 'strand', type: 'nominal' }, - // { field: 'numMates', type: 'quantitative' }, - // { field: 'mateIds', type: 'nominal' } - // ], - // row: { field: 'pileup-row', type: 'nominal', padding: 0.2 }, - // style: { - // outlineWidth: 0.5, - // legendTitle: 'Insert Size = 300bp' - // }, - // width: 450, - // height: 310 - // } - // ] - // } - // ] - // } + } + ] + } ] -}; + } \ No newline at end of file diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index 8d00f2fd1..40c526bd0 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -121,25 +121,45 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE if (type === TrackType.BrushLinear) { const domain = getXDomainSignal(trackDef.trackId, linkedEncodings); const brushDomain = getBrushSignal(trackDef.trackId, linkedEncodings); - - new BrushLinearTrack(options, brushDomain, pixiManager.makeContainer(boundingBox).overlayDiv).addInteractor( - plot => panZoom(plot, domain) - ); + // We only want to add the brush track if it is linked to another track + if (hasLinkedTracks(trackDef.trackId, linkedEncodings)) { + new BrushLinearTrack( + options, + brushDomain, + pixiManager.makeContainer(boundingBox).overlayDiv + ).addInteractor(plot => panZoom(plot, domain)); + } } if (type === TrackType.BrushCircular) { const domain = getXDomainSignal(trackDef.trackId, linkedEncodings); const brushDomain = getBrushSignal(trackDef.trackId, linkedEncodings); - - new BrushCircularTrack(options, brushDomain, pixiManager.makeContainer(boundingBox).overlayDiv, domain); + // We only want to add the brush track if it is linked to another track + if (hasLinkedTracks(trackDef.trackId, linkedEncodings)) { + new BrushCircularTrack(options, brushDomain, pixiManager.makeContainer(boundingBox).overlayDiv, domain); + } } }); } +/** + * Returns true if the brushId is linked to another track + * We don't want to render a brush track if it is not linked to another track + */ +function hasLinkedTracks(brushId: string, linkedEncodings: LinkedEncoding[]): boolean { + const linkedEncoding = linkedEncodings.find(link => link.brushIds.includes(brushId)); + if (!linkedEncoding) return false; + return linkedEncoding?.brushIds.length > 0 && linkedEncoding?.trackIds.length > 0; +} + function getBrushSignal(trackDefId: string, linkedEncodings: LinkedEncoding[]): Signal<[number, number]> { const linkedEncoding = linkedEncodings.find(link => link.brushIds.includes(trackDefId)); if (!linkedEncoding) { - console.error(`No linked encoding found for track ${trackDefId}`); + console.warn(`No linked encoding found for track ${trackDefId}`); + return signal<[number, number]>([0, 30000000]); + } + if (!linkedEncoding.signal) { + console.warn(`No signal found for linked encoding ${linkedEncoding.linkingId}`); return signal<[number, number]>([0, 30000000]); } return linkedEncoding!.signal; @@ -149,7 +169,11 @@ function getXDomainSignal(trackDefId: string, linkedEncodings: LinkedEncoding[]) const linkedEncoding = linkedEncodings.find(link => link.trackIds.includes(trackDefId)); if (!linkedEncoding) { - console.error(`No linked encoding found for track ${trackDefId}`); + console.warn(`No linked encoding found for track ${trackDefId}`); + return signal<[number, number]>([0, 30000000]); + } + if (!linkedEncoding.signal) { + console.warn(`No signal found for linked encoding ${linkedEncoding.linkingId}`); return signal<[number, number]>([0, 30000000]); } return linkedEncoding!.signal; From a6e54d46f59172ad5a6c950bca017faa6a625832 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Mon, 24 Jun 2024 15:18:40 -0400 Subject: [PATCH 064/139] feat: mocked worker --- scripts/setup-vitest.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/scripts/setup-vitest.js b/scripts/setup-vitest.js index 8985bb85b..1e717bfd9 100644 --- a/scripts/setup-vitest.js +++ b/scripts/setup-vitest.js @@ -8,4 +8,20 @@ beforeAll(() => { return ''; }; global.jest = vi; // Needed to mock canvas in jest -}); \ No newline at end of file +}); + +// Mock Worker to fix "ReferenceError: Worker is not defined" error in tests +class WorkerMock { + constructor(stringUrl) { + this.url = stringUrl; + this.onmessage = () => {}; + } + + postMessage(msg) { + this.onmessage({ data: msg }); + } + + terminate() {} + } + + global.Worker = WorkerMock; \ No newline at end of file From e50c0e0a07fc825721bc620bcd7e0c736847931a Mon Sep 17 00:00:00 2001 From: etowahadams Date: Mon, 24 Jun 2024 15:19:00 -0400 Subject: [PATCH 065/139] make vitest run on demo --- vite.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vite.config.js b/vite.config.js index d4beb8cb5..b671a5fbf 100644 --- a/vite.config.js +++ b/vite.config.js @@ -139,7 +139,7 @@ const testing = defineConfig({ coverage: { reportsDirectory: './coverage', reporter: ['lcov', 'text'], - include: ['src', 'editor'] + include: ['src', 'editor', 'demo'] } } }); From 41f548824b602adc8b9e41cc06ef396512236708 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Mon, 24 Jun 2024 15:19:11 -0400 Subject: [PATCH 066/139] test: linkedEncoding --- demo/renderer/linkedEncoding.test.ts | 276 +++++++++++++++++++++++++++ demo/renderer/linkedEncoding.ts | 3 +- 2 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 demo/renderer/linkedEncoding.test.ts diff --git a/demo/renderer/linkedEncoding.test.ts b/demo/renderer/linkedEncoding.test.ts new file mode 100644 index 000000000..3bdd151a0 --- /dev/null +++ b/demo/renderer/linkedEncoding.test.ts @@ -0,0 +1,276 @@ +import { describe, it, expect } from 'vitest'; +import { getLinkedEncodings } from './linkedEncoding'; + +describe('Link tracks', () => { + it('one track, one view', () => { + const spec = { + tracks: [{ id: 'track-1', x: { field: 'a', type: 'genomic' }, y: { field: 'b', type: 'quantitative' } }] + }; + const result = getLinkedEncodings(spec); + expect(result).toMatchInlineSnapshot(` + [ + { + "brushIds": [], + "encoding": "x", + "linkingId": undefined, + "signal": [ + 0, + 3088269832, + ], + "trackIds": [ + "track-1", + ], + }, + ] + `); + }); + + it('two tracks, one view', () => { + const spec = { + tracks: [ + { id: 'track-1', x: { field: 'a', type: 'genomic' }, y: { field: 'b', type: 'quantitative' } }, + { id: 'track-2', x: { field: 'a', type: 'genomic' }, y: { field: 'b', type: 'quantitative' } } + ] + }; + const result = getLinkedEncodings(spec); + expect(result).toMatchInlineSnapshot(` + [ + { + "brushIds": [], + "encoding": "x", + "linkingId": undefined, + "signal": [ + 0, + 3088269832, + ], + "trackIds": [ + "track-1", + "track-2", + ], + }, + ] + `); + }); + it('two view with one track each', () => { + const spec = { + views: [ + { + tracks: [ + { id: 'track-1', x: { field: 'a', type: 'genomic' }, y: { field: 'b', type: 'quantitative' } } + ] + }, + { + tracks: [ + { id: 'track-2', x: { field: 'a', type: 'genomic' }, y: { field: 'b', type: 'quantitative' } } + ] + } + ] + }; + const result = getLinkedEncodings(spec); + expect(result).toMatchInlineSnapshot(` + [ + { + "brushIds": [], + "encoding": "x", + "linkingId": undefined, + "signal": [ + 0, + 3088269832, + ], + "trackIds": [ + "track-1", + ], + }, + { + "brushIds": [], + "encoding": "x", + "linkingId": undefined, + "signal": [ + 0, + 3088269832, + ], + "trackIds": [ + "track-2", + ], + }, + ] + `); + }); + it('linkingId', () => { + const linkingTest = { + title: 'Basic Marks: line', + subtitle: 'Tutorial Examples', + views: [ + { + tracks: [ + { + layout: 'linear', + id: 'track-1', + mark: 'line', + x: { field: 'position', type: 'genomic', axis: 'bottom', linkingId: 'test' }, + y: { field: 'peak', type: 'quantitative', axis: 'right' } + } + ] + }, + { + linkingId: 'test', + tracks: [ + { + layout: 'linear', + id: 'track-2', + mark: 'line', + x: { field: 'position', type: 'genomic', axis: 'bottom' }, + y: { field: 'peak', type: 'quantitative', axis: 'right' } + } + ] + }, + { + tracks: [ + { + layout: 'linear', + id: 'track-3', + mark: 'line', + x: { field: 'position', type: 'genomic', axis: 'bottom' }, + y: { field: 'peak', type: 'quantitative', axis: 'right' } + }, + { + layout: 'linear', + id: 'overlay-1', + mark: 'line', + x: { field: 'position', type: 'genomic', axis: 'bottom', linkingId: 'test' }, + y: { field: 'peak', type: 'quantitative', axis: 'right' } + } + ] + } + ] + }; + // Test case 1 + const result1 = getLinkedEncodings(linkingTest); + expect(result1).toMatchInlineSnapshot(` + [ + { + "brushIds": [], + "encoding": "x", + "linkingId": "test", + "signal": [ + 0, + 3088269832, + ], + "trackIds": [ + "track-2", + "track-1", + "overlay-1", + ], + }, + { + "brushIds": [], + "encoding": "x", + "linkingId": undefined, + "signal": [ + 0, + 3088269832, + ], + "trackIds": [ + "track-3", + ], + }, + ] + `); + }); +}); + +describe('Link brushes', () => { + it('single unlinked brush ', () => { + const spec = { + tracks: [ + { + mark: 'brush', + id: 'brush-1', + x: { field: 'a', type: 'genomic' }, + y: { field: 'b', type: 'quantitative' } + } + ] + }; + const result = getLinkedEncodings(spec); + expect(result).toMatchInlineSnapshot(` + [ + { + "brushIds": [], + "encoding": "x", + "linkingId": undefined, + "signal": [ + 0, + 3088269832, + ], + "trackIds": [ + "brush-1", + ], + }, + ] + `); + }); + it('single linked brush ', () => { + const spec = { + views: [ + { + tracks: [ + { + id: 'track-1', + x: { field: 'a', type: 'genomic' }, + y: { + field: 'b', + type: 'quantitative' + }, + _overlay: [ + { + mark: 'brush', + id: 'brush-1', + x: { field: 'a', type: 'genomic', linkingId: 'link1' }, + y: { field: 'b', type: 'quantitative' } + } + ] + } + ] + }, + { + linkingId: 'link1', + tracks: [ + { id: 'track-2', x: { field: 'a', type: 'genomic' }, y: { field: 'b', type: 'quantitative' } } + ] + } + ] + }; + const result = getLinkedEncodings(spec); + expect(result).toMatchInlineSnapshot(` + [ + { + "brushIds": [], + "encoding": "x", + "linkingId": undefined, + "signal": [ + 0, + 3088269832, + ], + "trackIds": [ + "brush-1", + "track-1", + ], + }, + { + "brushIds": [ + "brush-1", + ], + "encoding": "x", + "linkingId": "link1", + "signal": [ + 0, + 3088269832, + ], + "trackIds": [ + "track-2", + ], + }, + ] + `); + }); +}); diff --git a/demo/renderer/linkedEncoding.ts b/demo/renderer/linkedEncoding.ts index ba1994b99..becddad70 100644 --- a/demo/renderer/linkedEncoding.ts +++ b/demo/renderer/linkedEncoding.ts @@ -19,7 +19,7 @@ export interface LinkedEncoding { * This is information extracted from the Gosling spec. * Is is the linking that is defined at the view level. */ -export interface ViewLink { +interface ViewLink { linkingId?: string; encoding: 'x'; trackIds: string[]; @@ -164,6 +164,7 @@ function getSingleViewTrackLinks(gs: SingleView): TrackLink[] { const trackLinks: TrackLink[] = []; tracks.forEach(track => { if ('x' in track && track.x && 'linkingId' in track.x) { + if (track.mark === 'brush') console.warn('Track with brush mark should only be used as an overlay'); const trackLink = { trackId: track.id, linkingId: track.x.linkingId, From ae2e0626df4402a19c1f4c9979f059e95cd41f39 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Mon, 24 Jun 2024 15:43:23 -0400 Subject: [PATCH 067/139] feat: heatmap plot --- src/higlass/tracks.ts | 1 + src/missing-types.d.ts | 45 ++++++++++++++- src/tracks/heatmap/heatmap-plot.ts | 93 ++++++++++++++++++++++++++++++ src/tracks/heatmap/index.ts | 1 + 4 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 src/tracks/heatmap/heatmap-plot.ts create mode 100644 src/tracks/heatmap/index.ts diff --git a/src/higlass/tracks.ts b/src/higlass/tracks.ts index 38a044776..6d0d21eac 100644 --- a/src/higlass/tracks.ts +++ b/src/higlass/tracks.ts @@ -1,3 +1,4 @@ export { SVGTrack } from './higlass-vendored'; export { PixiTrack } from './higlass-vendored'; export { TiledPixiTrack } from './higlass-vendored'; +export { HeatmapTiledPixiTrack } from './higlass-vendored'; diff --git a/src/missing-types.d.ts b/src/missing-types.d.ts index f312fb169..960c9cf2e 100644 --- a/src/missing-types.d.ts +++ b/src/missing-types.d.ts @@ -579,6 +579,46 @@ declare module '@higlass/tracks' { scene: PIXI.Container; } + export interface PixiTrackContext { + pubSub?: any; + scene: PIXI.Container; + id: string; + } + + export interface PixiTrackOptions { + labelPosition?: string; + labelText?: string; + trackBorderWidth?: number; + trackBorderColor?: string; + backgroundColor?: string; + labelColor?: string; + lineStrokeColor?: string; + barFillColor?: string; + name?: string; + labelTextOpacity?: number; + labelBackgroundColor?: string; + labelLeftMargin?: number; + labelRightMargin?: number; + labelTopMargin?: number; + labelBottomMargin?: number; + labelBackgroundOpacity?: number; + labelShowAssembly?: boolean; + labelShowResolution?: boolean; + dataTransform?: string; + } + + export type TiledPixiTrackContext = PixiTrackContext & { + dataFetcher?: DataFetcher; + dataConfig: MinimalDataConfig; + animate: () => void; + onValueScaleChanged: () => void; + handleTilesetInfoReceived: (tilesetInfo: any) => void; + }; + + export type TiledPixiTrackOptions = PixiTrackOptions & { + maxZoom?: number; + }; + interface TrackContext { id: string; pubSub?: PubSub; @@ -589,7 +629,10 @@ declare module '@higlass/tracks' { } interface ViewportTrackerHorizontalContext extends SVGTrackContext { - registerViewportChanged: (uid: string, callback: (viewportXScale: ScaleLinear, viewportYScale: ScaleLinear) => void) => void; + registerViewportChanged: ( + uid: string, + callback: (viewportXScale: ScaleLinear, viewportYScale: ScaleLinear) => void + ) => void; removeViewportChanged: (uid: string) => void; setDomainsCallback: (xDomain: [number, number], yDomain: [number, number]) => void; projectionXDomain: [number, number]; // The domain of the brush diff --git a/src/tracks/heatmap/heatmap-plot.ts b/src/tracks/heatmap/heatmap-plot.ts new file mode 100644 index 000000000..033aceef8 --- /dev/null +++ b/src/tracks/heatmap/heatmap-plot.ts @@ -0,0 +1,93 @@ +import { HeatmapTiledPixiTrack } from '@higlass/tracks'; +import type { TiledPixiTrackContext, TiledPixiTrackOptions } from '@higlass/tracks'; +import * as PIXI from 'pixi.js'; +import { fakePubSub } from '@higlass/utils'; +import { scaleLinear } from 'd3-scale'; + +import { type D3ZoomEvent, zoom } from 'd3-zoom'; +import { select } from 'd3-selection'; +import { zoomWheelBehavior } from '../utils'; + +type HeatmapTrackContext = TiledPixiTrackContext & { + svgElement: HTMLElement; + onTrackOptionsChanged: () => void; + onMouseMoveZoom?: (event: any) => void; + isShowGlobalMousePosition?: () => boolean; // only used when options.showMousePosition is true + isValueScaleLocked: () => boolean; +}; + +type HeatmapTrackOptions = TiledPixiTrackOptions & { + dataTransform?: unknown; + extent?: string; + reverseYAxis?: boolean; + showTooltip?: boolean; + heatmapValueScaling?: string; + colorRange?: unknown; + showMousePosition?: boolean; + scaleStartPercent?: unknown; + scaleEndPercent?: unknown; + labelPosition?: unknown; + colorbarPosition?: string; + colorbarBackgroundColor?: string; + colorbarBackgroundOpacity?: number; + zeroValueColor?: string; + selectRowsAggregationMode?: string; + selectRowsAggregationWithRelativeHeight?: unknown; + selectRowsAggregationMethod?: unknown; +}; + +export class HeatmapTrack extends HeatmapTiledPixiTrack { + constructor(pixiContainer: PIXI.Container, overlayDiv: HTMLElement, options: HeatmapTrackOptions) { + const height = overlayDiv.clientHeight; + const width = overlayDiv.clientWidth; + // The colorbar svg element isn't quite working yet + const colorbarDiv = document.createElement('svg'); + overlayDiv.appendChild(colorbarDiv); + + // Setup the context object + const context: HeatmapTrackContext = { + scene: pixiContainer, + id: 'test', + dataConfig: { + server: 'http://higlass.io/api/v1', + tilesetUid: 'CQMd6V_cRw6iCI_-Unl3PQ' + // coordSystem: "hg19", + }, + animate: () => {}, + onValueScaleChanged: () => {}, + handleTilesetInfoReceived: (tilesetInfo: any) => {}, + onTrackOptionsChanged: () => {}, + pubSub: fakePubSub, + isValueScaleLocked: () => false, + svgElement: colorbarDiv + }; + + super(context, options); + + // Now we need to initialize all of the properties that would normally be set by HiGlassComponent + this.setDimensions([width, height]); + this.setPosition([0, 0]); + // Create some scales which span the whole genome + const refXScale = scaleLinear().domain([0, 3088269832]).range([0, width]); + const refYScale = scaleLinear().domain([0, 3088269832]).range([0, height]); + // Set the scales + this.zoomed(refXScale, refYScale, 1, 0, 0); + this.refScalesChanged(refXScale, refYScale); + + // Attach zoom behavior to the canvas. + const zoomBehavior = zoom() + .wheelDelta(zoomWheelBehavior) + .on('zoom', this.handleZoom.bind(this)); + select(overlayDiv).call(zoomBehavior); + } + + /** + * This function is called when the user zooms in or out. + */ + handleZoom(event: D3ZoomEvent): void { + const transform = event.transform; + const newXScale = transform.rescaleX(this._refXScale); + const newYScale = transform.rescaleY(this._refYScale); + this.zoomed(newXScale, newYScale, transform.k, transform.x + this.position[0], transform.y + this.position[1]); + } +} diff --git a/src/tracks/heatmap/index.ts b/src/tracks/heatmap/index.ts new file mode 100644 index 000000000..fc52c0d85 --- /dev/null +++ b/src/tracks/heatmap/index.ts @@ -0,0 +1 @@ +export { HeatmapTrack } from './heatmap-plot'; From 20720e338af8c128a4f84d9d48d70d2d6244d2a2 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Mon, 24 Jun 2024 15:43:40 -0400 Subject: [PATCH 068/139] feat: alias for heatmap --- tsconfig.json | 1 + vite.config.js | 1 + 2 files changed, 2 insertions(+) diff --git a/tsconfig.json b/tsconfig.json index b9c091d64..039b3b692 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,6 +33,7 @@ "@gosling-lang/brush-linear": ["src/tracks/brush-linear/index.ts"], "@gosling-lang/dummy-track": ["./src/tracks/dummy-track/index.ts"], "@gosling-lang/text-track": ["./src/tracks/text-track/index.ts"], + "@gosling-lang/heatmap": ["./src/tracks/heatmap/index.ts"], "@gosling-lang/interactors": ["./src/interactors/index.ts"], "@pixi-manager": ["./src/pixi-manager/index.ts"], "@data-fetchers": ["./src/data-fetchers/index.ts"], diff --git a/vite.config.js b/vite.config.js index b671a5fbf..ad68a963b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -74,6 +74,7 @@ const alias = { '@gosling-lang/brush-circular': path.resolve(__dirname, './src/tracks/brush-circular/index.ts'), '@gosling-lang/brush-linear': path.resolve(__dirname, './src/tracks/brush-linear/index.ts'), '@gosling-lang/dummy-track': path.resolve(__dirname, './src/tracks/dummy-track/index.ts'), + '@gosling-lang/heatmap': path.resolve(__dirname, './src/tracks/heatmap/index.ts'), '@gosling-lang/text-track': path.resolve(__dirname, './src/tracks/text-track/index.ts'), '@gosling-lang/interactors': path.resolve(__dirname, './src/interactors/index.ts'), '@pixi-manager': path.resolve(__dirname, './src/pixi-manager/index.ts'), From cd34ca840c16d5f792c316c3835c625bb34f8a94 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Mon, 24 Jun 2024 15:45:55 -0400 Subject: [PATCH 069/139] feat: heatmap example --- demo/App.tsx | 1785 ++++++++++++++---------------- demo/examples/heatmap-example.ts | 14 + demo/examples/index.ts | 1 + 3 files changed, 868 insertions(+), 932 deletions(-) create mode 100644 demo/examples/heatmap-example.ts diff --git a/demo/App.tsx b/demo/App.tsx index cae34b534..38657a7cf 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -7,7 +7,8 @@ import { addGoslingTrack, addAxisTrack, addLinearBrush, - addBigwig + addBigwig, + addHeatmap } from './examples'; import { compile } from '../src/compiler/compile'; import { getTheme } from '../src/core/utils/theme'; @@ -35,29 +36,30 @@ function App() { // addAxisTrack(pixiManager); // addLinearBrush(pixiManager); // addBigwig(pixiManager); + addHeatmap(pixiManager); - const callback = ( - hg: HiGlassSpec, - size, - gs: GoslingSpec, - tracksAndViews, - idTable, - trackInfos: TrackInfo[], - theme: Require - ) => { - console.warn(trackInfos); - console.warn(tracksAndViews); - console.warn(gs); - // showTrackInfoPositions(trackInfos, pixiManager); - const linkedEncodings = getLinkedEncodings(gs); - console.warn('linkedEncodings', linkedEncodings); - const trackDefs = createTrackDefs(trackInfos, theme); - console.warn('trackDefs', trackDefs); - renderTrackDefs(trackDefs, linkedEncodings, pixiManager); - }; + // const callback = ( + // hg: HiGlassSpec, + // size, + // gs: GoslingSpec, + // tracksAndViews, + // idTable, + // trackInfos: TrackInfo[], + // theme: Require + // ) => { + // console.warn(trackInfos); + // console.warn(tracksAndViews); + // console.warn(gs); + // // showTrackInfoPositions(trackInfos, pixiManager); + // const linkedEncodings = getLinkedEncodings(gs); + // console.warn('linkedEncodings', linkedEncodings); + // const trackDefs = createTrackDefs(trackInfos, theme); + // console.warn('trackDefs', trackDefs); + // renderTrackDefs(trackDefs, linkedEncodings, pixiManager); + // }; - // Compile the spec - compile(linkingTest, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); + // // Compile the spec + // compile(linkingTest, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); }, []); return ( @@ -1574,923 +1576,842 @@ const cancer_simplify = { }; const cancer = { - "title": "Breast Cancer Variant (Staaf et al. 2019)", - "subtitle": "Genetic characteristics of RAD51C- and PALB2-altered TNBCs", - "layout": "linear", - "arrangement": "vertical", - "centerRadius": 0.5, - "assembly": "hg19", - "spacing": 40, - "style": { - "outlineWidth": 1, - "outline": "lightgray", - "enableSmoothPath": false + title: 'Breast Cancer Variant (Staaf et al. 2019)', + subtitle: 'Genetic characteristics of RAD51C- and PALB2-altered TNBCs', + layout: 'linear', + arrangement: 'vertical', + centerRadius: 0.5, + assembly: 'hg19', + spacing: 40, + style: { + outlineWidth: 1, + outline: 'lightgray', + enableSmoothPath: false }, - "views": [ - { - "arrangement": "vertical", - "views": [ - { - "xOffset": 190, - "layout": "circular", - "spacing": 1, - "tracks": [ - { - "title": "Patient Overview (PD35930a)", - "alignment": "overlay", - "data": { - "url": "https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/UCSC.HG38.Human.CytoBandIdeogram.csv", - "type": "csv", - "chromosomeField": "Chromosome", - "genomicFields": ["chromStart", "chromEnd"] - }, - "tracks": [ - {"mark": "rect"}, - { - "mark": "brush", - "x": {"linkingId": "mid-scale"}, - "strokeWidth": {"value": 1.5}, - "stroke": {"value": "#0070DC"}, - "color": {"value": "#AFD8FF"}, - "opacity": {"value": 0.5} - } - ], - "color": { - "field": "Stain", - "type": "nominal", - "domain": [ - "gneg", - "gpos25", - "gpos50", - "gpos75", - "gpos100", - "gvar", - "acen" - ], - "range": [ - "white", - "lightgray", - "gray", - "gray", - "black", - "#7B9CC8", - "#DC4542" - ] - }, - "size": {"value": 18}, - "x": {"field": "chromStart", "type": "genomic"}, - "xe": {"field": "chromEnd", "type": "genomic"}, - "stroke": {"value": "gray"}, - "strokeWidth": {"value": 0.3}, - "width": 500, - "height": 100 - }, - { - "title": "Putative Driver", - "alignment": "overlay", - "data": { - "url": "https://s3.amazonaws.com/gosling-lang.org/data/SV/driver.df.scanb.complete.csv", - "type": "csv", - "chromosomeField": "Chr", - "genomicFields": ["ChrStart", "ChrEnd"] - }, - "dataTransform": [ - {"type": "filter", "field": "Sample", "oneOf": ["PD35930a"]} - ], - "tracks": [ - {"mark": "text"}, - {"mark": "triangleBottom", "size": {"value": 5}} - ], - "x": {"field": "ChrStart", "type": "genomic"}, - "xe": {"field": "ChrEnd", "type": "genomic"}, - "text": {"field": "Gene", "type": "nominal"}, - "color": {"value": "black"}, - "style": { - "textFontWeight": "normal", - "dx": -10, - "outlineWidth": 0 - }, - "width": 500, - "height": 40 - }, - { - "title": "LOH", - "style": {"background": "lightgray", "backgroundOpacity": 0.2}, - "alignment": "overlay", - "data": { - "url": "https://s3.amazonaws.com/gosling-lang.org/data/cancer/cnv.PD35930a.csv", - "headerNames": [ - "id", - "chr", - "start", - "end", - "total_cn_normal", - "minor_cp_normal", - "total_cn_tumor", - "minor_cn_tumor" - ], - "type": "csv", - "chromosomeField": "chr", - "genomicFields": ["start", "end"] - }, - "dataTransform": [ - {"type": "filter", "field": "minor_cn_tumor", "oneOf": ["0"]} - ], - "tracks": [ - {"mark": "rect"}, - { - "mark": "brush", - "x": {"linkingId": "mid-scale"}, - "strokeWidth": {"value": 1}, - "stroke": {"value": "#94C2EF"}, - "color": {"value": "#AFD8FF"} - } - ], - "x": {"field": "start", "type": "genomic"}, - "xe": {"field": "end", "type": "genomic"}, - "color": {"value": "#FB6A4B"}, - "width": 620, - "height": 40 - }, - { - "title": "Gain", - "style": {"background": "lightgray", "backgroundOpacity": 0.2}, - "alignment": "overlay", - "data": { - "url": "https://s3.amazonaws.com/gosling-lang.org/data/cancer/cnv.PD35930a.csv", - "headerNames": [ - "id", - "chr", - "start", - "end", - "total_cn_normal", - "minor_cp_normal", - "total_cn_tumor", - "minor_cn_tumor" - ], - "type": "csv", - "chromosomeField": "chr", - "genomicFields": ["start", "end"] - }, - "dataTransform": [ - { - "type": "filter", - "field": "total_cn_tumor", - "inRange": [4.5, 900] - } - ], - "tracks": [ - {"mark": "rect"}, - { - "mark": "brush", - "x": {"linkingId": "mid-scale"}, - "strokeWidth": {"value": 0} - } - ], - "x": {"field": "start", "type": "genomic"}, - "xe": {"field": "end", "type": "genomic"}, - "color": {"value": "#73C475"}, - "width": 500, - "height": 40 - }, - { - "title": "Structural Variant", - "data": { - "url": "https://s3.amazonaws.com/gosling-lang.org/data/cancer/rearrangement.PD35930a.csv", - "type": "csv", - "genomicFieldsToConvert": [ - { - "chromosomeField": "chr1", - "genomicFields": ["start1", "end1"] - }, - { - "chromosomeField": "chr2", - "genomicFields": ["start2", "end2"] - } - ] - }, - "mark": "withinLink", - "x": {"field": "start1", "type": "genomic"}, - "xe": {"field": "end2", "type": "genomic"}, - "color": { - "field": "svclass", - "type": "nominal", - "legend": true, - "domain": [ - "tandem-duplication", - "translocation", - "delection", - "inversion" - ], - "range": ["#569C4D", "#4C75A2", "#DA5456", "#EA8A2A"] - }, - "stroke": { - "field": "svclass", - "type": "nominal", - "domain": [ - "tandem-duplication", - "translocation", - "delection", - "inversion" - ], - "range": ["#569C4D", "#4C75A2", "#DA5456", "#EA8A2A"] - }, - "strokeWidth": {"value": 1}, - "opacity": {"value": 0.6}, - "style": {"legendTitle": "SV Class"}, - "width": 500, - "height": 80 - } - ] - }, - { - "linkingId": "mid-scale", - "xDomain": {"chromosome": "chr1"}, - "layout": "linear", - "tracks": [ - { - "style": { - "background": "#D7EBFF", - "outline": "#8DC1F2", - "outlineWidth": 5 - }, - "title": "Ideogram", - "alignment": "overlay", - "data": { - "url": "https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/UCSC.HG38.Human.CytoBandIdeogram.csv", - "type": "csv", - "chromosomeField": "Chromosome", - "genomicFields": ["chromStart", "chromEnd"] - }, - "tracks": [ - { - "mark": "rect", - "dataTransform": [ - { - "type": "filter", - "field": "Stain", - "oneOf": ["acen"], - "not": true - } - ] - }, - { - "mark": "triangleRight", - "dataTransform": [ - {"type": "filter", "field": "Stain", "oneOf": ["acen"]}, - {"type": "filter", "field": "Name", "include": "q"} - ] - }, - { - "mark": "triangleLeft", - "dataTransform": [ - {"type": "filter", "field": "Stain", "oneOf": ["acen"]}, - {"type": "filter", "field": "Name", "include": "p"} - ] - }, - { - "mark": "text", - "dataTransform": [ - { - "type": "filter", - "field": "Stain", - "oneOf": ["acen"], - "not": true - } - ], - "size": {"value": 12}, - "color": { - "field": "Stain", - "type": "nominal", - "domain": [ - "gneg", - "gpos25", - "gpos50", - "gpos75", - "gpos100", - "gvar" - ], - "range": [ - "black", - "black", - "black", - "black", - "white", - "black" - ] - }, - "visibility": [ - { - "operation": "less-than", - "measure": "width", - "threshold": "|xe-x|", - "transitionPadding": 10, - "target": "mark" - } + views: [ + { + arrangement: 'vertical', + views: [ + { + xOffset: 190, + layout: 'circular', + spacing: 1, + tracks: [ + { + title: 'Patient Overview (PD35930a)', + alignment: 'overlay', + data: { + url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/UCSC.HG38.Human.CytoBandIdeogram.csv', + type: 'csv', + chromosomeField: 'Chromosome', + genomicFields: ['chromStart', 'chromEnd'] + }, + tracks: [ + { mark: 'rect' }, + { + mark: 'brush', + x: { linkingId: 'mid-scale' }, + strokeWidth: { value: 1.5 }, + stroke: { value: '#0070DC' }, + color: { value: '#AFD8FF' }, + opacity: { value: 0.5 } + } + ], + color: { + field: 'Stain', + type: 'nominal', + domain: ['gneg', 'gpos25', 'gpos50', 'gpos75', 'gpos100', 'gvar', 'acen'], + range: ['white', 'lightgray', 'gray', 'gray', 'black', '#7B9CC8', '#DC4542'] + }, + size: { value: 18 }, + x: { field: 'chromStart', type: 'genomic' }, + xe: { field: 'chromEnd', type: 'genomic' }, + stroke: { value: 'gray' }, + strokeWidth: { value: 0.3 }, + width: 500, + height: 100 + }, + { + title: 'Putative Driver', + alignment: 'overlay', + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/SV/driver.df.scanb.complete.csv', + type: 'csv', + chromosomeField: 'Chr', + genomicFields: ['ChrStart', 'ChrEnd'] + }, + dataTransform: [{ type: 'filter', field: 'Sample', oneOf: ['PD35930a'] }], + tracks: [{ mark: 'text' }, { mark: 'triangleBottom', size: { value: 5 } }], + x: { field: 'ChrStart', type: 'genomic' }, + xe: { field: 'ChrEnd', type: 'genomic' }, + text: { field: 'Gene', type: 'nominal' }, + color: { value: 'black' }, + style: { + textFontWeight: 'normal', + dx: -10, + outlineWidth: 0 + }, + width: 500, + height: 40 + }, + { + title: 'LOH', + style: { background: 'lightgray', backgroundOpacity: 0.2 }, + alignment: 'overlay', + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/cancer/cnv.PD35930a.csv', + headerNames: [ + 'id', + 'chr', + 'start', + 'end', + 'total_cn_normal', + 'minor_cp_normal', + 'total_cn_tumor', + 'minor_cn_tumor' + ], + type: 'csv', + chromosomeField: 'chr', + genomicFields: ['start', 'end'] + }, + dataTransform: [{ type: 'filter', field: 'minor_cn_tumor', oneOf: ['0'] }], + tracks: [ + { mark: 'rect' }, + { + mark: 'brush', + x: { linkingId: 'mid-scale' }, + strokeWidth: { value: 1 }, + stroke: { value: '#94C2EF' }, + color: { value: '#AFD8FF' } + } + ], + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + color: { value: '#FB6A4B' }, + width: 620, + height: 40 + }, + { + title: 'Gain', + style: { background: 'lightgray', backgroundOpacity: 0.2 }, + alignment: 'overlay', + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/cancer/cnv.PD35930a.csv', + headerNames: [ + 'id', + 'chr', + 'start', + 'end', + 'total_cn_normal', + 'minor_cp_normal', + 'total_cn_tumor', + 'minor_cn_tumor' + ], + type: 'csv', + chromosomeField: 'chr', + genomicFields: ['start', 'end'] + }, + dataTransform: [ + { + type: 'filter', + field: 'total_cn_tumor', + inRange: [4.5, 900] + } + ], + tracks: [ + { mark: 'rect' }, + { + mark: 'brush', + x: { linkingId: 'mid-scale' }, + strokeWidth: { value: 0 } + } + ], + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + color: { value: '#73C475' }, + width: 500, + height: 40 + }, + { + title: 'Structural Variant', + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/cancer/rearrangement.PD35930a.csv', + type: 'csv', + genomicFieldsToConvert: [ + { + chromosomeField: 'chr1', + genomicFields: ['start1', 'end1'] + }, + { + chromosomeField: 'chr2', + genomicFields: ['start2', 'end2'] + } + ] + }, + mark: 'withinLink', + x: { field: 'start1', type: 'genomic' }, + xe: { field: 'end2', type: 'genomic' }, + color: { + field: 'svclass', + type: 'nominal', + legend: true, + domain: ['tandem-duplication', 'translocation', 'delection', 'inversion'], + range: ['#569C4D', '#4C75A2', '#DA5456', '#EA8A2A'] + }, + stroke: { + field: 'svclass', + type: 'nominal', + domain: ['tandem-duplication', 'translocation', 'delection', 'inversion'], + range: ['#569C4D', '#4C75A2', '#DA5456', '#EA8A2A'] + }, + strokeWidth: { value: 1 }, + opacity: { value: 0.6 }, + style: { legendTitle: 'SV Class' }, + width: 500, + height: 80 + } ] - } - ], - "color": { - "field": "Stain", - "type": "nominal", - "domain": [ - "gneg", - "gpos25", - "gpos50", - "gpos75", - "gpos100", - "gvar", - "acen" - ], - "range": [ - "white", - "lightgray", - "gray", - "gray", - "black", - "#7B9CC8", - "#DC4542" - ] - }, - "size": {"value": 18}, - "x": {"field": "chromStart", "type": "genomic"}, - "xe": {"field": "chromEnd", "type": "genomic"}, - "text": {"field": "Name", "type": "nominal"}, - "stroke": {"value": "gray"}, - "strokeWidth": {"value": 0.3}, - "width": 500, - "height": 30 - }, - { - "title": "Putative Driver", - "data": { - "url": "https://s3.amazonaws.com/gosling-lang.org/data/SV/driver.df.scanb.complete.csv", - "type": "csv", - "chromosomeField": "Chr", - "genomicFields": ["ChrStart", "ChrEnd"] - }, - "dataTransform": [ - {"type": "filter", "field": "Sample", "oneOf": ["PD35930a"]} - ], - "mark": "text", - "x": {"field": "ChrStart", "type": "genomic"}, - "xe": {"field": "ChrEnd", "type": "genomic"}, - "text": {"field": "Gene", "type": "nominal"}, - "color": {"value": "black"}, - "style": {"textFontWeight": "normal", "dx": -10}, - "width": 500, - "height": 20 - }, - { - "alignment": "overlay", - "title": "hg38 | Genes", - "data": { - "url": "https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation", - "type": "beddb", - "genomicFields": [ - {"index": 1, "name": "start"}, - {"index": 2, "name": "end"} - ], - "valueFields": [ - {"index": 5, "name": "strand", "type": "nominal"}, - {"index": 3, "name": "name", "type": "nominal"} - ], - "exonIntervalFields": [ - {"index": 12, "name": "start"}, - {"index": 13, "name": "end"} - ] - }, - "tracks": [ - { - "dataTransform": [ - {"type": "filter", "field": "type", "oneOf": ["gene"]}, - {"type": "filter", "field": "strand", "oneOf": ["+"]} - ], - "mark": "triangleRight", - "x": {"field": "end", "type": "genomic"}, - "size": {"value": 15} - }, - { - "dataTransform": [ - {"type": "filter", "field": "type", "oneOf": ["gene"]} - ], - "mark": "text", - "text": {"field": "name", "type": "nominal"}, - "x": {"field": "start", "type": "genomic"}, - "xe": {"field": "end", "type": "genomic"}, - "style": {"dy": -15, "outline": "black", "outlineWidth": 0} - }, - { - "dataTransform": [ - {"type": "filter", "field": "type", "oneOf": ["gene"]}, - {"type": "filter", "field": "strand", "oneOf": ["-"]} - ], - "mark": "triangleLeft", - "x": {"field": "start", "type": "genomic"}, - "size": {"value": 15}, - "style": { - "align": "right", - "outline": "black", - "outlineWidth": 0 - } - }, - { - "dataTransform": [ - {"type": "filter", "field": "type", "oneOf": ["exon"]} - ], - "mark": "rect", - "x": {"field": "start", "type": "genomic"}, - "size": {"value": 15}, - "xe": {"field": "end", "type": "genomic"} - }, - { - "dataTransform": [ - {"type": "filter", "field": "type", "oneOf": ["gene"]}, - {"type": "filter", "field": "strand", "oneOf": ["+"]} - ], - "mark": "rule", - "x": {"field": "start", "type": "genomic"}, - "strokeWidth": {"value": 2}, - "xe": {"field": "end", "type": "genomic"}, - "style": { - "linePattern": {"type": "triangleRight", "size": 3.5}, - "outline": "black", - "outlineWidth": 0 - } - }, - { - "dataTransform": [ - {"type": "filter", "field": "type", "oneOf": ["gene"]}, - {"type": "filter", "field": "strand", "oneOf": ["-"]} - ], - "mark": "rule", - "x": {"field": "start", "type": "genomic"}, - "strokeWidth": {"value": 2}, - "xe": {"field": "end", "type": "genomic"}, - "style": { - "linePattern": {"type": "triangleLeft", "size": 3.5}, - "outline": "black", - "outlineWidth": 0 - } - }, - { - "mark": "brush", - "x": {"linkingId": "detail-1"}, - "strokeWidth": {"value": 0}, - "color": {"value": "gray"}, - "opacity": {"value": 0.3} - }, - { - "mark": "brush", - "x": {"linkingId": "detail-2"}, - "strokeWidth": {"value": 0}, - "color": {"value": "gray"}, - "opacity": {"value": 0.3} - } - ], - "row": { - "field": "strand", - "type": "nominal", - "domain": ["+", "-"] - }, - "color": { - "field": "strand", - "type": "nominal", - "domain": ["+", "-"], - "range": ["#97A8B2", "#D4C6BA"] - }, - "visibility": [ - { - "operation": "less-than", - "measure": "width", - "threshold": "|xe-x|", - "transitionPadding": 10, - "target": "mark" - } - ], - "width": 400, - "height": 100 - }, - { - "title": "LOH", - "style": {"background": "lightgray", "backgroundOpacity": 0.2}, - "data": { - "url": "https://s3.amazonaws.com/gosling-lang.org/data/cancer/cnv.PD35930a.csv", - "headerNames": [ - "id", - "chr", - "start", - "end", - "total_cn_normal", - "minor_cp_normal", - "total_cn_tumor", - "minor_cn_tumor" - ], - "type": "csv", - "chromosomeField": "chr", - "genomicFields": ["start", "end"] - }, - "dataTransform": [ - {"type": "filter", "field": "minor_cn_tumor", "oneOf": ["0"]} - ], - "mark": "rect", - "x": {"field": "start", "type": "genomic"}, - "xe": {"field": "end", "type": "genomic"}, - "color": {"value": "#FB6A4B"}, - "width": 620, - "height": 20 - }, - { - "title": "Gain", - "style": {"background": "lightgray", "backgroundOpacity": 0.2}, - "data": { - "url": "https://s3.amazonaws.com/gosling-lang.org/data/cancer/cnv.PD35930a.csv", - "headerNames": [ - "id", - "chr", - "start", - "end", - "total_cn_normal", - "minor_cp_normal", - "total_cn_tumor", - "minor_cn_tumor" - ], - "type": "csv", - "chromosomeField": "chr", - "genomicFields": ["start", "end"] - }, - "dataTransform": [ - { - "type": "filter", - "field": "total_cn_tumor", - "inRange": [4.5, 900] - } - ], - "mark": "rect", - "x": {"field": "start", "type": "genomic"}, - "xe": {"field": "end", "type": "genomic"}, - "color": {"value": "#73C475"}, - "width": 500, - "height": 20 - }, - { - "title": "Structural Variant", - "data": { - "url": "https://s3.amazonaws.com/gosling-lang.org/data/cancer/rearrangement.PD35930a.csv", - "type": "csv", - "genomicFieldsToConvert": [ - { - "chromosomeField": "chr1", - "genomicFields": ["start1", "end1"] - }, - { - "chromosomeField": "chr2", - "genomicFields": ["start2", "end2"] - } - ] }, - "alignment": "overlay", - "tracks": [ - { - "mark": "withinLink", - "x": {"field": "start1", "type": "genomic"}, - "xe": {"field": "end2", "type": "genomic"} - }, - { - "mark": "point", - "x": {"field": "start1", "type": "genomic"}, - "y": {"value": 400} - }, - { - "mark": "point", - "x": {"field": "end2", "type": "genomic"}, - "y": {"value": 400} - } - ], - "color": { - "field": "svclass", - "type": "nominal", - "domain": [ - "tandem-duplication", - "translocation", - "delection", - "inversion" - ], - "range": ["#569C4D", "#4C75A2", "#DA5456", "#EA8A2A"], - "legend": true - }, - "stroke": { - "field": "svclass", - "type": "nominal", - "domain": [ - "tandem-duplication", - "translocation", - "delection", - "inversion" - ], - "range": ["#569C4D", "#4C75A2", "#DA5456", "#EA8A2A"] - }, - "strokeWidth": {"value": 1}, - "opacity": {"value": 0.6}, - "size": {"value": 4}, - "tooltip": [ - {"field": "start1", "type": "genomic"}, - {"field": "end2", "type": "genomic"}, - {"field": "svclass", "type": "nominal"} - ], - "style": {"legendTitle": "SV Class", "linkStyle": "elliptical"}, - "width": 1000, - "height": 200 - } - ] - } - ] - }, - { - "arrangement": "horizontal", - "spacing": 100, - "views": [ - { - "static": false, - "layout": "linear", - "centerRadius": 0.05, - "xDomain": {"chromosome": "chr1", "interval": [205000, 207000]}, - "spacing": 0.01, - "tracks": [ - { - "alignment": "overlay", - "title": "example_higlass.bam", - "data": { - "type": "bam", - "url": "https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam", - "indexUrl": "https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai", - "loadMates": true - }, - "mark": "bar", - "tracks": [ - { - "dataTransform": [ - { - "type": "coverage", - "startField": "start", - "endField": "end" - } - ], - "x": {"field": "start", "type": "genomic"}, - "xe": {"field": "end", "type": "genomic"}, - "y": { - "field": "coverage", - "type": "quantitative", - "axis": "right" - }, - "color": {"value": "#C6C6C6"} - } - ], - "style": {"outlineWidth": 0.5}, - "width": 450, - "height": 80 - }, - { - "alignment": "overlay", - "title": "example_higlass.bam", - "data": { - "type": "bam", - "url": "https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam", - "indexUrl": "https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai", - "loadMates": true, - "maxInsertSize": 300 - }, - "mark": "rect", - "tracks": [ - { - "dataTransform": [ - { - "type": "displace", - "method": "pile", - "boundingBox": { - "startField": "start", - "endField": "end", - "padding": 5, - "isPaddingBP": true + { + linkingId: 'mid-scale', + xDomain: { chromosome: 'chr1' }, + layout: 'linear', + tracks: [ + { + style: { + background: '#D7EBFF', + outline: '#8DC1F2', + outlineWidth: 5 + }, + title: 'Ideogram', + alignment: 'overlay', + data: { + url: 'https://raw.githubusercontent.com/sehilyi/gemini-datasets/master/data/UCSC.HG38.Human.CytoBandIdeogram.csv', + type: 'csv', + chromosomeField: 'Chromosome', + genomicFields: ['chromStart', 'chromEnd'] + }, + tracks: [ + { + mark: 'rect', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen'], + not: true + } + ] + }, + { + mark: 'triangleRight', + dataTransform: [ + { type: 'filter', field: 'Stain', oneOf: ['acen'] }, + { type: 'filter', field: 'Name', include: 'q' } + ] + }, + { + mark: 'triangleLeft', + dataTransform: [ + { type: 'filter', field: 'Stain', oneOf: ['acen'] }, + { type: 'filter', field: 'Name', include: 'p' } + ] + }, + { + mark: 'text', + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen'], + not: true + } + ], + size: { value: 12 }, + color: { + field: 'Stain', + type: 'nominal', + domain: ['gneg', 'gpos25', 'gpos50', 'gpos75', 'gpos100', 'gvar'], + range: ['black', 'black', 'black', 'black', 'white', 'black'] + }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ] + } + ], + color: { + field: 'Stain', + type: 'nominal', + domain: ['gneg', 'gpos25', 'gpos50', 'gpos75', 'gpos100', 'gvar', 'acen'], + range: ['white', 'lightgray', 'gray', 'gray', 'black', '#7B9CC8', '#DC4542'] + }, + size: { value: 18 }, + x: { field: 'chromStart', type: 'genomic' }, + xe: { field: 'chromEnd', type: 'genomic' }, + text: { field: 'Name', type: 'nominal' }, + stroke: { value: 'gray' }, + strokeWidth: { value: 0.3 }, + width: 500, + height: 30 }, - "newField": "pileup-row" - } - ], - "x": {"field": "start", "type": "genomic"}, - "xe": {"field": "end", "type": "genomic"}, - "color": { - "field": "svType", - "type": "nominal", - "legend": true, - "domain": [ - "normal read", - "deletion (+-)", - "inversion (++)", - "inversion (--)", - "duplication (-+)", - "more than two mates", - "mates not found within chromosome", - "clipping" - ], - "range": [ - "#C8C8C8", - "#E79F00", - "#029F73", - "#0072B2", - "#CB7AA7", - "#57B4E9", - "#D61E2E", - "#414141" - ] - } - }, - { - "dataTransform": [ - { - "type": "displace", - "method": "pile", - "boundingBox": { - "startField": "start", - "endField": "end", - "padding": 5, - "isPaddingBP": true + { + title: 'Putative Driver', + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/SV/driver.df.scanb.complete.csv', + type: 'csv', + chromosomeField: 'Chr', + genomicFields: ['ChrStart', 'ChrEnd'] + }, + dataTransform: [{ type: 'filter', field: 'Sample', oneOf: ['PD35930a'] }], + mark: 'text', + x: { field: 'ChrStart', type: 'genomic' }, + xe: { field: 'ChrEnd', type: 'genomic' }, + text: { field: 'Gene', type: 'nominal' }, + color: { value: 'black' }, + style: { textFontWeight: 'normal', dx: -10 }, + width: 500, + height: 20 }, - "newField": "pileup-row" - }, - { - "type": "subjson", - "field": "substitutions", - "genomicField": "pos", - "baseGenomicField": "start", - "genomicLengthField": "length" - }, - {"type": "filter", "field": "type", "oneOf": ["S", "H"]} - ], - "x": {"field": "pos_start", "type": "genomic"}, - "xe": {"field": "pos_end", "type": "genomic"}, - "color": {"value": "#414141"} - } - ], - "tooltip": [ - {"field": "start", "type": "genomic"}, - {"field": "end", "type": "genomic"}, - {"field": "insertSize", "type": "quantitative"}, - {"field": "svType", "type": "nominal"}, - {"field": "strand", "type": "nominal"}, - {"field": "numMates", "type": "quantitative"}, - {"field": "mateIds", "type": "nominal"} - ], - "row": {"field": "pileup-row", "type": "nominal", "padding": 0.2}, - "style": { - "outlineWidth": 0.5, - "legendTitle": "Insert Size = 300bp" - }, - "width": 450, - "height": 310 - } - ] - }, - { - "static": false, - "layout": "linear", - "centerRadius": 0.05, - "xDomain": {"chromosome": "chr1", "interval": [490000, 496000]}, - "spacing": 0.01, - "tracks": [ - { - "alignment": "overlay", - "title": "example_higlass.bam", - "data": { - "type": "bam", - "url": "https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam", - "indexUrl": "https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai", - "loadMates": true - }, - "mark": "bar", - "tracks": [ - { - "dataTransform": [ - { - "type": "coverage", - "startField": "start", - "endField": "end" - } - ], - "x": {"field": "start", "type": "genomic"}, - "xe": {"field": "end", "type": "genomic"}, - "y": { - "field": "coverage", - "type": "quantitative", - "axis": "right" - }, - "color": {"value": "#C6C6C6"} - } - ], - "style": {"outlineWidth": 0.5}, - "width": 450, - "height": 80 - }, - { - "alignment": "overlay", - "title": "example_higlass.bam", - "data": { - "type": "bam", - "url": "https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam", - "indexUrl": "https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai", - "loadMates": true, - "maxInsertSize": 300 - }, - "mark": "rect", - "tracks": [ - { - "dataTransform": [ - { - "type": "displace", - "method": "pile", - "boundingBox": { - "startField": "start", - "endField": "end", - "padding": 5, - "isPaddingBP": true + { + alignment: 'overlay', + title: 'hg38 | Genes', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ], + exonIntervalFields: [ + { index: 12, name: 'start' }, + { index: 13, name: 'end' } + ] + }, + tracks: [ + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['+'] } + ], + mark: 'triangleRight', + x: { field: 'end', type: 'genomic' }, + size: { value: 15 } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['gene'] }], + mark: 'text', + text: { field: 'name', type: 'nominal' }, + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + style: { dy: -15, outline: 'black', outlineWidth: 0 } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['-'] } + ], + mark: 'triangleLeft', + x: { field: 'start', type: 'genomic' }, + size: { value: 15 }, + style: { + align: 'right', + outline: 'black', + outlineWidth: 0 + } + }, + { + dataTransform: [{ type: 'filter', field: 'type', oneOf: ['exon'] }], + mark: 'rect', + x: { field: 'start', type: 'genomic' }, + size: { value: 15 }, + xe: { field: 'end', type: 'genomic' } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['+'] } + ], + mark: 'rule', + x: { field: 'start', type: 'genomic' }, + strokeWidth: { value: 2 }, + xe: { field: 'end', type: 'genomic' }, + style: { + linePattern: { type: 'triangleRight', size: 3.5 }, + outline: 'black', + outlineWidth: 0 + } + }, + { + dataTransform: [ + { type: 'filter', field: 'type', oneOf: ['gene'] }, + { type: 'filter', field: 'strand', oneOf: ['-'] } + ], + mark: 'rule', + x: { field: 'start', type: 'genomic' }, + strokeWidth: { value: 2 }, + xe: { field: 'end', type: 'genomic' }, + style: { + linePattern: { type: 'triangleLeft', size: 3.5 }, + outline: 'black', + outlineWidth: 0 + } + }, + { + mark: 'brush', + x: { linkingId: 'detail-1' }, + strokeWidth: { value: 0 }, + color: { value: 'gray' }, + opacity: { value: 0.3 } + }, + { + mark: 'brush', + x: { linkingId: 'detail-2' }, + strokeWidth: { value: 0 }, + color: { value: 'gray' }, + opacity: { value: 0.3 } + } + ], + row: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'] + }, + color: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'], + range: ['#97A8B2', '#D4C6BA'] + }, + visibility: [ + { + operation: 'less-than', + measure: 'width', + threshold: '|xe-x|', + transitionPadding: 10, + target: 'mark' + } + ], + width: 400, + height: 100 }, - "newField": "pileup-row" - } - ], - "x": {"field": "start", "type": "genomic"}, - "xe": {"field": "end", "type": "genomic"}, - "color": { - "field": "svType", - "type": "nominal", - "legend": true, - "domain": [ - "normal read", - "deletion (+-)", - "inversion (++)", - "inversion (--)", - "duplication (-+)", - "more than two mates", - "mates not found within chromosome", - "clipping" - ], - "range": [ - "#C8C8C8", - "#E79F00", - "#029F73", - "#0072B2", - "#CB7AA7", - "#57B4E9", - "#D61E2E", - "#414141" - ] - } - }, - { - "dataTransform": [ - { - "type": "displace", - "method": "pile", - "boundingBox": { - "startField": "start", - "endField": "end", - "padding": 5, - "isPaddingBP": true + { + title: 'LOH', + style: { background: 'lightgray', backgroundOpacity: 0.2 }, + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/cancer/cnv.PD35930a.csv', + headerNames: [ + 'id', + 'chr', + 'start', + 'end', + 'total_cn_normal', + 'minor_cp_normal', + 'total_cn_tumor', + 'minor_cn_tumor' + ], + type: 'csv', + chromosomeField: 'chr', + genomicFields: ['start', 'end'] + }, + dataTransform: [{ type: 'filter', field: 'minor_cn_tumor', oneOf: ['0'] }], + mark: 'rect', + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + color: { value: '#FB6A4B' }, + width: 620, + height: 20 }, - "newField": "pileup-row" - }, - { - "type": "subjson", - "field": "substitutions", - "genomicField": "pos", - "baseGenomicField": "start", - "genomicLengthField": "length" - }, - {"type": "filter", "field": "type", "oneOf": ["S", "H"]} - ], - "x": {"field": "pos_start", "type": "genomic"}, - "xe": {"field": "pos_end", "type": "genomic"}, - "color": {"value": "#414141"} - } - ], - "tooltip": [ - {"field": "start", "type": "genomic"}, - {"field": "end", "type": "genomic"}, - {"field": "insertSize", "type": "quantitative"}, - {"field": "svType", "type": "nominal"}, - {"field": "strand", "type": "nominal"}, - {"field": "numMates", "type": "quantitative"}, - {"field": "mateIds", "type": "nominal"} - ], - "row": {"field": "pileup-row", "type": "nominal", "padding": 0.2}, - "style": { - "outlineWidth": 0.5, - "legendTitle": "Insert Size = 300bp" + { + title: 'Gain', + style: { background: 'lightgray', backgroundOpacity: 0.2 }, + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/cancer/cnv.PD35930a.csv', + headerNames: [ + 'id', + 'chr', + 'start', + 'end', + 'total_cn_normal', + 'minor_cp_normal', + 'total_cn_tumor', + 'minor_cn_tumor' + ], + type: 'csv', + chromosomeField: 'chr', + genomicFields: ['start', 'end'] + }, + dataTransform: [ + { + type: 'filter', + field: 'total_cn_tumor', + inRange: [4.5, 900] + } + ], + mark: 'rect', + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + color: { value: '#73C475' }, + width: 500, + height: 20 + }, + { + title: 'Structural Variant', + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/cancer/rearrangement.PD35930a.csv', + type: 'csv', + genomicFieldsToConvert: [ + { + chromosomeField: 'chr1', + genomicFields: ['start1', 'end1'] + }, + { + chromosomeField: 'chr2', + genomicFields: ['start2', 'end2'] + } + ] + }, + alignment: 'overlay', + tracks: [ + { + mark: 'withinLink', + x: { field: 'start1', type: 'genomic' }, + xe: { field: 'end2', type: 'genomic' } + }, + { + mark: 'point', + x: { field: 'start1', type: 'genomic' }, + y: { value: 400 } + }, + { + mark: 'point', + x: { field: 'end2', type: 'genomic' }, + y: { value: 400 } + } + ], + color: { + field: 'svclass', + type: 'nominal', + domain: ['tandem-duplication', 'translocation', 'delection', 'inversion'], + range: ['#569C4D', '#4C75A2', '#DA5456', '#EA8A2A'], + legend: true + }, + stroke: { + field: 'svclass', + type: 'nominal', + domain: ['tandem-duplication', 'translocation', 'delection', 'inversion'], + range: ['#569C4D', '#4C75A2', '#DA5456', '#EA8A2A'] + }, + strokeWidth: { value: 1 }, + opacity: { value: 0.6 }, + size: { value: 4 }, + tooltip: [ + { field: 'start1', type: 'genomic' }, + { field: 'end2', type: 'genomic' }, + { field: 'svclass', type: 'nominal' } + ], + style: { legendTitle: 'SV Class', linkStyle: 'elliptical' }, + width: 1000, + height: 200 + } + ] + } + ] + }, + { + arrangement: 'horizontal', + spacing: 100, + views: [ + { + static: false, + layout: 'linear', + centerRadius: 0.05, + xDomain: { chromosome: 'chr1', interval: [205000, 207000] }, + spacing: 0.01, + tracks: [ + { + alignment: 'overlay', + title: 'example_higlass.bam', + data: { + type: 'bam', + url: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam', + indexUrl: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai', + loadMates: true + }, + mark: 'bar', + tracks: [ + { + dataTransform: [ + { + type: 'coverage', + startField: 'start', + endField: 'end' + } + ], + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + y: { + field: 'coverage', + type: 'quantitative', + axis: 'right' + }, + color: { value: '#C6C6C6' } + } + ], + style: { outlineWidth: 0.5 }, + width: 450, + height: 80 + }, + { + alignment: 'overlay', + title: 'example_higlass.bam', + data: { + type: 'bam', + url: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam', + indexUrl: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai', + loadMates: true, + maxInsertSize: 300 + }, + mark: 'rect', + tracks: [ + { + dataTransform: [ + { + type: 'displace', + method: 'pile', + boundingBox: { + startField: 'start', + endField: 'end', + padding: 5, + isPaddingBP: true + }, + newField: 'pileup-row' + } + ], + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + color: { + field: 'svType', + type: 'nominal', + legend: true, + domain: [ + 'normal read', + 'deletion (+-)', + 'inversion (++)', + 'inversion (--)', + 'duplication (-+)', + 'more than two mates', + 'mates not found within chromosome', + 'clipping' + ], + range: [ + '#C8C8C8', + '#E79F00', + '#029F73', + '#0072B2', + '#CB7AA7', + '#57B4E9', + '#D61E2E', + '#414141' + ] + } + }, + { + dataTransform: [ + { + type: 'displace', + method: 'pile', + boundingBox: { + startField: 'start', + endField: 'end', + padding: 5, + isPaddingBP: true + }, + newField: 'pileup-row' + }, + { + type: 'subjson', + field: 'substitutions', + genomicField: 'pos', + baseGenomicField: 'start', + genomicLengthField: 'length' + }, + { type: 'filter', field: 'type', oneOf: ['S', 'H'] } + ], + x: { field: 'pos_start', type: 'genomic' }, + xe: { field: 'pos_end', type: 'genomic' }, + color: { value: '#414141' } + } + ], + tooltip: [ + { field: 'start', type: 'genomic' }, + { field: 'end', type: 'genomic' }, + { field: 'insertSize', type: 'quantitative' }, + { field: 'svType', type: 'nominal' }, + { field: 'strand', type: 'nominal' }, + { field: 'numMates', type: 'quantitative' }, + { field: 'mateIds', type: 'nominal' } + ], + row: { field: 'pileup-row', type: 'nominal', padding: 0.2 }, + style: { + outlineWidth: 0.5, + legendTitle: 'Insert Size = 300bp' + }, + width: 450, + height: 310 + } + ] }, - "width": 450, - "height": 310 - } + { + static: false, + layout: 'linear', + centerRadius: 0.05, + xDomain: { chromosome: 'chr1', interval: [490000, 496000] }, + spacing: 0.01, + tracks: [ + { + alignment: 'overlay', + title: 'example_higlass.bam', + data: { + type: 'bam', + url: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam', + indexUrl: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai', + loadMates: true + }, + mark: 'bar', + tracks: [ + { + dataTransform: [ + { + type: 'coverage', + startField: 'start', + endField: 'end' + } + ], + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + y: { + field: 'coverage', + type: 'quantitative', + axis: 'right' + }, + color: { value: '#C6C6C6' } + } + ], + style: { outlineWidth: 0.5 }, + width: 450, + height: 80 + }, + { + alignment: 'overlay', + title: 'example_higlass.bam', + data: { + type: 'bam', + url: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam', + indexUrl: 'https://s3.amazonaws.com/gosling-lang.org/data/example_higlass.bam.bai', + loadMates: true, + maxInsertSize: 300 + }, + mark: 'rect', + tracks: [ + { + dataTransform: [ + { + type: 'displace', + method: 'pile', + boundingBox: { + startField: 'start', + endField: 'end', + padding: 5, + isPaddingBP: true + }, + newField: 'pileup-row' + } + ], + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + color: { + field: 'svType', + type: 'nominal', + legend: true, + domain: [ + 'normal read', + 'deletion (+-)', + 'inversion (++)', + 'inversion (--)', + 'duplication (-+)', + 'more than two mates', + 'mates not found within chromosome', + 'clipping' + ], + range: [ + '#C8C8C8', + '#E79F00', + '#029F73', + '#0072B2', + '#CB7AA7', + '#57B4E9', + '#D61E2E', + '#414141' + ] + } + }, + { + dataTransform: [ + { + type: 'displace', + method: 'pile', + boundingBox: { + startField: 'start', + endField: 'end', + padding: 5, + isPaddingBP: true + }, + newField: 'pileup-row' + }, + { + type: 'subjson', + field: 'substitutions', + genomicField: 'pos', + baseGenomicField: 'start', + genomicLengthField: 'length' + }, + { type: 'filter', field: 'type', oneOf: ['S', 'H'] } + ], + x: { field: 'pos_start', type: 'genomic' }, + xe: { field: 'pos_end', type: 'genomic' }, + color: { value: '#414141' } + } + ], + tooltip: [ + { field: 'start', type: 'genomic' }, + { field: 'end', type: 'genomic' }, + { field: 'insertSize', type: 'quantitative' }, + { field: 'svType', type: 'nominal' }, + { field: 'strand', type: 'nominal' }, + { field: 'numMates', type: 'quantitative' }, + { field: 'mateIds', type: 'nominal' } + ], + row: { field: 'pileup-row', type: 'nominal', padding: 0.2 }, + style: { + outlineWidth: 0.5, + legendTitle: 'Insert Size = 300bp' + }, + width: 450, + height: 310 + } + ] + } ] - } - ] - } + } ] - } \ No newline at end of file +}; diff --git a/demo/examples/heatmap-example.ts b/demo/examples/heatmap-example.ts new file mode 100644 index 000000000..87c8ced7f --- /dev/null +++ b/demo/examples/heatmap-example.ts @@ -0,0 +1,14 @@ +import { PixiManager } from '@pixi-manager'; +import { HeatmapTrack } from '@gosling-lang/heatmap'; + +export function addHeatmap(pixiManager: PixiManager) { + // Let's add a heatmap + const heatmapPosition = { x: 500, y: 30, width: 400, height: 400 }; + const { pixiContainer: heatmapContainer, overlayDiv: heatmapOverlayDiv } = + pixiManager.makeContainer(heatmapPosition); + new HeatmapTrack(heatmapContainer, heatmapOverlayDiv, { + trackBorderWidth: 1, + trackBorderColor: 'black', + colorbarPosition: 'topRight' + }); +} diff --git a/demo/examples/index.ts b/demo/examples/index.ts index 83873ac55..b232847de 100644 --- a/demo/examples/index.ts +++ b/demo/examples/index.ts @@ -5,3 +5,4 @@ export { addGoslingTrack } from './gosling-track-example'; export { addAxisTrack } from './axis-track-example'; export { addLinearBrush } from './brush-linear-example'; export { addBigwig } from './bigwig-data-example'; +export { addHeatmap } from './heatmap-example'; From db1fd6b2f8902c0cbe00d9523dabbcc6e6689077 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Mon, 24 Jun 2024 16:03:11 -0400 Subject: [PATCH 070/139] refactor: heatmap plot inputs --- demo/examples/heatmap-example.ts | 25 ++++++++++++++++++------- src/tracks/heatmap/heatmap-plot.ts | 30 ++++++++++++++++++++---------- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/demo/examples/heatmap-example.ts b/demo/examples/heatmap-example.ts index 87c8ced7f..96bf8d094 100644 --- a/demo/examples/heatmap-example.ts +++ b/demo/examples/heatmap-example.ts @@ -1,14 +1,25 @@ import { PixiManager } from '@pixi-manager'; import { HeatmapTrack } from '@gosling-lang/heatmap'; +import { DataFetcher } from '@higlass/datafetcher'; +import { fakePubSub } from '@higlass/utils'; export function addHeatmap(pixiManager: PixiManager) { // Let's add a heatmap const heatmapPosition = { x: 500, y: 30, width: 400, height: 400 }; - const { pixiContainer: heatmapContainer, overlayDiv: heatmapOverlayDiv } = - pixiManager.makeContainer(heatmapPosition); - new HeatmapTrack(heatmapContainer, heatmapOverlayDiv, { - trackBorderWidth: 1, - trackBorderColor: 'black', - colorbarPosition: 'topRight' - }); + const dataFetcher = new DataFetcher( + { + server: 'http://higlass.io/api/v1', + tilesetUid: 'CQMd6V_cRw6iCI_-Unl3PQ' + }, + fakePubSub + ); + new HeatmapTrack( + { + trackBorderWidth: 1, + trackBorderColor: 'black', + colorbarPosition: 'topRight' + }, + dataFetcher, + pixiManager.makeContainer(heatmapPosition) + ); } diff --git a/src/tracks/heatmap/heatmap-plot.ts b/src/tracks/heatmap/heatmap-plot.ts index 033aceef8..df4beb313 100644 --- a/src/tracks/heatmap/heatmap-plot.ts +++ b/src/tracks/heatmap/heatmap-plot.ts @@ -7,6 +7,7 @@ import { scaleLinear } from 'd3-scale'; import { type D3ZoomEvent, zoom } from 'd3-zoom'; import { select } from 'd3-selection'; import { zoomWheelBehavior } from '../utils'; +import { DataFetcher } from '@higlass/datafetcher'; type HeatmapTrackContext = TiledPixiTrackContext & { svgElement: HTMLElement; @@ -37,29 +38,38 @@ type HeatmapTrackOptions = TiledPixiTrackOptions & { }; export class HeatmapTrack extends HeatmapTiledPixiTrack { - constructor(pixiContainer: PIXI.Container, overlayDiv: HTMLElement, options: HeatmapTrackOptions) { + constructor( + options: HeatmapTrackOptions, + dataFetcher: DataFetcher, + containers: { + pixiContainer: PIXI.Container; + overlayDiv: HTMLElement; + } + ) { + const { pixiContainer, overlayDiv } = containers; const height = overlayDiv.clientHeight; const width = overlayDiv.clientWidth; - // The colorbar svg element isn't quite working yet - const colorbarDiv = document.createElement('svg'); - overlayDiv.appendChild(colorbarDiv); + // If there is already an svg element, use it. Otherwise, create a new one + const existingSvgElement = overlayDiv.querySelector('svg'); + const svgElement = existingSvgElement || document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + if (!existingSvgElement) { + svgElement.style.width = `${width}px`; + svgElement.style.height = `${height}px`; + overlayDiv.appendChild(svgElement); + } // Setup the context object const context: HeatmapTrackContext = { scene: pixiContainer, id: 'test', - dataConfig: { - server: 'http://higlass.io/api/v1', - tilesetUid: 'CQMd6V_cRw6iCI_-Unl3PQ' - // coordSystem: "hg19", - }, + dataFetcher, animate: () => {}, onValueScaleChanged: () => {}, handleTilesetInfoReceived: (tilesetInfo: any) => {}, onTrackOptionsChanged: () => {}, pubSub: fakePubSub, isValueScaleLocked: () => false, - svgElement: colorbarDiv + svgElement: svgElement }; super(context, options); From b662ed0e5d09be033f8320a8c25e6443eae5cf1c Mon Sep 17 00:00:00 2001 From: etowahadams Date: Mon, 24 Jun 2024 16:13:36 -0400 Subject: [PATCH 071/139] refactor heatmap internals --- src/tracks/heatmap/heatmap-plot.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/tracks/heatmap/heatmap-plot.ts b/src/tracks/heatmap/heatmap-plot.ts index df4beb313..8843fbd1f 100644 --- a/src/tracks/heatmap/heatmap-plot.ts +++ b/src/tracks/heatmap/heatmap-plot.ts @@ -8,6 +8,7 @@ import { type D3ZoomEvent, zoom } from 'd3-zoom'; import { select } from 'd3-selection'; import { zoomWheelBehavior } from '../utils'; import { DataFetcher } from '@higlass/datafetcher'; +import { signal, type Signal } from '@preact/signals-core'; type HeatmapTrackContext = TiledPixiTrackContext & { svgElement: HTMLElement; @@ -38,13 +39,18 @@ type HeatmapTrackOptions = TiledPixiTrackOptions & { }; export class HeatmapTrack extends HeatmapTiledPixiTrack { + xDomain: Signal<[number, number]>; // This has to be a signal because it will potentially be updated by interactors + domOverlay: HTMLElement; + constructor( options: HeatmapTrackOptions, dataFetcher: DataFetcher, containers: { pixiContainer: PIXI.Container; overlayDiv: HTMLElement; - } + }, + xDomain = signal<[number, number]>([0, 3088269832]), + yDomain = signal<[number, number]>([0, 3088269832]) ) { const { pixiContainer, overlayDiv } = containers; const height = overlayDiv.clientHeight; @@ -73,13 +79,15 @@ export class HeatmapTrack extends HeatmapTiledPixiTrack { }; super(context, options); + this.xDomain = xDomain; + this.domOverlay = overlayDiv; // Now we need to initialize all of the properties that would normally be set by HiGlassComponent this.setDimensions([width, height]); this.setPosition([0, 0]); // Create some scales which span the whole genome - const refXScale = scaleLinear().domain([0, 3088269832]).range([0, width]); - const refYScale = scaleLinear().domain([0, 3088269832]).range([0, height]); + const refXScale = scaleLinear().domain(xDomain.value).range([0, width]); + const refYScale = scaleLinear().domain(yDomain.value).range([0, height]); // Set the scales this.zoomed(refXScale, refYScale, 1, 0, 0); this.refScalesChanged(refXScale, refYScale); From e4dafe351bdce83a39da62896290fdcd5fef7e3b Mon Sep 17 00:00:00 2001 From: etowahadams Date: Mon, 24 Jun 2024 16:48:04 -0400 Subject: [PATCH 072/139] feat: introduce signals --- demo/examples/heatmap-example.ts | 4 +++- src/tracks/heatmap/heatmap-plot.ts | 20 +++++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/demo/examples/heatmap-example.ts b/demo/examples/heatmap-example.ts index 96bf8d094..5ff616081 100644 --- a/demo/examples/heatmap-example.ts +++ b/demo/examples/heatmap-example.ts @@ -2,6 +2,7 @@ import { PixiManager } from '@pixi-manager'; import { HeatmapTrack } from '@gosling-lang/heatmap'; import { DataFetcher } from '@higlass/datafetcher'; import { fakePubSub } from '@higlass/utils'; +import { signal } from '@preact/signals-core'; export function addHeatmap(pixiManager: PixiManager) { // Let's add a heatmap @@ -13,6 +14,7 @@ export function addHeatmap(pixiManager: PixiManager) { }, fakePubSub ); + const xDomain = signal<[number, number]>([0, 3088269832]); new HeatmapTrack( { trackBorderWidth: 1, @@ -21,5 +23,5 @@ export function addHeatmap(pixiManager: PixiManager) { }, dataFetcher, pixiManager.makeContainer(heatmapPosition) - ); + ) } diff --git a/src/tracks/heatmap/heatmap-plot.ts b/src/tracks/heatmap/heatmap-plot.ts index 8843fbd1f..e060232b9 100644 --- a/src/tracks/heatmap/heatmap-plot.ts +++ b/src/tracks/heatmap/heatmap-plot.ts @@ -4,11 +4,11 @@ import * as PIXI from 'pixi.js'; import { fakePubSub } from '@higlass/utils'; import { scaleLinear } from 'd3-scale'; -import { type D3ZoomEvent, zoom } from 'd3-zoom'; +import { type D3ZoomEvent, zoom, ZoomTransform } from 'd3-zoom'; import { select } from 'd3-selection'; import { zoomWheelBehavior } from '../utils'; import { DataFetcher } from '@higlass/datafetcher'; -import { signal, type Signal } from '@preact/signals-core'; +import { signal, type Signal, effect } from '@preact/signals-core'; type HeatmapTrackContext = TiledPixiTrackContext & { svgElement: HTMLElement; @@ -40,7 +40,9 @@ type HeatmapTrackOptions = TiledPixiTrackOptions & { export class HeatmapTrack extends HeatmapTiledPixiTrack { xDomain: Signal<[number, number]>; // This has to be a signal because it will potentially be updated by interactors + yDomain: Signal<[number, number]>; domOverlay: HTMLElement; + d3ZoomTransform: ZoomTransform; constructor( options: HeatmapTrackOptions, @@ -80,6 +82,8 @@ export class HeatmapTrack extends HeatmapTiledPixiTrack { super(context, options); this.xDomain = xDomain; + this.yDomain = yDomain; + this.d3ZoomTransform = new ZoomTransform(1, 0, 0); this.domOverlay = overlayDiv; // Now we need to initialize all of the properties that would normally be set by HiGlassComponent @@ -97,6 +101,12 @@ export class HeatmapTrack extends HeatmapTiledPixiTrack { .wheelDelta(zoomWheelBehavior) .on('zoom', this.handleZoom.bind(this)); select(overlayDiv).call(zoomBehavior); + + effect(() => { + const newXScale = scaleLinear().domain(this.xDomain.value).range([0, width]); + const newYScale = scaleLinear().domain(this.yDomain.value).range([0, height]); + this.zoomed(newXScale, newYScale, this.d3ZoomTransform.k, this.d3ZoomTransform.x, this.d3ZoomTransform.y); + }); } /** @@ -104,8 +114,8 @@ export class HeatmapTrack extends HeatmapTiledPixiTrack { */ handleZoom(event: D3ZoomEvent): void { const transform = event.transform; - const newXScale = transform.rescaleX(this._refXScale); - const newYScale = transform.rescaleY(this._refYScale); - this.zoomed(newXScale, newYScale, transform.k, transform.x + this.position[0], transform.y + this.position[1]); + this.d3ZoomTransform = transform; + this.xDomain.value = transform.rescaleX(this._refXScale).domain() as [number, number]; + this.yDomain.value = transform.rescaleY(this._refYScale).domain() as [number, number]; } } From 8bde1957949f70e0ae3911731738178e150f2259 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Mon, 24 Jun 2024 16:49:49 -0400 Subject: [PATCH 073/139] copy over zoomPan interactor --- src/interactors/index.ts | 1 + src/interactors/panZoomXY.ts | 49 ++++++++++++++++++++++++++++++ src/tracks/heatmap/heatmap-plot.ts | 5 +++ 3 files changed, 55 insertions(+) create mode 100644 src/interactors/panZoomXY.ts diff --git a/src/interactors/index.ts b/src/interactors/index.ts index f8f5260e4..0bbcfaa91 100644 --- a/src/interactors/index.ts +++ b/src/interactors/index.ts @@ -1,2 +1,3 @@ export { cursor } from './cursor'; export { panZoom } from './panZoom'; +export { panZoomXY } from './panZoomXY'; diff --git a/src/interactors/panZoomXY.ts b/src/interactors/panZoomXY.ts new file mode 100644 index 000000000..066c2ab85 --- /dev/null +++ b/src/interactors/panZoomXY.ts @@ -0,0 +1,49 @@ +import { type Signal, effect } from '@preact/signals-core'; +import { scaleLinear } from 'd3-scale'; +import { ZoomTransform, type D3ZoomEvent, zoom } from 'd3-zoom'; +import { select } from 'd3-selection'; +import { zoomWheelBehavior, type Plot } from '../tracks/utils'; + +/** + * This interactor allows the user to pan and zoom the plot + */ + +export function panZoomXY(plot: Plot, xDomain: Signal<[number, number]>) { + plot.xDomain = xDomain; // Update the xDomain with the signal + const baseScale = scaleLinear().range([0, plot.domOverlay.clientWidth]); + // This will store the xDomain when the user starts zooming + const zoomStartScale = scaleLinear(); + // This function will be called every time the user zooms + const zoomed = (event: D3ZoomEvent) => { + const newXDomain = event.transform.rescaleX(zoomStartScale).domain(); + xDomain.value = newXDomain as [number, number]; + }; + // Create the zoom behavior + const zoomBehavior = zoom() + .wheelDelta(zoomWheelBehavior) + .filter(event => { + // We don't want to zoom if the user is dragging a brush + const isRect = event.target.tagName === 'rect'; + const isMousedown = event.type === 'mousedown'; + const isDraggingBrush = isRect && isMousedown; + // Here are the default filters + const defaultFilter = (!event.ctrlKey || event.type === 'wheel') && !event.button; + // Use the default filter and our custom filter + return defaultFilter && !isDraggingBrush; + }) + // @ts-expect-error We need to reset the transform when the user stops zooming + .on('end', () => (plot.domOverlay.__zoom = new ZoomTransform(1, 0, 0))) + .on('start', () => { + zoomStartScale.domain(xDomain.value).range([0, plot.domOverlay.clientWidth]); + }) + .on('zoom', zoomed); + + // Apply the zoom behavior to the overlay div + select(plot.domOverlay).call(zoomBehavior); + + // Every time the domain gets changed we want to update the zoom + effect(() => { + const newScale = baseScale.domain(plot.xDomain.value); + plot.zoomed(newScale, scaleLinear()); + }); +} diff --git a/src/tracks/heatmap/heatmap-plot.ts b/src/tracks/heatmap/heatmap-plot.ts index e060232b9..9564df083 100644 --- a/src/tracks/heatmap/heatmap-plot.ts +++ b/src/tracks/heatmap/heatmap-plot.ts @@ -118,4 +118,9 @@ export class HeatmapTrack extends HeatmapTiledPixiTrack { this.xDomain.value = transform.rescaleX(this._refXScale).domain() as [number, number]; this.yDomain.value = transform.rescaleY(this._refYScale).domain() as [number, number]; } + + addInteractor(interactor: (plot: HeatmapTrack) => void) { + interactor(this); + return this; // For chaining + } } From 95e2891ed75d616d760957094002be644422589c Mon Sep 17 00:00:00 2001 From: etowahadams Date: Mon, 24 Jun 2024 20:48:56 -0400 Subject: [PATCH 074/139] feat: zoomPanXY interactor --- demo/examples/heatmap-example.ts | 7 ++++-- src/interactors/panZoomXY.ts | 39 ++++++++++++++++++++++++------ src/tracks/heatmap/heatmap-plot.ts | 31 ++++++++++++------------ 3 files changed, 53 insertions(+), 24 deletions(-) diff --git a/demo/examples/heatmap-example.ts b/demo/examples/heatmap-example.ts index 5ff616081..3a1acd57b 100644 --- a/demo/examples/heatmap-example.ts +++ b/demo/examples/heatmap-example.ts @@ -3,6 +3,7 @@ import { HeatmapTrack } from '@gosling-lang/heatmap'; import { DataFetcher } from '@higlass/datafetcher'; import { fakePubSub } from '@higlass/utils'; import { signal } from '@preact/signals-core'; +import { panZoomXY } from '@gosling-lang/interactors'; export function addHeatmap(pixiManager: PixiManager) { // Let's add a heatmap @@ -15,7 +16,8 @@ export function addHeatmap(pixiManager: PixiManager) { fakePubSub ); const xDomain = signal<[number, number]>([0, 3088269832]); - new HeatmapTrack( + const yDomain = signal<[number, number]>([0, 3088269832]); + const heatmap = new HeatmapTrack( { trackBorderWidth: 1, trackBorderColor: 'black', @@ -23,5 +25,6 @@ export function addHeatmap(pixiManager: PixiManager) { }, dataFetcher, pixiManager.makeContainer(heatmapPosition) - ) + ); + heatmap.addInteractor((plot) => panZoomXY(plot, xDomain, yDomain)); } diff --git a/src/interactors/panZoomXY.ts b/src/interactors/panZoomXY.ts index 066c2ab85..4aec8419e 100644 --- a/src/interactors/panZoomXY.ts +++ b/src/interactors/panZoomXY.ts @@ -8,15 +8,36 @@ import { zoomWheelBehavior, type Plot } from '../tracks/utils'; * This interactor allows the user to pan and zoom the plot */ -export function panZoomXY(plot: Plot, xDomain: Signal<[number, number]>) { +export function panZoomXY(plot: Plot, xDomain: Signal<[number, number]>, yDomain: Signal<[number, number]>) { plot.xDomain = xDomain; // Update the xDomain with the signal + plot.yDomain = yDomain; const baseScale = scaleLinear().range([0, plot.domOverlay.clientWidth]); + const baseYScale = scaleLinear().range([0, plot.domOverlay.clientHeight]); // This will store the xDomain when the user starts zooming - const zoomStartScale = scaleLinear(); + const zoomStartXScale = scaleLinear(); + const zoomStartYScale = scaleLinear(); + + const originalDomain = [0, 3088269832]; + const originalRange = [0, plot.domOverlay.clientWidth]; + // This function will be called every time the user zooms const zoomed = (event: D3ZoomEvent) => { - const newXDomain = event.transform.rescaleX(zoomStartScale).domain(); + const { transform } = event; + const newXDomain = transform.rescaleX(zoomStartXScale).domain(); xDomain.value = newXDomain as [number, number]; + const newYDomain = transform.rescaleY(zoomStartYScale).domain(); + yDomain.value = newYDomain as [number, number]; + + const scalingFactor = originalRange[1] / originalDomain[1]; + + const k = (originalDomain[1] - originalDomain[0]) / (newXDomain[1] - newXDomain[0]); + const x = -(newXDomain[0] * k - originalDomain[0]) * scalingFactor; + const y = -(newYDomain[0] * k - originalDomain[0]) * scalingFactor; + + const newXScale = baseScale.domain(xDomain.value); + const newYScale = baseYScale.domain(yDomain.value); + + plot.zoomed(newXScale, newYScale, k, x, y); }; // Create the zoom behavior const zoomBehavior = zoom() @@ -32,9 +53,12 @@ export function panZoomXY(plot: Plot, xDomain: Signal<[number, number]>) { return defaultFilter && !isDraggingBrush; }) // @ts-expect-error We need to reset the transform when the user stops zooming - .on('end', () => (plot.domOverlay.__zoom = new ZoomTransform(1, 0, 0))) + .on('end', () => { + plot.domOverlay.__zoom = new ZoomTransform(1, 0, 0); + }) .on('start', () => { - zoomStartScale.domain(xDomain.value).range([0, plot.domOverlay.clientWidth]); + zoomStartXScale.domain(xDomain.value).range([0, plot.domOverlay.clientWidth]); + zoomStartYScale.domain(yDomain.value).range([0, plot.domOverlay.clientHeight]); }) .on('zoom', zoomed); @@ -43,7 +67,8 @@ export function panZoomXY(plot: Plot, xDomain: Signal<[number, number]>) { // Every time the domain gets changed we want to update the zoom effect(() => { - const newScale = baseScale.domain(plot.xDomain.value); - plot.zoomed(newScale, scaleLinear()); + const newXScale = baseScale.domain(plot.xDomain.value); + const newYScale = baseYScale.domain(plot.yDomain.value); + plot.zoomed(newXScale, newYScale, 1, 0, 0); }); } diff --git a/src/tracks/heatmap/heatmap-plot.ts b/src/tracks/heatmap/heatmap-plot.ts index 9564df083..002b11772 100644 --- a/src/tracks/heatmap/heatmap-plot.ts +++ b/src/tracks/heatmap/heatmap-plot.ts @@ -97,27 +97,28 @@ export class HeatmapTrack extends HeatmapTiledPixiTrack { this.refScalesChanged(refXScale, refYScale); // Attach zoom behavior to the canvas. - const zoomBehavior = zoom() - .wheelDelta(zoomWheelBehavior) - .on('zoom', this.handleZoom.bind(this)); - select(overlayDiv).call(zoomBehavior); + // const zoomBehavior = zoom() + // .wheelDelta(zoomWheelBehavior) + // .on('zoom', this.handleZoom.bind(this)); + // select(overlayDiv).call(zoomBehavior); - effect(() => { - const newXScale = scaleLinear().domain(this.xDomain.value).range([0, width]); - const newYScale = scaleLinear().domain(this.yDomain.value).range([0, height]); - this.zoomed(newXScale, newYScale, this.d3ZoomTransform.k, this.d3ZoomTransform.x, this.d3ZoomTransform.y); - }); + // effect(() => { + // const newXScale = scaleLinear().domain(this.xDomain.value).range([0, width]); + // const newYScale = scaleLinear().domain(this.yDomain.value).range([0, height]); + // console.warn(this.xDomain.value, this.yDomain.value); + // this.zoomed(newXScale, newYScale, this.d3ZoomTransform.k, this.d3ZoomTransform.x, this.d3ZoomTransform.y); + // }); } /** * This function is called when the user zooms in or out. */ - handleZoom(event: D3ZoomEvent): void { - const transform = event.transform; - this.d3ZoomTransform = transform; - this.xDomain.value = transform.rescaleX(this._refXScale).domain() as [number, number]; - this.yDomain.value = transform.rescaleY(this._refYScale).domain() as [number, number]; - } + // handleZoom(event: D3ZoomEvent): void { + // const transform = event.transform; + // this.d3ZoomTransform = transform; + // this.xDomain.value = transform.rescaleX(this._refXScale).domain() as [number, number]; + // this.yDomain.value = transform.rescaleY(this._refYScale).domain() as [number, number]; + // } addInteractor(interactor: (plot: HeatmapTrack) => void) { interactor(this); From 4f35c1abcaa66118dedfd4717de99fe7a344a374 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Mon, 24 Jun 2024 21:09:09 -0400 Subject: [PATCH 075/139] feat: full working zoomXY --- src/interactors/panZoomXY.ts | 49 +++++++++++++++++++----------------- src/tracks/utils.ts | 17 +++++++++++++ 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/src/interactors/panZoomXY.ts b/src/interactors/panZoomXY.ts index 4aec8419e..bfe64512c 100644 --- a/src/interactors/panZoomXY.ts +++ b/src/interactors/panZoomXY.ts @@ -2,23 +2,25 @@ import { type Signal, effect } from '@preact/signals-core'; import { scaleLinear } from 'd3-scale'; import { ZoomTransform, type D3ZoomEvent, zoom } from 'd3-zoom'; import { select } from 'd3-selection'; -import { zoomWheelBehavior, type Plot } from '../tracks/utils'; +import { zoomWheelBehavior, type PlotXY } from '../tracks/utils'; /** * This interactor allows the user to pan and zoom the plot */ -export function panZoomXY(plot: Plot, xDomain: Signal<[number, number]>, yDomain: Signal<[number, number]>) { +export function panZoomXY(plot: PlotXY, xDomain: Signal<[number, number]>, yDomain: Signal<[number, number]>) { plot.xDomain = xDomain; // Update the xDomain with the signal plot.yDomain = yDomain; - const baseScale = scaleLinear().range([0, plot.domOverlay.clientWidth]); - const baseYScale = scaleLinear().range([0, plot.domOverlay.clientHeight]); // This will store the xDomain when the user starts zooming const zoomStartXScale = scaleLinear(); const zoomStartYScale = scaleLinear(); - const originalDomain = [0, 3088269832]; - const originalRange = [0, plot.domOverlay.clientWidth]; + const origXDomain = xDomain.value; + const origYDomain = yDomain.value; + const width = plot.domOverlay.clientWidth; + const height = plot.domOverlay.clientHeight; + const baseXScale = scaleLinear().range([0, width]); + const baseYScale = scaleLinear().range([0, height]); // This function will be called every time the user zooms const zoomed = (event: D3ZoomEvent) => { @@ -27,17 +29,6 @@ export function panZoomXY(plot: Plot, xDomain: Signal<[number, number]>, yDomain xDomain.value = newXDomain as [number, number]; const newYDomain = transform.rescaleY(zoomStartYScale).domain(); yDomain.value = newYDomain as [number, number]; - - const scalingFactor = originalRange[1] / originalDomain[1]; - - const k = (originalDomain[1] - originalDomain[0]) / (newXDomain[1] - newXDomain[0]); - const x = -(newXDomain[0] * k - originalDomain[0]) * scalingFactor; - const y = -(newYDomain[0] * k - originalDomain[0]) * scalingFactor; - - const newXScale = baseScale.domain(xDomain.value); - const newYScale = baseYScale.domain(yDomain.value); - - plot.zoomed(newXScale, newYScale, k, x, y); }; // Create the zoom behavior const zoomBehavior = zoom() @@ -57,18 +48,30 @@ export function panZoomXY(plot: Plot, xDomain: Signal<[number, number]>, yDomain plot.domOverlay.__zoom = new ZoomTransform(1, 0, 0); }) .on('start', () => { - zoomStartXScale.domain(xDomain.value).range([0, plot.domOverlay.clientWidth]); - zoomStartYScale.domain(yDomain.value).range([0, plot.domOverlay.clientHeight]); + zoomStartXScale.domain(xDomain.value).range([0, width]); + zoomStartYScale.domain(yDomain.value).range([0, height]); }) .on('zoom', zoomed); // Apply the zoom behavior to the overlay div select(plot.domOverlay).call(zoomBehavior); - // Every time the domain gets changed we want to update the zoom effect(() => { - const newXScale = baseScale.domain(plot.xDomain.value); - const newYScale = baseYScale.domain(plot.yDomain.value); - plot.zoomed(newXScale, newYScale, 1, 0, 0); + const newXScale = baseXScale.domain(xDomain.value); + const newYScale = baseYScale.domain(yDomain.value); + + // We need to calculate the k, tx, and ty values for the zoom + // Normally we would use the d3-zoom transform object, but we can't use it here + // since after every zoom event, we reset the transform object to new ZoomTransform(1, 0, 0); + // This lets us change the xDomain and yDomain signals without having to update the transform object + + const k = (origXDomain[1] - origXDomain[0]) / (xDomain.value[1] - xDomain.value[0]); + const scalingXFactor = width / (origXDomain[1] - origXDomain[0]); + const tx = -(xDomain.value[0] * k - origXDomain[0]) * scalingXFactor; + + const scalingYFactor = height / (origYDomain[1] - origYDomain[0]); + const ty = -(yDomain.value[0] * k - origYDomain[0]) * scalingYFactor; + + plot.zoomed(newXScale, newYScale, k, tx, ty); }); } diff --git a/src/tracks/utils.ts b/src/tracks/utils.ts index bcd3bb61b..7d3189758 100644 --- a/src/tracks/utils.ts +++ b/src/tracks/utils.ts @@ -21,3 +21,20 @@ export interface Plot { xDomain: Signal<[number, number]>; zoomed(xScale: ScaleLinear, yScale: ScaleLinear): void; } + +/** + * This is the interface that plots must implement for Interactors to work + */ +export interface PlotXY { + addInteractor(interactor: (plot: Plot) => void): Plot; + domOverlay: HTMLElement; + xDomain: Signal<[number, number]>; + yDomain: Signal<[number, number]>; + zoomed( + xScale: ScaleLinear, + yScale: ScaleLinear, + k: number, + tx: number, + ty: number + ): void; +} From 8fa24b0056911ab099a5e78ba2cf2eeda5c8a5e4 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Tue, 25 Jun 2024 10:32:23 -0400 Subject: [PATCH 076/139] refactor: rename to panZoomHeatmap --- demo/examples/heatmap-example.ts | 4 ++-- src/interactors/index.ts | 2 +- src/interactors/{panZoomXY.ts => panZoomHeatmap.ts} | 8 ++++++-- src/tracks/utils.ts | 2 +- 4 files changed, 10 insertions(+), 6 deletions(-) rename src/interactors/{panZoomXY.ts => panZoomHeatmap.ts} (94%) diff --git a/demo/examples/heatmap-example.ts b/demo/examples/heatmap-example.ts index 3a1acd57b..c39fc389e 100644 --- a/demo/examples/heatmap-example.ts +++ b/demo/examples/heatmap-example.ts @@ -3,7 +3,7 @@ import { HeatmapTrack } from '@gosling-lang/heatmap'; import { DataFetcher } from '@higlass/datafetcher'; import { fakePubSub } from '@higlass/utils'; import { signal } from '@preact/signals-core'; -import { panZoomXY } from '@gosling-lang/interactors'; +import { panZoomHeatmap } from '@gosling-lang/interactors'; export function addHeatmap(pixiManager: PixiManager) { // Let's add a heatmap @@ -26,5 +26,5 @@ export function addHeatmap(pixiManager: PixiManager) { dataFetcher, pixiManager.makeContainer(heatmapPosition) ); - heatmap.addInteractor((plot) => panZoomXY(plot, xDomain, yDomain)); + heatmap.addInteractor((plot) => panZoomHeatmap(plot, xDomain, yDomain)); } diff --git a/src/interactors/index.ts b/src/interactors/index.ts index 0bbcfaa91..a27debc13 100644 --- a/src/interactors/index.ts +++ b/src/interactors/index.ts @@ -1,3 +1,3 @@ export { cursor } from './cursor'; export { panZoom } from './panZoom'; -export { panZoomXY } from './panZoomXY'; +export { panZoomHeatmap } from './panZoomHeatmap'; diff --git a/src/interactors/panZoomXY.ts b/src/interactors/panZoomHeatmap.ts similarity index 94% rename from src/interactors/panZoomXY.ts rename to src/interactors/panZoomHeatmap.ts index bfe64512c..f6b85c5f6 100644 --- a/src/interactors/panZoomXY.ts +++ b/src/interactors/panZoomHeatmap.ts @@ -2,13 +2,17 @@ import { type Signal, effect } from '@preact/signals-core'; import { scaleLinear } from 'd3-scale'; import { ZoomTransform, type D3ZoomEvent, zoom } from 'd3-zoom'; import { select } from 'd3-selection'; -import { zoomWheelBehavior, type PlotXY } from '../tracks/utils'; +import { zoomWheelBehavior, type HeatmapPlot } from '../tracks/utils'; /** * This interactor allows the user to pan and zoom the plot */ -export function panZoomXY(plot: PlotXY, xDomain: Signal<[number, number]>, yDomain: Signal<[number, number]>) { +export function panZoomHeatmap( + plot: HeatmapPlot, + xDomain: Signal<[number, number]>, + yDomain: Signal<[number, number]> +) { plot.xDomain = xDomain; // Update the xDomain with the signal plot.yDomain = yDomain; // This will store the xDomain when the user starts zooming diff --git a/src/tracks/utils.ts b/src/tracks/utils.ts index 7d3189758..083b217c1 100644 --- a/src/tracks/utils.ts +++ b/src/tracks/utils.ts @@ -25,7 +25,7 @@ export interface Plot { /** * This is the interface that plots must implement for Interactors to work */ -export interface PlotXY { +export interface HeatmapPlot { addInteractor(interactor: (plot: Plot) => void): Plot; domOverlay: HTMLElement; xDomain: Signal<[number, number]>; From 08824a8d90bf6a73e35d5bc89e82f836ea4a287e Mon Sep 17 00:00:00 2001 From: etowahadams Date: Tue, 25 Jun 2024 11:39:20 -0400 Subject: [PATCH 077/139] feat: heatmap options --- demo/renderer/heatmap.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 demo/renderer/heatmap.ts diff --git a/demo/renderer/heatmap.ts b/demo/renderer/heatmap.ts new file mode 100644 index 000000000..5cd04608c --- /dev/null +++ b/demo/renderer/heatmap.ts @@ -0,0 +1,35 @@ +import { type Track } from '@gosling-lang/gosling-schema'; + +function getHeatmapOptions(spec: Track) { + return { + id: 'cb1f4560-41a3-4040-bb8f-5f27b443997d', + siblingIds: ['cb1f4560-41a3-4040-bb8f-5f27b443997d'], + showMousePosition: true, + mousePositionColor: '#000000', + name: spec.title, + labelPosition: 'topLeft', + labelShowResolution: false, + labelColor: 'black', + labelBackgroundColor: 'white', + labelBackgroundOpacity: 0.5, + labelTextOpacity: 1, + labelLeftMargin: 1, + labelTopMargin: 1, + labelRightMargin: 0, + labelBottomMargin: 0, + backgroundColor: 'transparent', + trackBorderWidth: 1, + trackBorderColor: 'black', + extent: 'full', + colorbarPosition: 'hidden', + labelShowAssembly: true, + colorbarBackgroundColor: '#ffffff', + maxZoom: null, + minWidth: 100, + minHeight: 100, + heatmapValueScaling: 'log', + showTooltip: false, + zeroValueColor: null, + data: spec.data, + }; +} From 0f4f79ce9deb2a2fe6c5f8417402b28ee99ce4df Mon Sep 17 00:00:00 2001 From: etowahadams Date: Tue, 25 Jun 2024 15:44:47 -0400 Subject: [PATCH 078/139] feat: refactor connectivity --- demo/App.tsx | 44 +++++------ demo/renderer/linkedEncoding.test.ts | 107 ++++++++++++++++----------- demo/renderer/linkedEncoding.ts | 57 ++++++-------- demo/renderer/main.ts | 75 +++++++++---------- 4 files changed, 147 insertions(+), 136 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index 38657a7cf..4552e38cf 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -36,30 +36,30 @@ function App() { // addAxisTrack(pixiManager); // addLinearBrush(pixiManager); // addBigwig(pixiManager); - addHeatmap(pixiManager); + // addHeatmap(pixiManager); - // const callback = ( - // hg: HiGlassSpec, - // size, - // gs: GoslingSpec, - // tracksAndViews, - // idTable, - // trackInfos: TrackInfo[], - // theme: Require - // ) => { - // console.warn(trackInfos); - // console.warn(tracksAndViews); - // console.warn(gs); - // // showTrackInfoPositions(trackInfos, pixiManager); - // const linkedEncodings = getLinkedEncodings(gs); - // console.warn('linkedEncodings', linkedEncodings); - // const trackDefs = createTrackDefs(trackInfos, theme); - // console.warn('trackDefs', trackDefs); - // renderTrackDefs(trackDefs, linkedEncodings, pixiManager); - // }; + const callback = ( + hg: HiGlassSpec, + size, + gs: GoslingSpec, + tracksAndViews, + idTable, + trackInfos: TrackInfo[], + theme: Require + ) => { + console.warn(trackInfos); + console.warn(tracksAndViews); + console.warn(gs); + // showTrackInfoPositions(trackInfos, pixiManager); + const linkedEncodings = getLinkedEncodings(gs); + console.warn('linkedEncodings', linkedEncodings); + const trackDefs = createTrackDefs(trackInfos, theme); + console.warn('trackDefs', trackDefs); + renderTrackDefs(trackDefs, linkedEncodings, pixiManager); + }; - // // Compile the spec - // compile(linkingTest, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); + // Compile the spec + compile(linkingTest, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); }, []); return ( diff --git a/demo/renderer/linkedEncoding.test.ts b/demo/renderer/linkedEncoding.test.ts index 3bdd151a0..26c421bdb 100644 --- a/demo/renderer/linkedEncoding.test.ts +++ b/demo/renderer/linkedEncoding.test.ts @@ -10,15 +10,16 @@ describe('Link tracks', () => { expect(result).toMatchInlineSnapshot(` [ { - "brushIds": [], - "encoding": "x", "linkingId": undefined, "signal": [ 0, 3088269832, ], - "trackIds": [ - "track-1", + "tracks": [ + { + "encoding": "x", + "id": "track-1", + }, ], }, ] @@ -36,16 +37,20 @@ describe('Link tracks', () => { expect(result).toMatchInlineSnapshot(` [ { - "brushIds": [], - "encoding": "x", "linkingId": undefined, "signal": [ 0, 3088269832, ], - "trackIds": [ - "track-1", - "track-2", + "tracks": [ + { + "encoding": "x", + "id": "track-1", + }, + { + "encoding": "x", + "id": "track-2", + }, ], }, ] @@ -70,27 +75,29 @@ describe('Link tracks', () => { expect(result).toMatchInlineSnapshot(` [ { - "brushIds": [], - "encoding": "x", "linkingId": undefined, "signal": [ 0, 3088269832, ], - "trackIds": [ - "track-1", + "tracks": [ + { + "encoding": "x", + "id": "track-1", + }, ], }, { - "brushIds": [], - "encoding": "x", "linkingId": undefined, "signal": [ 0, 3088269832, ], - "trackIds": [ - "track-2", + "tracks": [ + { + "encoding": "x", + "id": "track-2", + }, ], }, ] @@ -149,29 +156,37 @@ describe('Link tracks', () => { expect(result1).toMatchInlineSnapshot(` [ { - "brushIds": [], - "encoding": "x", "linkingId": "test", "signal": [ 0, 3088269832, ], - "trackIds": [ - "track-2", - "track-1", - "overlay-1", + "tracks": [ + { + "encoding": "x", + "id": "track-1", + }, + { + "encoding": "x", + "id": "overlay-1", + }, + { + "encoding": "x", + "id": "track-2", + }, ], }, { - "brushIds": [], - "encoding": "x", "linkingId": undefined, "signal": [ 0, 3088269832, ], - "trackIds": [ - "track-3", + "tracks": [ + { + "encoding": "x", + "id": "track-3", + }, ], }, ] @@ -195,15 +210,16 @@ describe('Link brushes', () => { expect(result).toMatchInlineSnapshot(` [ { - "brushIds": [], - "encoding": "x", "linkingId": undefined, "signal": [ 0, 3088269832, ], - "trackIds": [ - "brush-1", + "tracks": [ + { + "encoding": "x", + "id": "brush-1", + }, ], }, ] @@ -244,30 +260,37 @@ describe('Link brushes', () => { expect(result).toMatchInlineSnapshot(` [ { - "brushIds": [], - "encoding": "x", "linkingId": undefined, "signal": [ 0, 3088269832, ], - "trackIds": [ - "brush-1", - "track-1", + "tracks": [ + { + "encoding": "x", + "id": "brush-1", + }, + { + "encoding": "x", + "id": "track-1", + }, ], }, { - "brushIds": [ - "brush-1", - ], - "encoding": "x", "linkingId": "link1", "signal": [ 0, 3088269832, ], - "trackIds": [ - "track-2", + "tracks": [ + { + "encoding": "brush", + "id": "brush-1", + }, + { + "encoding": "x", + "id": "track-2", + }, ], }, ] diff --git a/demo/renderer/linkedEncoding.ts b/demo/renderer/linkedEncoding.ts index becddad70..1dfbd4493 100644 --- a/demo/renderer/linkedEncoding.ts +++ b/demo/renderer/linkedEncoding.ts @@ -9,10 +9,11 @@ import { TrackType } from './main'; */ export interface LinkedEncoding { linkingId: string; - encoding: 'x'; signal: Signal; - trackIds: string[]; - brushIds: string[]; + tracks: { + id: string; + encoding: 'x' | 'brush'; + }[]; } /** @@ -31,7 +32,7 @@ interface ViewLink { * It is the x-linking defined at the track level (opposed to the view level) */ interface TrackLink { - encoding: 'x'; + encoding: 'x' | 'brush'; linkingId: string; trackId: string; trackType: TrackType; @@ -56,18 +57,16 @@ export function getLinkedEncodings(gs: GoslingSpec) { console.warn('trackLinks', trackLinks); // We associate tracks the other tracks they are linked with const linkedEncodings = viewLinks.map(viewLink => { - const linkedBrushes = filterLinkedTracksByType( - [TrackType.BrushLinear, TrackType.BrushCircular], + const linkedTracks = filterLinkedTracksByType( + [TrackType.BrushLinear, TrackType.BrushCircular, TrackType.Gosling], viewLink.linkingId, trackLinks - ); - const linkedTracks = filterLinkedTracksByType([TrackType.Gosling], viewLink.linkingId, trackLinks); + ).map(track => ({ id: track.trackId, encoding: track.encoding })); + const viewTracks = viewLink.trackIds.map(trackId => ({ id: trackId, encoding: 'x' })); return { linkingId: viewLink.linkingId, - encoding: viewLink.encoding, signal: viewLink.signal, - trackIds: [...viewLink.trackIds, ...linkedTracks.map(track => track.trackId)], - brushIds: linkedBrushes.map(brush => brush.trackId) + tracks: [...linkedTracks, ...viewTracks] } as LinkedEncoding; }); // Combine trackLinks that do not belong to any viewLink @@ -76,7 +75,7 @@ export function getLinkedEncodings(gs: GoslingSpec) { ); linkedEncodings.push(...combineUnlinkedTracks(unlinkedTracks)); - return linkedEncodings.filter(link => link.trackIds.length > 0 || link.brushIds.length > 0); + return linkedEncodings.filter(link => link.tracks.length > 0); } /** @@ -89,27 +88,20 @@ function combineUnlinkedTracks(unlinkedTracks: TrackLink[]): LinkedEncoding[] { unlinkedTracks.forEach(trackLink => { const existingLink = linkedEncodings.find(link => link.linkingId === trackLink.linkingId); if (existingLink) { - if (isBrushTrack(trackLink.trackType)) { - existingLink.brushIds.push(trackLink.trackId); - } else { - existingLink.trackIds.push(trackLink.trackId); - } + existingLink.tracks.push({ id: trackLink.trackId, encoding: trackLink.encoding }); if (trackLink.signal) { existingLink.signal = trackLink.signal; } } else { + // TODO: handle default domain better. + // We might just want to remove this link all together if it doesn't have a domain + const DEFAULT_DOMAIN = [0, 3088269832]; const newLink = { linkingId: trackLink.linkingId, - encoding: trackLink.encoding + tracks: [], + signal: signal(DEFAULT_DOMAIN) // this signal will get replaced if the track has a domain } as LinkedEncoding; - - if (isBrushTrack(trackLink.trackType)) { - newLink.brushIds = [trackLink.trackId]; - newLink.trackIds = []; - } else { - newLink.trackIds = [trackLink.trackId]; - newLink.brushIds = []; - } + newLink.tracks.push({ id: trackLink.trackId, encoding: trackLink.encoding }); if (trackLink.signal) { newLink.signal = trackLink.signal; } @@ -120,12 +112,6 @@ function combineUnlinkedTracks(unlinkedTracks: TrackLink[]): LinkedEncoding[] { return linkedEncodings; } -/** - * Helper function to determine if a track is a brush track - */ -function isBrushTrack(trackType: TrackType) { - return trackType === TrackType.BrushLinear || trackType === TrackType.BrushCircular; -} /** * Helper function to filter the linked tracks by type */ @@ -183,7 +169,12 @@ function getSingleViewTrackLinks(gs: SingleView): TrackLink[] { track._overlay!.forEach(overlay => { if (overlay.mark === 'brush') { const trackType = gs.layout === 'linear' ? TrackType.BrushLinear : TrackType.BrushCircular; - const trackLink = { trackId: overlay.id, linkingId: overlay.x.linkingId, trackType, encoding: 'x' }; + const trackLink = { + trackId: overlay.id, + linkingId: overlay.x.linkingId, + trackType, + encoding: 'brush' + }; if (overlay.x.domain !== undefined) { const { assembly } = gs; const domain = getDomain(overlay.x.domain, assembly); diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index 40c526bd0..4bb1cd13c 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -60,6 +60,9 @@ type TrackDefs = { [K in keyof TrackOptionsMap]: TrackDef; }[keyof TrackOptionsMap]; +/** + * This function is for internal testing. It will render a red border around each track + */ export function showTrackInfoPositions(trackInfos: TrackInfo[], pixiManager: PixiManager) { trackInfos.forEach(trackInfo => { const { track, boundingBox } = trackInfo; @@ -71,7 +74,7 @@ export function showTrackInfoPositions(trackInfos: TrackInfo[], pixiManager: Pix } /** - * Takes a list of TrackInfos and returns a list of TrackOptions + * Takes a list of TrackInfos and returns a list of TrackDefs * @param trackInfos * @param pixiManager * @param theme @@ -107,7 +110,9 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE new TextTrack(options, pixiManager.makeContainer(boundingBox)); } if (type === TrackType.Gosling) { - const domain = getXDomainSignal(trackDef.trackId, linkedEncodings); + const domain = getEncodingSignal(trackDef.trackId, 'x', linkedEncodings); + if (!domain) return; + const datafetcher = getDataFetcher(options.spec); const gosPlot = new GoslingTrack(options, datafetcher, pixiManager.makeContainer(boundingBox), domain); if (!options.spec.static) { @@ -115,66 +120,58 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE } } if (type === TrackType.Axis) { - const domain = getXDomainSignal(trackDef.trackId, linkedEncodings); + const domain = getEncodingSignal(trackDef.trackId, 'x', linkedEncodings); + if (!domain) return; + new AxisTrack(options, domain, pixiManager.makeContainer(boundingBox)); } if (type === TrackType.BrushLinear) { - const domain = getXDomainSignal(trackDef.trackId, linkedEncodings); - const brushDomain = getBrushSignal(trackDef.trackId, linkedEncodings); + const domain = getEncodingSignal(trackDef.trackId, 'x', linkedEncodings); + const brushDomain = getEncodingSignal(trackDef.trackId, 'brush', linkedEncodings); + if (!domain || !brushDomain || !hasLinkedTracks(trackDef.trackId, linkedEncodings)) return; // We only want to add the brush track if it is linked to another track - if (hasLinkedTracks(trackDef.trackId, linkedEncodings)) { - new BrushLinearTrack( - options, - brushDomain, - pixiManager.makeContainer(boundingBox).overlayDiv - ).addInteractor(plot => panZoom(plot, domain)); - } + new BrushLinearTrack(options, brushDomain, pixiManager.makeContainer(boundingBox).overlayDiv).addInteractor( + plot => panZoom(plot, domain) + ); } if (type === TrackType.BrushCircular) { - const domain = getXDomainSignal(trackDef.trackId, linkedEncodings); - const brushDomain = getBrushSignal(trackDef.trackId, linkedEncodings); + const domain = getEncodingSignal(trackDef.trackId, 'x', linkedEncodings); + const brushDomain = getEncodingSignal(trackDef.trackId, 'brush', linkedEncodings); + if (!domain || !brushDomain || !hasLinkedTracks(trackDef.trackId, linkedEncodings)) return; // We only want to add the brush track if it is linked to another track - if (hasLinkedTracks(trackDef.trackId, linkedEncodings)) { - new BrushCircularTrack(options, brushDomain, pixiManager.makeContainer(boundingBox).overlayDiv, domain); - } + new BrushCircularTrack(options, brushDomain, pixiManager.makeContainer(boundingBox).overlayDiv, domain); } }); } /** - * Returns true if the brushId is linked to another track + * Returns true if the brush track is linked to non-brush tracks * We don't want to render a brush track if it is not linked to another track */ function hasLinkedTracks(brushId: string, linkedEncodings: LinkedEncoding[]): boolean { - const linkedEncoding = linkedEncodings.find(link => link.brushIds.includes(brushId)); + const linkedEncoding = linkedEncodings.find(link => + link.tracks.find(t => t.id === brushId && t.encoding === 'brush') + ); if (!linkedEncoding) return false; - return linkedEncoding?.brushIds.length > 0 && linkedEncoding?.trackIds.length > 0; + const nonBrushTracks = linkedEncoding.tracks.filter(t => t.encoding !== 'brush'); + return nonBrushTracks.length > 0; } -function getBrushSignal(trackDefId: string, linkedEncodings: LinkedEncoding[]): Signal<[number, number]> { - const linkedEncoding = linkedEncodings.find(link => link.brushIds.includes(trackDefId)); - - if (!linkedEncoding) { - console.warn(`No linked encoding found for track ${trackDefId}`); - return signal<[number, number]>([0, 30000000]); - } - if (!linkedEncoding.signal) { - console.warn(`No signal found for linked encoding ${linkedEncoding.linkingId}`); - return signal<[number, number]>([0, 30000000]); - } - return linkedEncoding!.signal; -} - -function getXDomainSignal(trackDefId: string, linkedEncodings: LinkedEncoding[]): Signal<[number, number]> { - const linkedEncoding = linkedEncodings.find(link => link.trackIds.includes(trackDefId)); - +function getEncodingSignal( + trackDefId: string, + encodingType: string, + linkedEncodings: LinkedEncoding[] +): Signal | undefined { + const linkedEncoding = linkedEncodings.find(link => + link.tracks.find(t => t.id === trackDefId && t.encoding === encodingType) + ); if (!linkedEncoding) { console.warn(`No linked encoding found for track ${trackDefId}`); - return signal<[number, number]>([0, 30000000]); + return undefined; } if (!linkedEncoding.signal) { console.warn(`No signal found for linked encoding ${linkedEncoding.linkingId}`); - return signal<[number, number]>([0, 30000000]); + return undefined; } return linkedEncoding!.signal; } From ccac22c82b79a412e075a0c620026e9259a4df25 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Tue, 25 Jun 2024 16:17:09 -0400 Subject: [PATCH 079/139] feat: working heatmap --- demo/App.tsx | 23 ++++- demo/renderer/dataFetcher.ts | 2 +- demo/renderer/heatmap.ts | 132 +++++++++++++++++++++++++++-- demo/renderer/main.ts | 19 ++++- src/tracks/heatmap/heatmap-plot.ts | 4 +- src/tracks/heatmap/index.ts | 1 + 6 files changed, 168 insertions(+), 13 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index 4552e38cf..caad025e3 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -59,7 +59,7 @@ function App() { }; // Compile the spec - compile(linkingTest, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); + compile(matrix, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); }, []); return ( @@ -75,6 +75,27 @@ function App() { export default App; +const matrix = { + xDomain: { chromosome: 'chr7', interval: [77700000, 81000000] }, + tracks: [ + { + title: 'HFFc6_Micro-C', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=hffc6-microc-hg38', + type: 'matrix' + }, + mark: 'bar', + x: { field: 'xs', type: 'genomic', axis: 'none' }, + xe: { field: 'xe', type: 'genomic', axis: 'none' }, + y: { field: 'ys', type: 'genomic', axis: 'none' }, + ye: { field: 'ye', type: 'genomic', axis: 'none' }, + color: { field: 'value', type: 'quantitative', range: 'warm' }, + width: 600, + height: 600 + } + ] +}; + const spec = { title: 'Basic Marks: line', subtitle: 'Tutorial Examples', diff --git a/demo/renderer/dataFetcher.ts b/demo/renderer/dataFetcher.ts index 5106cb096..337c330ad 100644 --- a/demo/renderer/dataFetcher.ts +++ b/demo/renderer/dataFetcher.ts @@ -6,7 +6,7 @@ export function getDataFetcher(spec: Track) { if (!('data' in spec)) { console.warn('No data in the track spec', spec); } - if (spec.data.type == 'multivec' || spec.data.type == 'beddb') { + if (spec.data.type == 'multivec' || spec.data.type == 'beddb' || spec.data.type == 'matrix') { const url = spec.data.url; const server = url.split('/').slice(0, -2).join('/'); const tilesetUid = url.split('=').slice(-1)[0]; diff --git a/demo/renderer/heatmap.ts b/demo/renderer/heatmap.ts index 5cd04608c..028ed15c7 100644 --- a/demo/renderer/heatmap.ts +++ b/demo/renderer/heatmap.ts @@ -1,10 +1,28 @@ import { type Track } from '@gosling-lang/gosling-schema'; +import { type TrackDef, TrackType } from './main'; +import { type HeatmapTrackOptions } from '@gosling-lang/heatmap'; +import type { CompleteThemeDeep } from '../../src/core/utils/theme'; -function getHeatmapOptions(spec: Track) { +export function processHeatmapTrack( + track: Track, + boundingBox: { x: number; y: number; width: number; height: number }, + theme: Required +): TrackDef[] { + const trackDefs: TrackDef[] = []; + const heatmapOptions = getHeatmapOptions(track, theme); + trackDefs.push({ + type: TrackType.Heatmap, + options: heatmapOptions, + boundingBox, + trackId: track.id + }); + return trackDefs; +} + +function getHeatmapOptions(spec: Track, theme: Required): HeatmapTrackOptions { return { - id: 'cb1f4560-41a3-4040-bb8f-5f27b443997d', - siblingIds: ['cb1f4560-41a3-4040-bb8f-5f27b443997d'], - showMousePosition: true, + spec: spec, + showMousePosition: false, mousePositionColor: '#000000', name: spec.title, labelPosition: 'topLeft', @@ -24,12 +42,112 @@ function getHeatmapOptions(spec: Track) { colorbarPosition: 'hidden', labelShowAssembly: true, colorbarBackgroundColor: '#ffffff', - maxZoom: null, minWidth: 100, minHeight: 100, heatmapValueScaling: 'log', showTooltip: false, - zeroValueColor: null, - data: spec.data, + zeroValueColor: undefined, + colorRange: [ + 'rgb(110, 64, 170)', + 'rgb(114, 64, 171)', + 'rgb(117, 63, 173)', + 'rgb(121, 63, 174)', + 'rgb(125, 63, 175)', + 'rgb(129, 62, 176)', + 'rgb(134, 62, 177)', + 'rgb(138, 62, 178)', + 'rgb(142, 62, 178)', + 'rgb(146, 61, 179)', + 'rgb(150, 61, 179)', + 'rgb(154, 61, 179)', + 'rgb(158, 61, 179)', + 'rgb(162, 61, 179)', + 'rgb(167, 60, 179)', + 'rgb(171, 60, 178)', + 'rgb(175, 60, 178)', + 'rgb(179, 60, 177)', + 'rgb(183, 60, 177)', + 'rgb(187, 60, 176)', + 'rgb(191, 60, 175)', + 'rgb(195, 61, 173)', + 'rgb(199, 61, 172)', + 'rgb(203, 61, 171)', + 'rgb(207, 61, 169)', + 'rgb(210, 62, 167)', + 'rgb(214, 62, 166)', + 'rgb(217, 63, 164)', + 'rgb(221, 63, 162)', + 'rgb(224, 64, 160)', + 'rgb(228, 65, 157)', + 'rgb(231, 65, 155)', + 'rgb(234, 66, 153)', + 'rgb(237, 67, 150)', + 'rgb(240, 68, 148)', + 'rgb(242, 69, 145)', + 'rgb(245, 70, 142)', + 'rgb(248, 71, 139)', + 'rgb(250, 73, 136)', + 'rgb(252, 74, 134)', + 'rgb(254, 75, 131)', + 'rgb(255, 77, 128)', + 'rgb(255, 78, 124)', + 'rgb(255, 80, 121)', + 'rgb(255, 82, 118)', + 'rgb(255, 84, 115)', + 'rgb(255, 86, 112)', + 'rgb(255, 88, 109)', + 'rgb(255, 90, 106)', + 'rgb(255, 92, 102)', + 'rgb(255, 94, 99)', + 'rgb(255, 96, 96)', + 'rgb(255, 99, 93)', + 'rgb(255, 101, 90)', + 'rgb(255, 103, 87)', + 'rgb(255, 106, 84)', + 'rgb(255, 109, 81)', + 'rgb(255, 111, 78)', + 'rgb(255, 114, 76)', + 'rgb(255, 117, 73)', + 'rgb(255, 120, 71)', + 'rgb(255, 122, 68)', + 'rgb(255, 125, 66)', + 'rgb(255, 128, 63)', + 'rgb(255, 131, 61)', + 'rgb(255, 135, 59)', + 'rgb(255, 138, 57)', + 'rgb(255, 141, 56)', + 'rgb(255, 144, 54)', + 'rgb(253, 147, 52)', + 'rgb(251, 150, 51)', + 'rgb(249, 154, 50)', + 'rgb(246, 157, 49)', + 'rgb(244, 160, 48)', + 'rgb(242, 164, 47)', + 'rgb(239, 167, 47)', + 'rgb(237, 170, 46)', + 'rgb(234, 173, 46)', + 'rgb(231, 177, 46)', + 'rgb(229, 180, 46)', + 'rgb(226, 183, 47)', + 'rgb(223, 187, 47)', + 'rgb(220, 190, 48)', + 'rgb(218, 193, 49)', + 'rgb(215, 196, 50)', + 'rgb(212, 199, 51)', + 'rgb(209, 202, 52)', + 'rgb(206, 205, 54)', + 'rgb(204, 208, 56)', + 'rgb(201, 211, 58)', + 'rgb(198, 214, 60)', + 'rgb(196, 217, 62)', + 'rgb(193, 220, 65)', + 'rgb(191, 223, 67)', + 'rgb(188, 225, 70)', + 'rgb(186, 228, 73)', + 'rgb(183, 230, 76)', + 'rgb(181, 233, 80)', + 'rgb(179, 235, 83)', + 'rgb(177, 238, 87)' + ] }; } diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index 4bb1cd13c..09c1b07d3 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -12,10 +12,12 @@ import type { CompleteThemeDeep } from '../../src/core/utils/theme'; import type { GoslingTrackOptions } from '../../src/tracks/gosling-track/gosling-track'; import { proccessTextHeader } from './text'; +import { processHeatmapTrack } from './heatmap'; import { processGoslingTrack } from './gosling'; import { getDataFetcher } from './dataFetcher'; import type { LinkedEncoding } from './linkedEncoding'; import { BrushCircularTrack, type BrushCircularTrackOptions } from '@gosling-lang/brush-circular'; +import { type HeatmapTrackOptions, HeatmapTrack } from '@gosling-lang/heatmap'; /** * All the different types of tracks that can be rendered @@ -40,7 +42,7 @@ interface TrackOptionsMap { [TrackType.Axis]: AxisTrackOptions; [TrackType.BrushLinear]: BrushLinearTrackOptions; [TrackType.BrushCircular]: BrushCircularTrackOptions; - [TrackType.Heatmap]: any; + [TrackType.Heatmap]: HeatmapTrackOptions; } /** @@ -85,11 +87,16 @@ export function createTrackDefs(trackInfos: TrackInfo[], theme: Required { const { track, boundingBox } = trackInfo; - // Header marks contain both the title and subtitle if (track.mark === '_header') { + // Header marks contain both the title and subtitle const textTrackDefs = proccessTextHeader(track, boundingBox, theme); trackDefs.push(...textTrackDefs); + } else if (isHeatmapTrack(track)) { + // We have a heatmap track + const heatmapTrackDefs = processHeatmapTrack(track, boundingBox, theme); + trackDefs.push(...heatmapTrackDefs); } else { + // We have a gosling track const goslingAxisDefs = processGoslingTrack(track, boundingBox, theme); trackDefs.push(...goslingAxisDefs); } @@ -97,6 +104,10 @@ export function createTrackDefs(trackInfos: TrackInfo[], theme: Required panZoom(plot, domain)); } } + if (type === TrackType.Heatmap) { + const datafetcher = getDataFetcher(options.spec); + new HeatmapTrack(options, datafetcher, pixiManager.makeContainer(boundingBox)); + } if (type === TrackType.Axis) { const domain = getEncodingSignal(trackDef.trackId, 'x', linkedEncodings); if (!domain) return; diff --git a/src/tracks/heatmap/heatmap-plot.ts b/src/tracks/heatmap/heatmap-plot.ts index 002b11772..60dc3d454 100644 --- a/src/tracks/heatmap/heatmap-plot.ts +++ b/src/tracks/heatmap/heatmap-plot.ts @@ -10,7 +10,7 @@ import { zoomWheelBehavior } from '../utils'; import { DataFetcher } from '@higlass/datafetcher'; import { signal, type Signal, effect } from '@preact/signals-core'; -type HeatmapTrackContext = TiledPixiTrackContext & { +export type HeatmapTrackContext = TiledPixiTrackContext & { svgElement: HTMLElement; onTrackOptionsChanged: () => void; onMouseMoveZoom?: (event: any) => void; @@ -18,7 +18,7 @@ type HeatmapTrackContext = TiledPixiTrackContext & { isValueScaleLocked: () => boolean; }; -type HeatmapTrackOptions = TiledPixiTrackOptions & { +export type HeatmapTrackOptions = TiledPixiTrackOptions & { dataTransform?: unknown; extent?: string; reverseYAxis?: boolean; diff --git a/src/tracks/heatmap/index.ts b/src/tracks/heatmap/index.ts index fc52c0d85..78725e3d2 100644 --- a/src/tracks/heatmap/index.ts +++ b/src/tracks/heatmap/index.ts @@ -1 +1,2 @@ export { HeatmapTrack } from './heatmap-plot'; +export { type HeatmapTrackOptions, type HeatmapTrackContext } from './heatmap-plot'; From 12c29e57bd076dcb280c28f955dd3736c634da20 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Tue, 25 Jun 2024 17:23:43 -0400 Subject: [PATCH 080/139] feat: working heatmap --- demo/renderer/heatmap.ts | 7 ++++ demo/renderer/linkedEncoding.test.ts | 58 ++++++++++++++++++++++++++++ demo/renderer/linkedEncoding.ts | 45 ++++++++++++++------- demo/renderer/main.ts | 19 +++++---- src/interactors/panZoomHeatmap.ts | 13 +++---- src/tracks/heatmap/heatmap-plot.ts | 27 ++----------- src/tracks/utils.ts | 1 + 7 files changed, 119 insertions(+), 51 deletions(-) diff --git a/demo/renderer/heatmap.ts b/demo/renderer/heatmap.ts index 028ed15c7..0d9b7ae27 100644 --- a/demo/renderer/heatmap.ts +++ b/demo/renderer/heatmap.ts @@ -2,6 +2,7 @@ import { type Track } from '@gosling-lang/gosling-schema'; import { type TrackDef, TrackType } from './main'; import { type HeatmapTrackOptions } from '@gosling-lang/heatmap'; import type { CompleteThemeDeep } from '../../src/core/utils/theme'; +import { computeChromSizes } from '../../src/core/utils/assembly'; export function processHeatmapTrack( track: Track, @@ -19,9 +20,15 @@ export function processHeatmapTrack( return trackDefs; } +export function isHeatmapTrack(track: Track): boolean { + return track.data && track.data.type === 'matrix'; +} + function getHeatmapOptions(spec: Track, theme: Required): HeatmapTrackOptions { + const { assembly } = spec; return { spec: spec, + maxDomain: computeChromSizes(assembly).total, showMousePosition: false, mousePositionColor: '#000000', name: spec.title, diff --git a/demo/renderer/linkedEncoding.test.ts b/demo/renderer/linkedEncoding.test.ts index 26c421bdb..bb7da2164 100644 --- a/demo/renderer/linkedEncoding.test.ts +++ b/demo/renderer/linkedEncoding.test.ts @@ -297,3 +297,61 @@ describe('Link brushes', () => { `); }); }); + +describe('Heatmap', () => { + it('one track, one view', () => { + const matrix = { + xDomain: { chromosome: 'chr7', interval: [77700000, 81000000] }, + tracks: [ + { + id: 'matrix-1', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=hffc6-microc-hg38', + type: 'matrix' + }, + mark: 'bar', + x: { field: 'xs', type: 'genomic', axis: 'none' }, + xe: { field: 'xe', type: 'genomic', axis: 'none' }, + y: { field: 'ys', type: 'genomic', axis: 'none' }, + ye: { field: 'ye', type: 'genomic', axis: 'none' }, + color: { field: 'value', type: 'quantitative', range: 'warm' }, + width: 600, + height: 600 + } + ] + }; + + const result = getLinkedEncodings(matrix); + + expect(result).toMatchInlineSnapshot(` + [ + { + "linkingId": undefined, + "signal": [ + 0, + 3088269832, + ], + "tracks": [ + { + "encoding": "x", + "id": "matrix-1", + }, + ], + }, + { + linkingId: undefined, + signal: [ + 0, + 3088269832 + ], + tracks: [ + { + encoding: "y", + id: "matrix-1" + } + ] + } + ] + `); + }); +}); diff --git a/demo/renderer/linkedEncoding.ts b/demo/renderer/linkedEncoding.ts index 1dfbd4493..2bf4ffd25 100644 --- a/demo/renderer/linkedEncoding.ts +++ b/demo/renderer/linkedEncoding.ts @@ -3,6 +3,7 @@ import { GenomicPositionHelper, computeChromSizes } from '../../src/core/utils/a import { signal, type Signal } from '@preact/signals-core'; import type { GoslingSpec } from 'gosling.js'; import { TrackType } from './main'; +import { isHeatmapTrack } from './heatmap'; /** * This is the information needed to link tracks together @@ -32,7 +33,7 @@ interface ViewLink { * It is the x-linking defined at the track level (opposed to the view level) */ interface TrackLink { - encoding: 'x' | 'brush'; + encoding: 'x' | 'brush' | 'y'; linkingId: string; trackId: string; trackType: TrackType; @@ -57,11 +58,10 @@ export function getLinkedEncodings(gs: GoslingSpec) { console.warn('trackLinks', trackLinks); // We associate tracks the other tracks they are linked with const linkedEncodings = viewLinks.map(viewLink => { - const linkedTracks = filterLinkedTracksByType( - [TrackType.BrushLinear, TrackType.BrushCircular, TrackType.Gosling], - viewLink.linkingId, - trackLinks - ).map(track => ({ id: track.trackId, encoding: track.encoding })); + const linkedTracks = getLinkedTracks(viewLink.linkingId, trackLinks).map(track => ({ + id: track.trackId, + encoding: track.encoding + })); const viewTracks = viewLink.trackIds.map(trackId => ({ id: trackId, encoding: 'x' })); return { linkingId: viewLink.linkingId, @@ -71,7 +71,8 @@ export function getLinkedEncodings(gs: GoslingSpec) { }); // Combine trackLinks that do not belong to any viewLink const unlinkedTracks = trackLinks.filter( - trackLink => !linkedEncodings.some(link => trackLink.linkingId === link.linkingId) + trackLink => + !linkedEncodings.some(link => link.linkingId !== undefined && trackLink.linkingId === link.linkingId) ); linkedEncodings.push(...combineUnlinkedTracks(unlinkedTracks)); @@ -86,7 +87,7 @@ function combineUnlinkedTracks(unlinkedTracks: TrackLink[]): LinkedEncoding[] { console.warn('unlinkedTracks', unlinkedTracks); const linkedEncodings: LinkedEncoding[] = []; unlinkedTracks.forEach(trackLink => { - const existingLink = linkedEncodings.find(link => link.linkingId === trackLink.linkingId); + const existingLink = linkedEncodings.find(link => link.linkingId && link.linkingId === trackLink.linkingId); if (existingLink) { existingLink.tracks.push({ id: trackLink.trackId, encoding: trackLink.encoding }); if (trackLink.signal) { @@ -113,11 +114,11 @@ function combineUnlinkedTracks(unlinkedTracks: TrackLink[]): LinkedEncoding[] { } /** - * Helper function to filter the linked tracks by type + * Helper function to get linked tracks by linkingId */ -function filterLinkedTracksByType(trackType: TrackType[], linkingId: string | undefined, trackLinks: TrackLink[]) { +function getLinkedTracks(linkingId: string | undefined, trackLinks: TrackLink[]) { if (!linkingId) return []; - return trackLinks.filter(trackLink => trackLink.linkingId === linkingId && trackType.includes(trackLink.trackType)); + return trackLinks.filter(trackLink => trackLink.linkingId === linkingId); } /** @@ -149,12 +150,28 @@ function getSingleViewTrackLinks(gs: SingleView): TrackLink[] { const { tracks } = gs; const trackLinks: TrackLink[] = []; tracks.forEach(track => { - if ('x' in track && track.x && 'linkingId' in track.x) { + const trackType = isHeatmapTrack(track) ? TrackType.Heatmap : TrackType.Gosling; + + // Handle the y domain when we have a heatmap track + if (trackType === TrackType.Heatmap) { + const { assembly, xDomain, yDomain } = gs; + const trackDomain = getDomain(yDomain ?? xDomain, assembly); // default to the xDomain if no yDomain + const trackLink = { + trackId: track.id, + linkingId: track.y.linkingId, // we may or may not have a linkingId + trackType: TrackType.Heatmap, + encoding: 'y', + signal: signal(trackDomain) + } as TrackLink; + trackLinks.push(trackLink); + } + // Handle x domain + if ('x' in track && track.x && 'linkingId' in track.x && track.x?.linkingId !== undefined) { if (track.mark === 'brush') console.warn('Track with brush mark should only be used as an overlay'); const trackLink = { trackId: track.id, linkingId: track.x.linkingId, - trackType: TrackType.Gosling, + trackType, encoding: 'x' } as TrackLink; // If the track has a domain, we create a signal and add it to the trackLink @@ -165,6 +182,8 @@ function getSingleViewTrackLinks(gs: SingleView): TrackLink[] { } trackLinks.push(trackLink); } + + // Handle linking in the brushes which are defined in the overlay tracks if (!('_overlay' in track)) return; track._overlay!.forEach(overlay => { if (overlay.mark === 'brush') { diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index 09c1b07d3..8c0e20aaf 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -6,13 +6,13 @@ import { AxisTrack, type AxisTrackOptions } from '@gosling-lang/genomic-axis'; import { BrushLinearTrack, type BrushLinearTrackOptions } from '@gosling-lang/brush-linear'; import { Signal, signal } from '@preact/signals-core'; -import { cursor, panZoom } from '@gosling-lang/interactors'; +import { cursor, panZoom, panZoomHeatmap } from '@gosling-lang/interactors'; import type { TrackInfo } from '../../src/compiler/bounding-box'; import type { CompleteThemeDeep } from '../../src/core/utils/theme'; import type { GoslingTrackOptions } from '../../src/tracks/gosling-track/gosling-track'; import { proccessTextHeader } from './text'; -import { processHeatmapTrack } from './heatmap'; +import { processHeatmapTrack, isHeatmapTrack } from './heatmap'; import { processGoslingTrack } from './gosling'; import { getDataFetcher } from './dataFetcher'; import type { LinkedEncoding } from './linkedEncoding'; @@ -104,10 +104,6 @@ export function createTrackDefs(trackInfos: TrackInfo[], theme: Required([0, 3088269832]); + // const yDomain = signal<[number, number]>([0, 3088269832]); + console.warn('making track'); + new HeatmapTrack(options, datafetcher, pixiManager.makeContainer(boundingBox)).addInteractor(plot => + panZoomHeatmap(plot, xDomain, yDomain) + ); } if (type === TrackType.Axis) { const domain = getEncodingSignal(trackDef.trackId, 'x', linkedEncodings); diff --git a/src/interactors/panZoomHeatmap.ts b/src/interactors/panZoomHeatmap.ts index f6b85c5f6..2b35c2333 100644 --- a/src/interactors/panZoomHeatmap.ts +++ b/src/interactors/panZoomHeatmap.ts @@ -19,8 +19,7 @@ export function panZoomHeatmap( const zoomStartXScale = scaleLinear(); const zoomStartYScale = scaleLinear(); - const origXDomain = xDomain.value; - const origYDomain = yDomain.value; + const maxDomain = plot.maxDomain; // used to calculate k, the scaling factor const width = plot.domOverlay.clientWidth; const height = plot.domOverlay.clientHeight; const baseXScale = scaleLinear().range([0, width]); @@ -69,12 +68,12 @@ export function panZoomHeatmap( // since after every zoom event, we reset the transform object to new ZoomTransform(1, 0, 0); // This lets us change the xDomain and yDomain signals without having to update the transform object - const k = (origXDomain[1] - origXDomain[0]) / (xDomain.value[1] - xDomain.value[0]); - const scalingXFactor = width / (origXDomain[1] - origXDomain[0]); - const tx = -(xDomain.value[0] * k - origXDomain[0]) * scalingXFactor; + const k = maxDomain / (xDomain.value[1] - xDomain.value[0]); + const scalingXFactor = width / maxDomain; + const tx = -(xDomain.value[0] * k) * scalingXFactor; - const scalingYFactor = height / (origYDomain[1] - origYDomain[0]); - const ty = -(yDomain.value[0] * k - origYDomain[0]) * scalingYFactor; + const scalingYFactor = height / maxDomain; + const ty = -(yDomain.value[0] * k) * scalingYFactor; plot.zoomed(newXScale, newYScale, k, tx, ty); }); diff --git a/src/tracks/heatmap/heatmap-plot.ts b/src/tracks/heatmap/heatmap-plot.ts index 60dc3d454..6dee5712c 100644 --- a/src/tracks/heatmap/heatmap-plot.ts +++ b/src/tracks/heatmap/heatmap-plot.ts @@ -19,6 +19,7 @@ export type HeatmapTrackContext = TiledPixiTrackContext & { }; export type HeatmapTrackOptions = TiledPixiTrackOptions & { + maxDomain: number; dataTransform?: unknown; extent?: string; reverseYAxis?: boolean; @@ -41,6 +42,7 @@ export type HeatmapTrackOptions = TiledPixiTrackOptions & { export class HeatmapTrack extends HeatmapTiledPixiTrack { xDomain: Signal<[number, number]>; // This has to be a signal because it will potentially be updated by interactors yDomain: Signal<[number, number]>; + maxDomain: number; // the maximum domain of the data. This is needed for zoomPanHeatmap to work properly domOverlay: HTMLElement; d3ZoomTransform: ZoomTransform; @@ -83,8 +85,8 @@ export class HeatmapTrack extends HeatmapTiledPixiTrack { super(context, options); this.xDomain = xDomain; this.yDomain = yDomain; - this.d3ZoomTransform = new ZoomTransform(1, 0, 0); this.domOverlay = overlayDiv; + this.maxDomain = options.maxDomain; // Now we need to initialize all of the properties that would normally be set by HiGlassComponent this.setDimensions([width, height]); @@ -95,31 +97,8 @@ export class HeatmapTrack extends HeatmapTiledPixiTrack { // Set the scales this.zoomed(refXScale, refYScale, 1, 0, 0); this.refScalesChanged(refXScale, refYScale); - - // Attach zoom behavior to the canvas. - // const zoomBehavior = zoom() - // .wheelDelta(zoomWheelBehavior) - // .on('zoom', this.handleZoom.bind(this)); - // select(overlayDiv).call(zoomBehavior); - - // effect(() => { - // const newXScale = scaleLinear().domain(this.xDomain.value).range([0, width]); - // const newYScale = scaleLinear().domain(this.yDomain.value).range([0, height]); - // console.warn(this.xDomain.value, this.yDomain.value); - // this.zoomed(newXScale, newYScale, this.d3ZoomTransform.k, this.d3ZoomTransform.x, this.d3ZoomTransform.y); - // }); } - /** - * This function is called when the user zooms in or out. - */ - // handleZoom(event: D3ZoomEvent): void { - // const transform = event.transform; - // this.d3ZoomTransform = transform; - // this.xDomain.value = transform.rescaleX(this._refXScale).domain() as [number, number]; - // this.yDomain.value = transform.rescaleY(this._refYScale).domain() as [number, number]; - // } - addInteractor(interactor: (plot: HeatmapTrack) => void) { interactor(this); return this; // For chaining diff --git a/src/tracks/utils.ts b/src/tracks/utils.ts index 083b217c1..8837b94d4 100644 --- a/src/tracks/utils.ts +++ b/src/tracks/utils.ts @@ -30,6 +30,7 @@ export interface HeatmapPlot { domOverlay: HTMLElement; xDomain: Signal<[number, number]>; yDomain: Signal<[number, number]>; + maxDomain: number; zoomed( xScale: ScaleLinear, yScale: ScaleLinear, From 9e1bc6cefb65ecc451cdeded86eafe90bfdf7f43 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 26 Jun 2024 13:44:35 -0400 Subject: [PATCH 081/139] feat: add basic heatmap axis --- demo/renderer/gosling.ts | 2 +- demo/renderer/heatmap.ts | 15 +++++++++++++-- demo/renderer/main.ts | 4 +--- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/demo/renderer/gosling.ts b/demo/renderer/gosling.ts index 3ff111d93..dd18f0021 100644 --- a/demo/renderer/gosling.ts +++ b/demo/renderer/gosling.ts @@ -21,7 +21,7 @@ export function processGoslingTrack( | TrackDef )[] = []; - // Adds the title and subtitle tracks + // Adds the axis tracks const [newTrackBbox, axisTrackDef] = getAxisTrackDef(track, boundingBox, theme); if (axisTrackDef) { trackDefs.push(axisTrackDef); diff --git a/demo/renderer/heatmap.ts b/demo/renderer/heatmap.ts index 0d9b7ae27..2a566a858 100644 --- a/demo/renderer/heatmap.ts +++ b/demo/renderer/heatmap.ts @@ -3,13 +3,24 @@ import { type TrackDef, TrackType } from './main'; import { type HeatmapTrackOptions } from '@gosling-lang/heatmap'; import type { CompleteThemeDeep } from '../../src/core/utils/theme'; import { computeChromSizes } from '../../src/core/utils/assembly'; +import { getAxisTrackDef } from './axis'; +import { type AxisTrackOptions } from '@gosling-lang/genomic-axis'; export function processHeatmapTrack( track: Track, boundingBox: { x: number; y: number; width: number; height: number }, theme: Required -): TrackDef[] { - const trackDefs: TrackDef[] = []; +): (TrackDef | TrackDef)[] { + const trackDefs: (TrackDef | TrackDef)[] = []; + + // Adds the axis tracks if needed + const [newTrackBbox, axisTrackDef] = getAxisTrackDef(track, boundingBox, theme); + if (axisTrackDef) { + trackDefs.push(axisTrackDef); + // modify the bounding box to exclude the axis track + boundingBox = newTrackBbox; + } + const heatmapOptions = getHeatmapOptions(track, theme); trackDefs.push({ type: TrackType.Heatmap, diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index 8c0e20aaf..10a3e46c8 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -131,10 +131,8 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE const yDomain = getEncodingSignal(trackDef.trackId, 'y', linkedEncodings); console.warn('domains,', xDomain, yDomain); if (!xDomain || !yDomain) return; + const datafetcher = getDataFetcher(options.spec); - // const xDomain = signal<[number, number]>([0, 3088269832]); - // const yDomain = signal<[number, number]>([0, 3088269832]); - console.warn('making track'); new HeatmapTrack(options, datafetcher, pixiManager.makeContainer(boundingBox)).addInteractor(plot => panZoomHeatmap(plot, xDomain, yDomain) ); From 00a0c2f17cfccd454ad8173a3e4e686da4bbac42 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 26 Jun 2024 15:46:08 -0400 Subject: [PATCH 082/139] feat: basic reverse orientation --- src/tracks/genomic-axis/axis-track-plot.ts | 54 ++++++++++++++++------ 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/src/tracks/genomic-axis/axis-track-plot.ts b/src/tracks/genomic-axis/axis-track-plot.ts index 70db6aaa3..17ff6424c 100644 --- a/src/tracks/genomic-axis/axis-track-plot.ts +++ b/src/tracks/genomic-axis/axis-track-plot.ts @@ -22,7 +22,10 @@ function wheelDelta(event: WheelEvent) { export class AxisTrack extends AxisTrackClass { xDomain: Signal; zoomStartScale = scaleLinear(); - #element: HTMLElement; + domOverlay: HTMLElement; + width: number; + height: number; + orientation: 'horizontal' | 'vertical'; constructor( options: AxisTrackOptions, @@ -30,15 +33,14 @@ export class AxisTrack extends AxisTrackClass { containers: { pixiContainer: PIXI.Container; overlayDiv: HTMLElement; - } + }, + orientation: 'horizontal' | 'vertical' = 'horizontal' ) { const { pixiContainer, overlayDiv } = containers; - const height = overlayDiv.clientHeight; - const width = overlayDiv.clientWidth; // Create a new svg element. The brush will be drawn on this element const svgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svgElement.style.width = `${width}px`; - svgElement.style.height = `${height}px`; + svgElement.style.width = `${overlayDiv.clientWidth}px`; + svgElement.style.height = `${overlayDiv.clientHeight}px`; // Add it to the overlay div overlayDiv.appendChild(svgElement); @@ -58,13 +60,26 @@ export class AxisTrack extends AxisTrackClass { super(context, options); + this.orientation = orientation; + if (this.orientation === 'horizontal') { + this.width = overlayDiv.clientWidth; + this.height = overlayDiv.clientHeight; + } else { + // The width and height are swapped because the scene is rotated + this.width = overlayDiv.clientHeight; + this.height = overlayDiv.clientWidth; + this.scene.rotation = Math.PI / 2 + Math.PI; + const position = this.scene.position; + this.scene.position.set(position.x, position.y + this.width); + } + this.xDomain = xDomain; - this.#element = overlayDiv; + this.domOverlay = overlayDiv; // Now we need to initialize all of the properties that would normally be set by HiGlassComponent - this.setDimensions([width, height]); + this.setDimensions([this.width, this.height]); this.setPosition([0, 0]); // Create some scales which span the whole genome - const refXScale = scaleLinear().domain(xDomain.value).range([0, width]); + const refXScale = scaleLinear().domain(xDomain.value).range([0, this.width]); const refYScale = scaleLinear(); // This doesn't get used anywhere but we need to pass it in // Set the scales this.zoomed(refXScale, refYScale); @@ -75,26 +90,35 @@ export class AxisTrack extends AxisTrackClass { } #addZoom(): void { - const baseScale = scaleLinear().domain(this.xDomain.value).range([0, this.#element.clientWidth]); + const baseScale = scaleLinear().domain(this.xDomain.value).range([0, this.width]); // This function will be called every time the user zooms const zoomed = (event: D3ZoomEvent) => { - const newXDomain = event.transform.rescaleX(this.zoomStartScale).domain(); - this.xDomain.value = newXDomain; + if (this.orientation === 'vertical') { + const newXDomain = event.transform.rescaleY(this.zoomStartScale).domain(); + this.xDomain.value = newXDomain; + } + if (this.orientation === 'horizontal') { + const newXDomain = event.transform.rescaleX(this.zoomStartScale).domain(); + this.xDomain.value = newXDomain; + } }; // Create the zoom behavior const zoomBehavior = zoom() .wheelDelta(wheelDelta) // @ts-expect-error We need to reset the transform when the user stops zooming - .on('end', () => (this.#element.__zoom = new ZoomTransform(1, 0, 0))) + .on('end', () => (this.domOverlay.__zoom = new ZoomTransform(1, 0, 0))) .on('start', () => { - this.zoomStartScale.domain(this.xDomain.value).range([0, this.#element.clientWidth]); + if (this.orientation === 'horizontal') + this.zoomStartScale.domain(this.xDomain.value).range([0, this.width]); + if (this.orientation === 'vertical') + this.zoomStartScale.domain(this.xDomain.value).range([this.width, 0]); }) .on('zoom', zoomed.bind(this)); // Apply the zoom behavior to the overlay div - select(this.#element).call(zoomBehavior); + select(this.domOverlay).call(zoomBehavior); // Every time the domain gets changed we want to update the zoom effect(() => { From 2c0fb9f0a0e612502ec71593331846a73b05498b Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 26 Jun 2024 15:46:30 -0400 Subject: [PATCH 083/139] feat: reverse example --- demo/App.tsx | 112 ++++++++++++--- demo/examples/index.ts | 1 + demo/examples/left-track-example.ts | 202 ++++++++++++++++++++++++++++ 3 files changed, 293 insertions(+), 22 deletions(-) create mode 100644 demo/examples/left-track-example.ts diff --git a/demo/App.tsx b/demo/App.tsx index caad025e3..2bd1970ec 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -8,7 +8,8 @@ import { addAxisTrack, addLinearBrush, addBigwig, - addHeatmap + addHeatmap, + addLeftAxisTrack } from './examples'; import { compile } from '../src/compiler/compile'; import { getTheme } from '../src/core/utils/theme'; @@ -37,29 +38,30 @@ function App() { // addLinearBrush(pixiManager); // addBigwig(pixiManager); // addHeatmap(pixiManager); + addLeftAxisTrack(pixiManager); - const callback = ( - hg: HiGlassSpec, - size, - gs: GoslingSpec, - tracksAndViews, - idTable, - trackInfos: TrackInfo[], - theme: Require - ) => { - console.warn(trackInfos); - console.warn(tracksAndViews); - console.warn(gs); - // showTrackInfoPositions(trackInfos, pixiManager); - const linkedEncodings = getLinkedEncodings(gs); - console.warn('linkedEncodings', linkedEncodings); - const trackDefs = createTrackDefs(trackInfos, theme); - console.warn('trackDefs', trackDefs); - renderTrackDefs(trackDefs, linkedEncodings, pixiManager); - }; + // const callback = ( + // hg: HiGlassSpec, + // size, + // gs: GoslingSpec, + // tracksAndViews, + // idTable, + // trackInfos: TrackInfo[], + // theme: Require + // ) => { + // console.warn(trackInfos); + // console.warn(tracksAndViews); + // console.warn(gs); + // // showTrackInfoPositions(trackInfos, pixiManager); + // const linkedEncodings = getLinkedEncodings(gs); + // console.warn('linkedEncodings', linkedEncodings); + // const trackDefs = createTrackDefs(trackInfos, theme); + // console.warn('trackDefs', trackDefs); + // renderTrackDefs(trackDefs, linkedEncodings, pixiManager); + // }; - // Compile the spec - compile(matrix, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); + // // Compile the spec + // compile(simple, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); }, []); return ( @@ -75,6 +77,46 @@ function App() { export default App; +const matrix2 = { + xDomain: { chromosome: 'chr7', interval: [77700000, 81000000] }, + tracks: [ + { + layout: 'linear', + width: 600, + height: 180, + data: { + url: 'https://resgen.io/api/v1/tileset_info/?d=UvVPeLHuRDiYA3qwFlm7xQ', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1'], + binSize: 5 + }, + mark: 'bar', + x: { field: 'start', type: 'genomic', axis: 'bottom' }, + xe: { field: 'end', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative', axis: 'right' }, + size: { value: 5 } + }, + { + title: 'HFFc6_Micro-C', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=hffc6-microc-hg38', + type: 'matrix' + }, + mark: 'bar', + x: { field: 'xs', type: 'genomic', axis: 'none' }, + xe: { field: 'xe', type: 'genomic', axis: 'none' }, + y: { field: 'ys', type: 'genomic', axis: 'left' }, + ye: { field: 'ye', type: 'genomic', axis: 'none' }, + color: { field: 'value', type: 'quantitative', range: 'warm' }, + width: 600, + height: 600 + } + ] +}; + const matrix = { xDomain: { chromosome: 'chr7', interval: [77700000, 81000000] }, tracks: [ @@ -96,10 +138,36 @@ const matrix = { ] }; +const simple = { + orientation: 'vertical', + tracks: [ + { + layout: 'linear', + width: 800, + height: 180, + data: { + url: 'https://resgen.io/api/v1/tileset_info/?d=UvVPeLHuRDiYA3qwFlm7xQ', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1'], + binSize: 5 + }, + mark: 'bar', + x: { field: 'start', type: 'genomic', axis: 'bottom' }, + xe: { field: 'end', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative', axis: 'right' }, + size: { value: 5 } + } + ] +}; + const spec = { title: 'Basic Marks: line', subtitle: 'Tutorial Examples', layout: 'linear', + orientation: 'vertical', tracks: [ { layout: 'circular', diff --git a/demo/examples/index.ts b/demo/examples/index.ts index b232847de..48088543e 100644 --- a/demo/examples/index.ts +++ b/demo/examples/index.ts @@ -6,3 +6,4 @@ export { addAxisTrack } from './axis-track-example'; export { addLinearBrush } from './brush-linear-example'; export { addBigwig } from './bigwig-data-example'; export { addHeatmap } from './heatmap-example'; +export { addLeftAxisTrack } from './left-track-example'; diff --git a/demo/examples/left-track-example.ts b/demo/examples/left-track-example.ts new file mode 100644 index 000000000..736245aa6 --- /dev/null +++ b/demo/examples/left-track-example.ts @@ -0,0 +1,202 @@ +import { PixiManager } from '@pixi-manager'; +import { signal } from '@preact/signals-core'; +import { AxisTrack } from '@gosling-lang/genomic-axis'; +import { LeftTrackModifier } from '../../src/tracks/utils'; + +export function addLeftAxisTrack(pixiManager: PixiManager) { + const view1Domain = signal<[number, number]>([0, 3000000000]); + // Axis track + const posAxis = { + x: 150, + y: 300, + width: 200, + height: 800 + }; + const { pixiContainer, overlayDiv } = pixiManager.makeContainer(posAxis); + overlayDiv.style.border = '1px solid black'; + const axis = new AxisTrack(axisTrack, view1Domain, { pixiContainer, overlayDiv }, 'vertical'); +} + +export const axisTrack = { + id: '8fa0dea6-19df-4e06-85fc-09aea7700a29-right-axis', + layout: 'linear', + innerRadius: 340, + outerRadius: null, + startAngle: 0, + endAngle: 360, + theme: { + base: 'light', + root: { + background: 'white', + titleColor: 'black', + titleBackgroundColor: 'transparent', + titleFontSize: 18, + titleFontFamily: 'Arial', + titleAlign: 'left', + titleFontWeight: 'bold', + subtitleColor: 'gray', + subtitleBackgroundColor: 'transparent', + subtitleFontSize: 16, + subtitleFontFamily: 'Arial', + subtitleFontWeight: 'normal', + subtitleAlign: 'left', + showMousePosition: true, + mousePositionColor: '#000000' + }, + track: { + background: 'transparent', + alternatingBackground: 'transparent', + titleColor: 'black', + titleBackground: 'white', + titleFontSize: 24, + titleAlign: 'left', + outline: 'black', + outlineWidth: 1 + }, + legend: { + position: 'top', + background: 'white', + backgroundOpacity: 0.7, + labelColor: 'black', + labelFontSize: 12, + labelFontWeight: 'normal', + labelFontFamily: 'Arial', + backgroundStroke: '#DBDBDB', + tickColor: 'black' + }, + axis: { + tickColor: 'black', + labelColor: 'black', + labelMargin: 5, + labelExcludeChrPrefix: false, + labelFontSize: 12, + labelFontWeight: 'normal', + labelFontFamily: 'Arial', + baselineColor: 'black', + gridColor: '#E3E3E3', + gridStrokeWidth: 1, + gridStrokeType: 'solid', + gridStrokeDash: [4, 4] + }, + markCommon: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + point: { + color: '#E79F00', + size: 3, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + rect: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + triangle: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + area: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + line: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + bar: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + rule: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 1, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + link: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 1, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + text: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6], + textAnchor: 'middle', + textFontWeight: 'normal' + }, + brush: { + color: 'gray', + size: 1, + stroke: 'black', + strokeWidth: 1, + opacity: 0.3, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + } + }, + assembly: 'hg38', + stroke: 'transparent', + color: 'black', + labelMargin: 5, + excludeChrPrefix: false, + fontSize: 12, + fontFamily: 'Arial', + fontWeight: 'normal', + tickColor: 'black', + tickFormat: 'plain', + tickPositions: 'even', + reverseOrientation: true, + labelPosition: 'none', + labelColor: 'black', + labelTextOpacity: 0.4, + trackBorderWidth: 0, + trackBorderColor: 'black', + backgroundColor: 'transparent', + showMousePosition: false +}; From 73536925d64d70dc384904be93c33c5d260272e8 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 26 Jun 2024 16:00:48 -0400 Subject: [PATCH 084/139] feat: panZoom for axis track --- demo/examples/left-track-example.ts | 6 +- src/interactors/panZoom.ts | 18 +- src/tracks/genomic-axis/axis-track-plot.ts | 13 +- src/tracks/utils.ts | 257 ++++++++++++++++++++- 4 files changed, 284 insertions(+), 10 deletions(-) diff --git a/demo/examples/left-track-example.ts b/demo/examples/left-track-example.ts index 736245aa6..e5578d156 100644 --- a/demo/examples/left-track-example.ts +++ b/demo/examples/left-track-example.ts @@ -2,6 +2,7 @@ import { PixiManager } from '@pixi-manager'; import { signal } from '@preact/signals-core'; import { AxisTrack } from '@gosling-lang/genomic-axis'; import { LeftTrackModifier } from '../../src/tracks/utils'; +import { panZoom } from '@gosling-lang/interactors'; export function addLeftAxisTrack(pixiManager: PixiManager) { const view1Domain = signal<[number, number]>([0, 3000000000]); @@ -9,12 +10,13 @@ export function addLeftAxisTrack(pixiManager: PixiManager) { const posAxis = { x: 150, y: 300, - width: 200, - height: 800 + width: 800, + height: 200 }; const { pixiContainer, overlayDiv } = pixiManager.makeContainer(posAxis); overlayDiv.style.border = '1px solid black'; const axis = new AxisTrack(axisTrack, view1Domain, { pixiContainer, overlayDiv }, 'vertical'); + axis.addInteractor(plot => panZoom(plot, view1Domain)); } export const axisTrack = { diff --git a/src/interactors/panZoom.ts b/src/interactors/panZoom.ts index 4684ac8e2..fc81a2c42 100644 --- a/src/interactors/panZoom.ts +++ b/src/interactors/panZoom.ts @@ -10,13 +10,19 @@ import { zoomWheelBehavior, type Plot } from '../tracks/utils'; export function panZoom(plot: Plot, xDomain: Signal<[number, number]>) { plot.xDomain = xDomain; // Update the xDomain with the signal - const baseScale = scaleLinear().range([0, plot.domOverlay.clientWidth]); + const baseScale = scaleLinear().range([0, plot.width]); // This will store the xDomain when the user starts zooming const zoomStartScale = scaleLinear(); // This function will be called every time the user zooms const zoomed = (event: D3ZoomEvent) => { - const newXDomain = event.transform.rescaleX(zoomStartScale).domain(); - xDomain.value = newXDomain as [number, number]; + if (plot.orientation === undefined || plot.orientation === 'horizontal') { + const newXDomain = event.transform.rescaleX(zoomStartScale).domain(); + xDomain.value = newXDomain as [number, number]; + } + if (plot.orientation === 'vertical') { + const newXDomain = event.transform.rescaleY(zoomStartScale).domain(); + xDomain.value = newXDomain as [number, number]; + } }; // Create the zoom behavior const zoomBehavior = zoom() @@ -34,7 +40,11 @@ export function panZoom(plot: Plot, xDomain: Signal<[number, number]>) { // @ts-expect-error We need to reset the transform when the user stops zooming .on('end', () => (plot.domOverlay.__zoom = new ZoomTransform(1, 0, 0))) .on('start', () => { - zoomStartScale.domain(xDomain.value).range([0, plot.domOverlay.clientWidth]); + if (plot.orientation === undefined || plot.orientation === 'horizontal') { + zoomStartScale.domain(xDomain.value).range([0, plot.width]); + } else if (plot.orientation === 'vertical') { + zoomStartScale.domain(xDomain.value).range([plot.width, 0]); + } }) .on('zoom', zoomed); diff --git a/src/tracks/genomic-axis/axis-track-plot.ts b/src/tracks/genomic-axis/axis-track-plot.ts index 17ff6424c..41ecc38b0 100644 --- a/src/tracks/genomic-axis/axis-track-plot.ts +++ b/src/tracks/genomic-axis/axis-track-plot.ts @@ -20,7 +20,7 @@ function wheelDelta(event: WheelEvent) { } export class AxisTrack extends AxisTrackClass { - xDomain: Signal; + xDomain: Signal<[number, number]>; zoomStartScale = scaleLinear(); domOverlay: HTMLElement; width: number; @@ -29,7 +29,7 @@ export class AxisTrack extends AxisTrackClass { constructor( options: AxisTrackOptions, - xDomain: Signal, + xDomain: Signal<[number, number]>, containers: { pixiContainer: PIXI.Container; overlayDiv: HTMLElement; @@ -68,8 +68,10 @@ export class AxisTrack extends AxisTrackClass { // The width and height are swapped because the scene is rotated this.width = overlayDiv.clientHeight; this.height = overlayDiv.clientWidth; + // We rotate the scene 90 degrees to the left this.scene.rotation = Math.PI / 2 + Math.PI; const position = this.scene.position; + // We move the scene down because the rotation point is the top left corner this.scene.position.set(position.x, position.y + this.width); } @@ -86,7 +88,12 @@ export class AxisTrack extends AxisTrackClass { this.refScalesChanged(refXScale, refYScale); // Add the zoom - this.#addZoom(); + // this.#addZoom(); + } + + addInteractor(interactor: (plot: AxisTrack) => void) { + interactor(this); + return this; // For chaining } #addZoom(): void { diff --git a/src/tracks/utils.ts b/src/tracks/utils.ts index 8837b94d4..1089856ac 100644 --- a/src/tracks/utils.ts +++ b/src/tracks/utils.ts @@ -1,5 +1,6 @@ import { type Signal } from '@preact/signals-core'; import { type ScaleLinear } from 'd3-scale'; +import * as PIXI from 'pixi.js'; // Default d3 zoom feels slow so we use this instead // https://d3js.org/d3-zoom#zoom_wheelDelta @@ -18,12 +19,15 @@ export function zoomWheelBehavior(event: WheelEvent) { export interface Plot { addInteractor(interactor: (plot: Plot) => void): Plot; domOverlay: HTMLElement; + orientation?: 'horizontal' | 'vertical'; + width: number; + height: number; xDomain: Signal<[number, number]>; zoomed(xScale: ScaleLinear, yScale: ScaleLinear): void; } /** - * This is the interface that plots must implement for Interactors to work + * This this is the plot interface for the PanZoomHeatmap interactor */ export interface HeatmapPlot { addInteractor(interactor: (plot: Plot) => void): Plot; @@ -39,3 +43,254 @@ export interface HeatmapPlot { ty: number ): void; } + +/** + * This rotates a track 90 degrees to the left + */ +export class LeftTrackModifier { + scene: PIXI.Container; + originalTrack: any; + pBase: PIXI.Graphics; + moveToOrigin: PIXI.Graphics; + svgOutput: any; + dimensions: any; + position: any; + + constructor(originalTrack) { + this.scene = originalTrack.scene; + + this.originalTrack = originalTrack; + this.pBase = new PIXI.Graphics(); + + this.scene.removeChild(originalTrack.pBase); + this.scene.addChild(this.pBase); + + this.moveToOrigin = new PIXI.Graphics(); + this.moveToOrigin.addChild(originalTrack.pBase); + + this.pBase.addChild(this.moveToOrigin); + + this.moveToOrigin.rotation = Math.PI / 2; + + // Indicate that the track has been flipped. This is generally the same as + // `originalTrack.flipText` but `flipText` is semantically not that clear + originalTrack.isLeftModified = true; + + // If the original track has text labels, we need to flip + // them horizontally, otherwise they'll be mirrored. + originalTrack.flipText = true; + this.svgOutput = null; + + if (originalTrack.gBase && originalTrack.gMain) { + this.originalTrack.gBase.attr( + 'transform', + `translate(${this.moveToOrigin.position.x},${this.moveToOrigin.position.y}) + rotate(90) + scale(${this.moveToOrigin.scale.x},${this.moveToOrigin.scale.y})` + ); + this.originalTrack.gMain.attr( + 'transform', + `translate(${this.originalTrack.pBase.position.x},${this.originalTrack.pBase.position.y})` + ); + } + } + + remove() { + this.originalTrack.remove(); + + this.pBase.clear(); + this.scene.removeChild(this.pBase); + } + + setDimensions(newDimensions) { + this.dimensions = newDimensions; + + const reversedDimensions = [newDimensions[1], newDimensions[0]]; + + this.originalTrack.setDimensions(reversedDimensions); + } + + setPosition(newPosition) { + this.position = newPosition; + + this.originalTrack.setPosition(newPosition); + + this.originalTrack.pBase.position.x = -this.originalTrack.position[0]; + this.originalTrack.pBase.position.y = -this.originalTrack.position[1]; + + this.moveToOrigin.scale.y = -1; + this.moveToOrigin.scale.x = 1; + this.moveToOrigin.position.x = this.originalTrack.position[0]; + this.moveToOrigin.position.y = this.originalTrack.position[1]; + + if (this.originalTrack.gMain) { + this.originalTrack.gBase.attr( + 'transform', + `translate(${this.moveToOrigin.position.x},${this.moveToOrigin.position.y}) + rotate(90) + scale(${this.moveToOrigin.scale.x},${this.moveToOrigin.scale.y})` + ); + this.originalTrack.gMain.attr( + 'transform', + `translate(${this.originalTrack.pBase.position.x},${this.originalTrack.pBase.position.y})` + ); + } + } + + refXScale(_) { + /** + * Either get or set the reference xScale + */ + if (!arguments.length) { + return this.originalTrack._refYScale; + } + + this.originalTrack._refXScale = _; + + return this; + } + + refYScale(_) { + /** + * Either get or set the reference yScale + */ + if (!arguments.length) { + return this.originalTrack._refXScale; + } + + this.originalTrack._refYScale = _; + + return this; + } + + xScale(_) { + /** + * Either get or set the xScale + */ + if (!arguments.length) { + return this.originalTrack._xScale; + } + + this.originalTrack._yScale = _; + + return this; + } + + yScale(_) { + /** + * Either get or set the yScale + */ + if (!arguments.length) { + return this.originalTrack._yScale; + } + + this.originalTrack._xScale = _; + + return this; + } + + getMouseOverHtml(trackX, trackY) { + return this.originalTrack.getMouseOverHtml(trackY, trackX); + } + + clickOutside() { + this.originalTrack.clickOutside(); + } + + click(...args) { + this.originalTrack.click(...args); + } + + draw() { + this.originalTrack.draw(); + } + + zoomed(newXScale, newYScale, k = 1, tx = 0, ty = 0, xPositionOffset = 0, yPositionOffset = 0) { + this.xScale(newXScale); + this.yScale(newYScale); + + if (this.originalTrack.leftTrackZoomed) { + if (this.originalTrack.refreshTiles) { + // some tracks don't have refreshTiles (e.g. PixiTrack) + this.originalTrack.refreshTiles(); + } + // the track implements its own left-oriented zooming and scrolling + this.originalTrack.leftTrackZoomed(newXScale, newYScale, k, tx, ty); + this.originalTrack.draw(); + return; + } + + const offset = this.originalTrack._xScale(0) - k * this.originalTrack._refXScale(0); + this.originalTrack.pMobile.position.x = offset + this.originalTrack.position[0]; + this.originalTrack.pMobile.position.y = this.originalTrack.position[1] + this.originalTrack.dimensions[1]; + + this.originalTrack.pMobile.scale.x = k; + this.originalTrack.pMobile.scale.y = k; + + if (this.originalTrack.options.oneDHeatmapFlipped) { + this.originalTrack.pMobile.scale.y = -k; + this.originalTrack.pMobile.position.y = this.originalTrack.position[1]; + } + + if (this.originalTrack.leftTrackDraw) { + // if the track implements leftTrackDraw we just redraw the track and + // won't call the track's zoomed method + if (this.originalTrack.refreshTiles) { + this.originalTrack.refreshTiles(); + } + this.originalTrack.leftTrackDraw(); + return; + } + + this.originalTrack.zoomed(this.xScale(), this.yScale()); + } + + zoomedY(yPos, kMultiplier) { + this.originalTrack.zoomedY(yPos, kMultiplier); + } + + movedY(dY) { + this.originalTrack.movedY(dY); + } + + refScalesChanged(refXScale, refYScale) { + this.originalTrack.refScalesChanged(refYScale, refXScale); + } + + rerender(options) { + this.originalTrack.rerender(options); + } + + exportSVG() { + const output = document.createElement('g'); + output.setAttribute( + 'transform', + `translate(${this.moveToOrigin.position.x},${this.moveToOrigin.position.y}) + rotate(90) + scale(${this.moveToOrigin.scale.x},${this.moveToOrigin.scale.y})` + ); + + if (this.originalTrack.exportSVG) { + const g = document.createElement('g'); + g.setAttribute( + 'transform', + `translate(${this.originalTrack.pBase.position.x}, ${this.originalTrack.pBase.position.y})` + ); + + g.appendChild(this.originalTrack.exportSVG()[0]); + output.appendChild(g); + } + + return [output, output]; + } + + respondsToPosition(x, y) { + return ( + x >= this.position[0] && + x <= this.dimensions[0] + this.position[0] && + y >= this.position[1] && + y <= this.dimensions[1] + this.position[1] + ); + } +} + From 9ac88742136bf176ebb3a1982eb09fdd8933e49a Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 26 Jun 2024 16:16:02 -0400 Subject: [PATCH 085/139] feat: vertical Gosling track --- demo/App.tsx | 6 +- demo/examples/gosling-track-vertical.ts | 246 ++++++++++++++++++ demo/examples/index.ts | 1 + .../gosling-track/gosling-track-plot.ts | 30 ++- 4 files changed, 274 insertions(+), 9 deletions(-) create mode 100644 demo/examples/gosling-track-vertical.ts diff --git a/demo/App.tsx b/demo/App.tsx index 2bd1970ec..19e38d18b 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -9,7 +9,8 @@ import { addLinearBrush, addBigwig, addHeatmap, - addLeftAxisTrack + addLeftAxisTrack, + addGoslingVertical } from './examples'; import { compile } from '../src/compiler/compile'; import { getTheme } from '../src/core/utils/theme'; @@ -38,7 +39,8 @@ function App() { // addLinearBrush(pixiManager); // addBigwig(pixiManager); // addHeatmap(pixiManager); - addLeftAxisTrack(pixiManager); + // addLeftAxisTrack(pixiManager); + addGoslingVertical(pixiManager); // const callback = ( // hg: HiGlassSpec, diff --git a/demo/examples/gosling-track-vertical.ts b/demo/examples/gosling-track-vertical.ts new file mode 100644 index 000000000..c4bba38e7 --- /dev/null +++ b/demo/examples/gosling-track-vertical.ts @@ -0,0 +1,246 @@ +import { PixiManager } from '@pixi-manager'; +import { GoslingTrack } from '@gosling-lang/gosling-track'; +import { DataFetcher } from '@higlass/datafetcher'; +import { fakePubSub } from '@higlass/utils'; +import { signal } from '@preact/signals-core'; +import { panZoom } from '@gosling-lang/interactors'; + +export function addGoslingVertical(pixiManager: PixiManager) { + const circularDomain = signal<[number, number]>([0, 248956422]); + // All tracks use this datafetcher + const dataFetcher = new DataFetcher( + { + server: 'https://server.gosling-lang.org/api/v1', + tilesetUid: 'cistrome-multivec' + }, + fakePubSub + ); + + // Circular track + const pos0 = { x: 10, y: 200, width: 150, height: 600 }; + new GoslingTrack( + circularTrackOptions, + dataFetcher, + pixiManager.makeContainer(pos0), + circularDomain, + 'vertical' + ).addInteractor(plot => panZoom(plot, circularDomain)); +} +export const circularTrackOptions = { + id: '8a003683-9a57-4202-bf00-1c4d9b11f13d', + siblingIds: ['8a003683-9a57-4202-bf00-1c4d9b11f13d'], + showMousePosition: false, + mousePositionColor: '#000000', + name: ' ', + labelPosition: 'none', + labelShowResolution: false, + labelColor: 'black', + labelBackgroundColor: 'white', + labelBackgroundOpacity: 0.5, + labelTextOpacity: 1, + labelLeftMargin: 1, + labelTopMargin: 1, + labelRightMargin: 0, + labelBottomMargin: 0, + backgroundColor: 'transparent', + spec: { + spacing: 5, + static: true, + layout: 'linear', + xDomain: { chromosome: 'chr1' }, + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=cistrome-multivec', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1', 'sample 2', 'sample 3', 'sample 4'] + }, + x: { + field: 'start', + type: 'genomic', + domain: { chromosome: 'chr1' }, + axis: 'top' + }, + xe: { field: 'end', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative' }, + row: { field: 'sample', type: 'nominal' }, + color: { field: 'sample', type: 'nominal' }, + width: 250, + height: 250, + assembly: 'hg38', + orientation: 'horizontal', + zoomLimits: [1, null], + centerRadius: 0.4, + xOffset: 0, + yOffset: 0, + style: {}, + id: '8a003683-9a57-4202-bf00-1c4d9b11f13d', + _overlay: [ + { mark: 'bar', style: {} }, + { mark: 'brush', x: {}, style: {} } + ], + overlayOnPreviousTrack: false, + outerRadius: 125, + innerRadius: 50, + startAngle: 7.2, + endAngle: 352.8, + _renderingId: '085fb2cf-83dd-4d47-b7da-7fc96bbde6a1' + }, + theme: { + base: 'light', + root: { + background: 'white', + titleColor: 'black', + titleBackgroundColor: 'transparent', + titleFontSize: 18, + titleFontFamily: 'Arial', + titleAlign: 'left', + titleFontWeight: 'bold', + subtitleColor: 'gray', + subtitleBackgroundColor: 'transparent', + subtitleFontSize: 16, + subtitleFontFamily: 'Arial', + subtitleFontWeight: 'normal', + subtitleAlign: 'left', + showMousePosition: true, + mousePositionColor: '#000000' + }, + track: { + background: 'transparent', + alternatingBackground: 'transparent', + titleColor: 'black', + titleBackground: 'white', + titleFontSize: 24, + titleAlign: 'left', + outline: 'black', + outlineWidth: 1 + }, + legend: { + position: 'top', + background: 'white', + backgroundOpacity: 0.7, + labelColor: 'black', + labelFontSize: 12, + labelFontWeight: 'normal', + labelFontFamily: 'Arial', + backgroundStroke: '#DBDBDB', + tickColor: 'black' + }, + axis: { + tickColor: 'black', + labelColor: 'black', + labelMargin: 5, + labelExcludeChrPrefix: false, + labelFontSize: 12, + labelFontWeight: 'normal', + labelFontFamily: 'Arial', + baselineColor: 'black', + gridColor: '#E3E3E3', + gridStrokeWidth: 1, + gridStrokeType: 'solid', + gridStrokeDash: [4, 4] + }, + markCommon: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + point: { + color: '#E79F00', + size: 3, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + rect: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + triangle: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + area: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + line: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + bar: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + rule: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 1, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + link: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 1, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + text: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6], + textAnchor: 'middle', + textFontWeight: 'normal' + }, + brush: { + color: 'gray', + size: 1, + stroke: 'black', + strokeWidth: 1, + opacity: 0.3, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + } + } +}; diff --git a/demo/examples/index.ts b/demo/examples/index.ts index 48088543e..039ebdafe 100644 --- a/demo/examples/index.ts +++ b/demo/examples/index.ts @@ -7,3 +7,4 @@ export { addLinearBrush } from './brush-linear-example'; export { addBigwig } from './bigwig-data-example'; export { addHeatmap } from './heatmap-example'; export { addLeftAxisTrack } from './left-track-example'; +export { addGoslingVertical } from './gosling-track-vertical'; diff --git a/src/tracks/gosling-track/gosling-track-plot.ts b/src/tracks/gosling-track/gosling-track-plot.ts index 03fae435f..008b1efee 100644 --- a/src/tracks/gosling-track/gosling-track-plot.ts +++ b/src/tracks/gosling-track/gosling-track-plot.ts @@ -12,6 +12,9 @@ export class GoslingTrack extends GoslingTrackClass implements Plot { xDomain: Signal<[number, number]>; // This has to be a signal because it will potentially be updated by interactors zoomStartScale = scaleLinear(); domOverlay: HTMLElement; + width: number; + height: number; + orientation: 'horizontal' | 'vertical'; constructor( options: GoslingTrackOptions, @@ -20,18 +23,17 @@ export class GoslingTrack extends GoslingTrackClass implements Plot { pixiContainer: PIXI.Container; overlayDiv: HTMLElement; }, - xDomain = signal<[number, number]>([0, 3088269832]) + xDomain = signal<[number, number]>([0, 3088269832]), + orientation: 'horizontal' | 'vertical' = 'horizontal' ) { const { pixiContainer, overlayDiv } = containers; - const height = overlayDiv.clientHeight; - const width = overlayDiv.clientWidth; // If there is already an svg element, use it. Otherwise, create a new one const existingSvgElement = overlayDiv.querySelector('svg'); const svgElement = existingSvgElement || document.createElementNS('http://www.w3.org/2000/svg', 'svg'); if (!existingSvgElement) { - svgElement.style.width = `${width}px`; - svgElement.style.height = `${height}px`; + svgElement.style.width = `${overlayDiv.clientWidth}px`; + svgElement.style.height = `${overlayDiv.clientHeight}px`; overlayDiv.appendChild(svgElement); } @@ -56,14 +58,28 @@ export class GoslingTrack extends GoslingTrackClass implements Plot { }; super(context, options); + this.orientation = orientation; + if (this.orientation === 'horizontal') { + this.width = overlayDiv.clientWidth; + this.height = overlayDiv.clientHeight; + } else { + // The width and height are swapped because the scene is rotated + this.width = overlayDiv.clientHeight; + this.height = overlayDiv.clientWidth; + // We rotate the scene 90 degrees to the left + this.scene.rotation = Math.PI / 2 + Math.PI; + const position = this.scene.position; + // We move the scene down because the rotation point is the top left corner + this.scene.position.set(position.x, position.y + this.width); + } this.xDomain = xDomain; this.domOverlay = overlayDiv; // Now we need to initialize all of the properties that would normally be set by HiGlassComponent - this.setDimensions([width, height]); + this.setDimensions([this.width, this.height]); this.setPosition([0, 0]); // Create some scales which span the whole genome - const refXScale = scaleLinear().domain(xDomain.value).range([0, width]); + const refXScale = scaleLinear().domain(xDomain.value).range([0, this.width]); const refYScale = scaleLinear(); // This doesn't get used anywhere but we need to pass it in // Set the scales this.zoomed(refXScale, refYScale); From 358002f5789211fccf33b5c82ad396285e13b387 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 26 Jun 2024 16:29:50 -0400 Subject: [PATCH 086/139] feat: make brushes use new interactor --- demo/App.tsx | 6 +++--- demo/examples/left-track-example.ts | 4 ++-- .../brush-circular/brush-circular-plot.ts | 18 ++++++++---------- src/tracks/brush-linear/brush-linear-plot.ts | 15 ++++++++------- 4 files changed, 21 insertions(+), 22 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index 19e38d18b..7473a3553 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -39,8 +39,8 @@ function App() { // addLinearBrush(pixiManager); // addBigwig(pixiManager); // addHeatmap(pixiManager); - // addLeftAxisTrack(pixiManager); - addGoslingVertical(pixiManager); + addLeftAxisTrack(pixiManager); + // addGoslingVertical(pixiManager); // const callback = ( // hg: HiGlassSpec, @@ -63,7 +63,7 @@ function App() { // }; // // Compile the spec - // compile(simple, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); + // compile(doubleBrush, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); }, []); return ( diff --git a/demo/examples/left-track-example.ts b/demo/examples/left-track-example.ts index e5578d156..a171ac490 100644 --- a/demo/examples/left-track-example.ts +++ b/demo/examples/left-track-example.ts @@ -10,8 +10,8 @@ export function addLeftAxisTrack(pixiManager: PixiManager) { const posAxis = { x: 150, y: 300, - width: 800, - height: 200 + width: 200, + height: 800 }; const { pixiContainer, overlayDiv } = pixiManager.makeContainer(posAxis); overlayDiv.style.border = '1px solid black'; diff --git a/src/tracks/brush-circular/brush-circular-plot.ts b/src/tracks/brush-circular/brush-circular-plot.ts index cc3aa2fe0..ceb7af322 100644 --- a/src/tracks/brush-circular/brush-circular-plot.ts +++ b/src/tracks/brush-circular/brush-circular-plot.ts @@ -4,16 +4,15 @@ import { type BrushCircularTrackContext } from './brush-circular'; import { scaleLinear } from 'd3-scale'; -import { ZoomTransform, type D3ZoomEvent, zoom } from 'd3-zoom'; -import { select } from 'd3-selection'; import { type Signal, effect, signal } from '@preact/signals-core'; -import { zoomWheelBehavior } from '../utils'; export class BrushCircularTrack extends CircularBrushTrackClass { xDomain: Signal; xBrushDomain: Signal; zoomStartScale = scaleLinear(); // This is the scale that we use to store the domain when the user starts zooming domOverlay: HTMLElement; // This is the div that we're going to apply the zoom behavior to + width: number; + height: number; constructor( options: BrushCircularTrackOptions, @@ -21,14 +20,12 @@ export class BrushCircularTrack extends CircularBrushTrackClass { domOverlay: HTMLElement, xDomain = signal<[number, number]>([0, 3088269832]) ) { - const height = domOverlay.clientHeight; - const width = domOverlay.clientWidth; // If there is already an svg element, use it. Otherwise, create a new one const existingSvgElement = domOverlay.querySelector('svg'); const svgElement = existingSvgElement || document.createElementNS('http://www.w3.org/2000/svg', 'svg'); if (!existingSvgElement) { - svgElement.style.width = `${width}px`; - svgElement.style.height = `${height}px`; + svgElement.style.width = `${domOverlay.clientWidth}px`; + svgElement.style.height = `${domOverlay.clientHeight}px`; domOverlay.appendChild(svgElement); } @@ -44,15 +41,16 @@ export class BrushCircularTrack extends CircularBrushTrackClass { }; super(context, options); - + this.width = domOverlay.clientWidth; + this.height = domOverlay.clientHeight; this.xDomain = xDomain; this.xBrushDomain = xBrushDomain; this.domOverlay = domOverlay; // Now we need to initialize all of the properties that would normally be set by HiGlassComponent - this.setDimensions([width, height]); + this.setDimensions([this.width, this.height]); this.setPosition([0, 0]); // Create some scales to pass in - const refXScale = scaleLinear().domain(xDomain.value).range([0, width]); + const refXScale = scaleLinear().domain(xDomain.value).range([0, this.width]); const refYScale = scaleLinear(); // This doesn't get used anywhere but we need to pass it in // Set the scales this.zoomed(refXScale, refYScale); diff --git a/src/tracks/brush-linear/brush-linear-plot.ts b/src/tracks/brush-linear/brush-linear-plot.ts index eb0d458f6..1a7ff8ec4 100644 --- a/src/tracks/brush-linear/brush-linear-plot.ts +++ b/src/tracks/brush-linear/brush-linear-plot.ts @@ -10,6 +10,8 @@ export class BrushLinearTrack extends BrushLinearTrackClass; xBrushDomain: Signal<[number, number]>; domOverlay: HTMLElement; + width: number; + height: number; constructor( options: BrushLinearTrackOptions, @@ -17,15 +19,12 @@ export class BrushLinearTrack extends BrushLinearTrackClass([0, 3088269832]) // Default domain ) { - const height = domOverlay.clientHeight; - const width = domOverlay.clientWidth; - // If there is already an svg element, use it. Otherwise, create a new one const existingSvgElement = domOverlay.querySelector('svg'); const svgElement = existingSvgElement || document.createElementNS('http://www.w3.org/2000/svg', 'svg'); if (!existingSvgElement) { - svgElement.style.width = `${width}px`; - svgElement.style.height = `${height}px`; + svgElement.style.width = `${domOverlay.clientWidth}px`; + svgElement.style.height = `${domOverlay.clientHeight}px`; domOverlay.appendChild(svgElement); } @@ -41,15 +40,17 @@ export class BrushLinearTrack extends BrushLinearTrackClass Date: Wed, 26 Jun 2024 17:09:54 -0400 Subject: [PATCH 087/139] feat: working example --- demo/App.tsx | 44 +++++++++---------- demo/renderer/main.ts | 12 ++++- src/core/mark/axis.ts | 8 ++-- .../gosling-track/gosling-track-model.ts | 10 ++--- 4 files changed, 41 insertions(+), 33 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index 7473a3553..2649d4b22 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -39,31 +39,31 @@ function App() { // addLinearBrush(pixiManager); // addBigwig(pixiManager); // addHeatmap(pixiManager); - addLeftAxisTrack(pixiManager); + // addLeftAxisTrack(pixiManager); // addGoslingVertical(pixiManager); - // const callback = ( - // hg: HiGlassSpec, - // size, - // gs: GoslingSpec, - // tracksAndViews, - // idTable, - // trackInfos: TrackInfo[], - // theme: Require - // ) => { - // console.warn(trackInfos); - // console.warn(tracksAndViews); - // console.warn(gs); - // // showTrackInfoPositions(trackInfos, pixiManager); - // const linkedEncodings = getLinkedEncodings(gs); - // console.warn('linkedEncodings', linkedEncodings); - // const trackDefs = createTrackDefs(trackInfos, theme); - // console.warn('trackDefs', trackDefs); - // renderTrackDefs(trackDefs, linkedEncodings, pixiManager); - // }; + const callback = ( + hg: HiGlassSpec, + size, + gs: GoslingSpec, + tracksAndViews, + idTable, + trackInfos: TrackInfo[], + theme: Require + ) => { + console.warn(trackInfos); + console.warn(tracksAndViews); + console.warn(gs); + // showTrackInfoPositions(trackInfos, pixiManager); + const linkedEncodings = getLinkedEncodings(gs); + console.warn('linkedEncodings', linkedEncodings); + const trackDefs = createTrackDefs(trackInfos, theme); + console.warn('trackDefs', trackDefs); + renderTrackDefs(trackDefs, linkedEncodings, pixiManager); + }; - // // Compile the spec - // compile(doubleBrush, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); + // Compile the spec + compile(simple, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); }, []); return ( diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index 10a3e46c8..d26897925 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -121,7 +121,13 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE if (!domain) return; const datafetcher = getDataFetcher(options.spec); - const gosPlot = new GoslingTrack(options, datafetcher, pixiManager.makeContainer(boundingBox), domain); + const gosPlot = new GoslingTrack( + options, + datafetcher, + pixiManager.makeContainer(boundingBox), + domain, + options.spec.orientation + ); if (!options.spec.static) { gosPlot.addInteractor(plot => panZoom(plot, domain)); } @@ -141,7 +147,9 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE const domain = getEncodingSignal(trackDef.trackId, 'x', linkedEncodings); if (!domain) return; - new AxisTrack(options, domain, pixiManager.makeContainer(boundingBox)); + new AxisTrack(options, domain, pixiManager.makeContainer(boundingBox), 'vertical').addInteractor(plot => + panZoom(plot, domain) + ); } if (type === TrackType.BrushLinear) { const domain = getEncodingSignal(trackDef.trackId, 'x', linkedEncodings); diff --git a/src/core/mark/axis.ts b/src/core/mark/axis.ts index 0d9d0ca53..8d29074dd 100644 --- a/src/core/mark/axis.ts +++ b/src/core/mark/axis.ts @@ -137,10 +137,10 @@ export function drawLinearYAxis( textGraphic.position.y = dy + rowHeight - y; // Flip labels when orientation is vertical - if (spec.orientation === 'vertical') { - textGraphic.anchor.x = isLeft ? 1 : 0; - textGraphic.scale.x *= -1; - } + // if (spec.orientation === 'vertical') { + // textGraphic.anchor.x = isLeft ? 1 : 0; + // textGraphic.scale.x *= -1; + // } graphics.addChild(textGraphic); }); }); diff --git a/src/tracks/gosling-track/gosling-track-model.ts b/src/tracks/gosling-track/gosling-track-model.ts index 7449cdd88..e8ef44da8 100644 --- a/src/tracks/gosling-track/gosling-track-model.ts +++ b/src/tracks/gosling-track/gosling-track-model.ts @@ -139,11 +139,11 @@ export class GoslingTrackModel { } // If this is vertical track, switch them. - if (spec.orientation === 'vertical') { - const width = spec.width; - spec.width = spec.height; - spec.height = width; - } + // if (spec.orientation === 'vertical') { + // const width = spec.width; + // spec.width = spec.height; + // spec.height = width; + // } // If axis presents, reserve a space to show axis const xOrY = this.getGenomicChannelKey(); From 8618c1ced3f5d5d17fd054267d741223ec247ac9 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 26 Jun 2024 19:58:56 -0400 Subject: [PATCH 088/139] feat: basic fix to heatmap scaling --- demo/App.tsx | 4 ++-- demo/renderer/main.ts | 2 +- src/interactors/panZoomHeatmap.ts | 19 +++++++++++++++---- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index 2649d4b22..be90159a2 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -63,7 +63,7 @@ function App() { }; // Compile the spec - compile(simple, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); + compile(matrix2, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); }, []); return ( @@ -110,7 +110,7 @@ const matrix2 = { mark: 'bar', x: { field: 'xs', type: 'genomic', axis: 'none' }, xe: { field: 'xe', type: 'genomic', axis: 'none' }, - y: { field: 'ys', type: 'genomic', axis: 'left' }, + y: { field: 'ys', type: 'genomic', axis: 'none' }, ye: { field: 'ye', type: 'genomic', axis: 'none' }, color: { field: 'value', type: 'quantitative', range: 'warm' }, width: 600, diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index d26897925..c00c29f48 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -147,7 +147,7 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE const domain = getEncodingSignal(trackDef.trackId, 'x', linkedEncodings); if (!domain) return; - new AxisTrack(options, domain, pixiManager.makeContainer(boundingBox), 'vertical').addInteractor(plot => + new AxisTrack(options, domain, pixiManager.makeContainer(boundingBox), 'horizontal').addInteractor(plot => panZoom(plot, domain) ); } diff --git a/src/interactors/panZoomHeatmap.ts b/src/interactors/panZoomHeatmap.ts index 2b35c2333..f94ae6c18 100644 --- a/src/interactors/panZoomHeatmap.ts +++ b/src/interactors/panZoomHeatmap.ts @@ -1,4 +1,4 @@ -import { type Signal, effect } from '@preact/signals-core'; +import { type Signal, effect, batch } from '@preact/signals-core'; import { scaleLinear } from 'd3-scale'; import { ZoomTransform, type D3ZoomEvent, zoom } from 'd3-zoom'; import { select } from 'd3-selection'; @@ -29,9 +29,12 @@ export function panZoomHeatmap( const zoomed = (event: D3ZoomEvent) => { const { transform } = event; const newXDomain = transform.rescaleX(zoomStartXScale).domain(); - xDomain.value = newXDomain as [number, number]; const newYDomain = transform.rescaleY(zoomStartYScale).domain(); - yDomain.value = newYDomain as [number, number]; + + batch(() => { + xDomain.value = newXDomain as [number, number]; + yDomain.value = newYDomain as [number, number]; + }); }; // Create the zoom behavior const zoomBehavior = zoom() @@ -72,9 +75,17 @@ export function panZoomHeatmap( const scalingXFactor = width / maxDomain; const tx = -(xDomain.value[0] * k) * scalingXFactor; + const ky = maxDomain / (yDomain.value[1] - yDomain.value[0]); const scalingYFactor = height / maxDomain; const ty = -(yDomain.value[0] * k) * scalingYFactor; - plot.zoomed(newXScale, newYScale, k, tx, ty); + if (ky.toPrecision(3) !== k.toPrecision(3)) { + // If there is a mismatch between the x and y scaling factors, we need to adjust the yDomain + // TODO: This is a temporary fix. We need to find a better way to handle this + const diff = maxDomain / k; + yDomain.value = [yDomain.value[0], yDomain.value[0] + diff]; + } else { + plot.zoomed(newXScale, newYScale, k, tx, ty); + } }); } From 2f79d6d87015355bd9811f65aded99ecd7f5a261 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 26 Jun 2024 22:23:53 -0400 Subject: [PATCH 089/139] feat: working heatmap x and y --- demo/App.tsx | 92 ++++++++++++------- demo/examples/gosling-track-vertical.ts | 2 +- demo/renderer/axis.ts | 6 +- demo/renderer/main.ts | 2 +- src/core/mark/axis.ts | 8 +- src/interactors/panZoom.ts | 6 +- src/tracks/genomic-axis/axis-track-plot.ts | 46 +--------- .../gosling-track/gosling-track-plot.ts | 6 +- 8 files changed, 81 insertions(+), 87 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index be90159a2..b574866f7 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -81,40 +81,70 @@ export default App; const matrix2 = { xDomain: { chromosome: 'chr7', interval: [77700000, 81000000] }, - tracks: [ + arrangement: 'serial', + views: [ { - layout: 'linear', - width: 600, - height: 180, - data: { - url: 'https://resgen.io/api/v1/tileset_info/?d=UvVPeLHuRDiYA3qwFlm7xQ', - type: 'multivec', - row: 'sample', - column: 'position', - value: 'peak', - categories: ['sample 1'], - binSize: 5 - }, - mark: 'bar', - x: { field: 'start', type: 'genomic', axis: 'bottom' }, - xe: { field: 'end', type: 'genomic' }, - y: { field: 'peak', type: 'quantitative', axis: 'right' }, - size: { value: 5 } + orientation: 'vertical', + yOffset: 210, + tracks: [ + { + layout: 'linear', + width: 180, + height: 600, + data: { + url: 'https://resgen.io/api/v1/tileset_info/?d=UvVPeLHuRDiYA3qwFlm7xQ', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1'], + binSize: 5 + }, + mark: 'bar', + x: { field: 'start', type: 'genomic', axis: 'bottom', linkingId: 'test' }, + xe: { field: 'end', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative', axis: 'right' }, + size: { value: 5 } + } + ] }, { - title: 'HFFc6_Micro-C', - data: { - url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=hffc6-microc-hg38', - type: 'matrix' - }, - mark: 'bar', - x: { field: 'xs', type: 'genomic', axis: 'none' }, - xe: { field: 'xe', type: 'genomic', axis: 'none' }, - y: { field: 'ys', type: 'genomic', axis: 'none' }, - ye: { field: 'ye', type: 'genomic', axis: 'none' }, - color: { field: 'value', type: 'quantitative', range: 'warm' }, - width: 600, - height: 600 + tracks: [ + { + layout: 'linear', + width: 600, + height: 180, + data: { + url: 'https://resgen.io/api/v1/tileset_info/?d=UvVPeLHuRDiYA3qwFlm7xQ', + type: 'multivec', + row: 'sample', + column: 'position', + value: 'peak', + categories: ['sample 1'], + binSize: 5 + }, + mark: 'bar', + x: { field: 'start', type: 'genomic', axis: 'bottom' }, + xe: { field: 'end', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative', axis: 'right' }, + size: { value: 5 } + }, + { + title: 'HFFc6_Micro-C', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=hffc6-microc-hg38', + type: 'matrix' + }, + mark: 'bar', + x: { field: 'xs', type: 'genomic', axis: 'none' }, + xe: { field: 'xe', type: 'genomic', axis: 'none' }, + y: { field: 'ys', type: 'genomic', axis: 'none', linkingId: 'test' }, + ye: { field: 'ye', type: 'genomic', axis: 'none' }, + color: { field: 'value', type: 'quantitative', range: 'warm' }, + width: 600, + height: 600 + } + ] } ] }; diff --git a/demo/examples/gosling-track-vertical.ts b/demo/examples/gosling-track-vertical.ts index c4bba38e7..7f85d296a 100644 --- a/demo/examples/gosling-track-vertical.ts +++ b/demo/examples/gosling-track-vertical.ts @@ -69,7 +69,7 @@ export const circularTrackOptions = { width: 250, height: 250, assembly: 'hg38', - orientation: 'horizontal', + orientation: 'vertical', zoomLimits: [1, null], centerRadius: 0.4, xOffset: 0, diff --git a/demo/renderer/axis.ts b/demo/renderer/axis.ts index 0e86e23ce..28c36cb5b 100644 --- a/demo/renderer/axis.ts +++ b/demo/renderer/axis.ts @@ -50,7 +50,7 @@ export function getAxisTrackDef( type: TrackType.Axis, trackId: track.id, boundingBox: axisBbox, - options: getAxisTrackLinearOptions(axisBbox, xAxisPosition, theme) + options: getAxisTrackLinearOptions(track, axisBbox, xAxisPosition, theme) } ]; } else if (track.layout === 'circular') { @@ -77,12 +77,14 @@ export function getAxisTrackDef( * @param position "top" | "bottom" | "left" | "right */ function getAxisTrackLinearOptions( + track: SingleTrack | OverlaidTrack | TemplateTrack, boundingBox: { x: number; y: number; width: number; height: number }, position: AxisPosition, theme: Required ): AxisTrackOptions { - const narrowType = getAxisNarrowType('x', 'horizontal', boundingBox.width, boundingBox.height); + const narrowType = getAxisNarrowType('x', track.orientation, boundingBox.width, boundingBox.height); const options: AxisTrackOptions = { + orientation: track.orientation, innerRadius: 0, outerRadius: 0, width: boundingBox.width, diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index c00c29f48..c441ef625 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -147,7 +147,7 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE const domain = getEncodingSignal(trackDef.trackId, 'x', linkedEncodings); if (!domain) return; - new AxisTrack(options, domain, pixiManager.makeContainer(boundingBox), 'horizontal').addInteractor(plot => + new AxisTrack(options, domain, pixiManager.makeContainer(boundingBox), options.orientation).addInteractor(plot => panZoom(plot, domain) ); } diff --git a/src/core/mark/axis.ts b/src/core/mark/axis.ts index 8d29074dd..0d9d0ca53 100644 --- a/src/core/mark/axis.ts +++ b/src/core/mark/axis.ts @@ -137,10 +137,10 @@ export function drawLinearYAxis( textGraphic.position.y = dy + rowHeight - y; // Flip labels when orientation is vertical - // if (spec.orientation === 'vertical') { - // textGraphic.anchor.x = isLeft ? 1 : 0; - // textGraphic.scale.x *= -1; - // } + if (spec.orientation === 'vertical') { + textGraphic.anchor.x = isLeft ? 1 : 0; + textGraphic.scale.x *= -1; + } graphics.addChild(textGraphic); }); }); diff --git a/src/interactors/panZoom.ts b/src/interactors/panZoom.ts index fc81a2c42..32f0b2a32 100644 --- a/src/interactors/panZoom.ts +++ b/src/interactors/panZoom.ts @@ -40,11 +40,7 @@ export function panZoom(plot: Plot, xDomain: Signal<[number, number]>) { // @ts-expect-error We need to reset the transform when the user stops zooming .on('end', () => (plot.domOverlay.__zoom = new ZoomTransform(1, 0, 0))) .on('start', () => { - if (plot.orientation === undefined || plot.orientation === 'horizontal') { - zoomStartScale.domain(xDomain.value).range([0, plot.width]); - } else if (plot.orientation === 'vertical') { - zoomStartScale.domain(xDomain.value).range([plot.width, 0]); - } + zoomStartScale.domain(xDomain.value).range([0, plot.width]); }) .on('zoom', zoomed); diff --git a/src/tracks/genomic-axis/axis-track-plot.ts b/src/tracks/genomic-axis/axis-track-plot.ts index 41ecc38b0..6960df5ca 100644 --- a/src/tracks/genomic-axis/axis-track-plot.ts +++ b/src/tracks/genomic-axis/axis-track-plot.ts @@ -68,11 +68,13 @@ export class AxisTrack extends AxisTrackClass { // The width and height are swapped because the scene is rotated this.width = overlayDiv.clientHeight; this.height = overlayDiv.clientWidth; - // We rotate the scene 90 degrees to the left - this.scene.rotation = Math.PI / 2 + Math.PI; + // We rotate the scene 90 degrees to the left and flip it + this.scene.scale.y *= -1; + this.scene.rotation = Math.PI / 2; const position = this.scene.position; + this.flipText = true; // We move the scene down because the rotation point is the top left corner - this.scene.position.set(position.x, position.y + this.width); + this.scene.position.set(position.x, position.y); } this.xDomain = xDomain; @@ -95,42 +97,4 @@ export class AxisTrack extends AxisTrackClass { interactor(this); return this; // For chaining } - - #addZoom(): void { - const baseScale = scaleLinear().domain(this.xDomain.value).range([0, this.width]); - - // This function will be called every time the user zooms - const zoomed = (event: D3ZoomEvent) => { - if (this.orientation === 'vertical') { - const newXDomain = event.transform.rescaleY(this.zoomStartScale).domain(); - this.xDomain.value = newXDomain; - } - if (this.orientation === 'horizontal') { - const newXDomain = event.transform.rescaleX(this.zoomStartScale).domain(); - this.xDomain.value = newXDomain; - } - }; - - // Create the zoom behavior - const zoomBehavior = zoom() - .wheelDelta(wheelDelta) - // @ts-expect-error We need to reset the transform when the user stops zooming - .on('end', () => (this.domOverlay.__zoom = new ZoomTransform(1, 0, 0))) - .on('start', () => { - if (this.orientation === 'horizontal') - this.zoomStartScale.domain(this.xDomain.value).range([0, this.width]); - if (this.orientation === 'vertical') - this.zoomStartScale.domain(this.xDomain.value).range([this.width, 0]); - }) - .on('zoom', zoomed.bind(this)); - - // Apply the zoom behavior to the overlay div - select(this.domOverlay).call(zoomBehavior); - - // Every time the domain gets changed we want to update the zoom - effect(() => { - const newScale = baseScale.domain(this.xDomain.value); - this.zoomed(newScale, this._refYScale); - }); - } } diff --git a/src/tracks/gosling-track/gosling-track-plot.ts b/src/tracks/gosling-track/gosling-track-plot.ts index 008b1efee..78230875b 100644 --- a/src/tracks/gosling-track/gosling-track-plot.ts +++ b/src/tracks/gosling-track/gosling-track-plot.ts @@ -67,10 +67,12 @@ export class GoslingTrack extends GoslingTrackClass implements Plot { this.width = overlayDiv.clientHeight; this.height = overlayDiv.clientWidth; // We rotate the scene 90 degrees to the left - this.scene.rotation = Math.PI / 2 + Math.PI; + + this.scene.scale.y *= -1; + this.scene.rotation = Math.PI / 2; const position = this.scene.position; // We move the scene down because the rotation point is the top left corner - this.scene.position.set(position.x, position.y + this.width); + this.scene.position.set(position.x, position.y); } this.xDomain = xDomain; From 6e11667a088fc3414c907d5d23c4572ec037b96d Mon Sep 17 00:00:00 2001 From: etowahadams Date: Thu, 27 Jun 2024 15:23:37 -0400 Subject: [PATCH 090/139] feat: refine heatmap axis --- demo/App.tsx | 4 +- demo/renderer/axis.ts | 83 ++++++++++++++++++++++++++++------------ demo/renderer/gosling.ts | 6 +-- demo/renderer/heatmap.ts | 6 +-- demo/renderer/main.ts | 11 ++++-- 5 files changed, 74 insertions(+), 36 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index b574866f7..0a465b093 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -159,9 +159,9 @@ const matrix = { type: 'matrix' }, mark: 'bar', - x: { field: 'xs', type: 'genomic', axis: 'none' }, + x: { field: 'xs', type: 'genomic', axis: 'top' }, xe: { field: 'xe', type: 'genomic', axis: 'none' }, - y: { field: 'ys', type: 'genomic', axis: 'none' }, + y: { field: 'ys', type: 'genomic', axis: 'right' }, ye: { field: 'ye', type: 'genomic', axis: 'none' }, color: { field: 'value', type: 'quantitative', range: 'warm' }, width: 600, diff --git a/demo/renderer/axis.ts b/demo/renderer/axis.ts index 28c36cb5b..be104d15f 100644 --- a/demo/renderer/axis.ts +++ b/demo/renderer/axis.ts @@ -25,11 +25,21 @@ export function getAxisTrackDef( track: SingleTrack | OverlaidTrack | TemplateTrack, boundingBox: { x: number; y: number; width: number; height: number }, theme: Required -): [trackBbox: { x: number; y: number; width: number; height: number }, TrackDef | undefined] { +): [trackBbox: { x: number; y: number; width: number; height: number }, TrackDef[] | undefined] { const { xAxisPosition, yAxisPosition } = getAxisPositions(track); + console.warn('xAxisPosition', xAxisPosition, 'yAxisPosition', yAxisPosition); // This is a copy of the original bounding box. It will be modified if an axis is added const trackBbox = { ...boundingBox }; + const trackDefs: TrackDef[] = []; if (xAxisPosition) { + if (track.layout === 'circular') { + trackDefs.push({ + type: TrackType.Axis, + trackId: track.id, + boundingBox: boundingBox, + options: getAxisTrackCircularOptions(track, boundingBox, xAxisPosition, theme) + }); + } if (track.layout === 'linear') { const isHorizontal = track.orientation === 'horizontal'; const widthOrHeight = isHorizontal ? 'height' : 'width'; @@ -44,31 +54,40 @@ export function getAxisTrackDef( } else if (xAxisPosition === 'left') { trackBbox.x += axisBbox.width; } - return [ - trackBbox, - { - type: TrackType.Axis, - trackId: track.id, - boundingBox: axisBbox, - options: getAxisTrackLinearOptions(track, axisBbox, xAxisPosition, theme) - } - ]; - } else if (track.layout === 'circular') { - return [ - trackBbox, - { - type: TrackType.Axis, - trackId: track.id, - boundingBox: boundingBox, - options: getAxisTrackCircularOptions(track, boundingBox, xAxisPosition, theme) - } - ]; + trackDefs.push({ + type: TrackType.Axis, + trackId: track.id, + boundingBox: axisBbox, + options: getAxisTrackLinearOptions('x', track, axisBbox, xAxisPosition, theme) + }); } } if (yAxisPosition) { - console.warn('Vertical axis is not supported yet'); + if (track.layout === 'circular') { + console.warn('Error: Circular layout does not support y-axis'); + } + if (track.layout === 'linear') { + if (yAxisPosition === 'top' || yAxisPosition === 'bottom') { + console.warn('Error: Bottom y-axis is not supported. Defaulting to left.'); + } + const isHorizontal = track.orientation === 'horizontal'; + const widthOrHeight = isHorizontal ? 'width' : 'height'; + const axisBbox = { ...trackBbox, [widthOrHeight]: HIGLASS_AXIS_SIZE }; + trackBbox[widthOrHeight] -= axisBbox[widthOrHeight]; + if (yAxisPosition === 'right') { + axisBbox.x = trackBbox.x + trackBbox.width; + } else if (yAxisPosition === 'left' || yAxisPosition === 'bottom' || yAxisPosition === 'top') { + trackBbox.x += axisBbox.width; + } + trackDefs.push({ + type: TrackType.Axis, + trackId: track.id, + boundingBox: axisBbox, + options: getAxisTrackLinearOptions('y', track, axisBbox, yAxisPosition, theme) + }); + } } - return [trackBbox, undefined]; + return [trackBbox, trackDefs]; } /** @@ -77,14 +96,16 @@ export function getAxisTrackDef( * @param position "top" | "bottom" | "left" | "right */ function getAxisTrackLinearOptions( + encoding: 'x' | 'y', track: SingleTrack | OverlaidTrack | TemplateTrack, boundingBox: { x: number; y: number; width: number; height: number }, position: AxisPosition, theme: Required ): AxisTrackOptions { - const narrowType = getAxisNarrowType('x', track.orientation, boundingBox.width, boundingBox.height); + const narrowType = getAxisNarrowType(encoding, track.orientation, boundingBox.width, boundingBox.height); const options: AxisTrackOptions = { - orientation: track.orientation, + orientation: getAxisOrientation(encoding, track.orientation), + encoding: encoding, innerRadius: 0, outerRadius: 0, width: boundingBox.width, @@ -108,6 +129,20 @@ function getAxisTrackLinearOptions( return options; } +/** + * Determines the orientation of the axis + */ +function getAxisOrientation(encoding: 'x' | 'y', trackOrientation: 'horizontal' | 'vertical') { + if (encoding === 'x') { + return trackOrientation === 'horizontal' ? 'horizontal' : 'vertical'; + } + if (encoding === 'y') { + return trackOrientation === 'horizontal' ? 'vertical' : 'horizontal'; + } + console.warn('Invalid track orientation. Defaulting to horizontal'); + return 'horizontal'; +} + /** * Generates options for the circular axis track */ diff --git a/demo/renderer/gosling.ts b/demo/renderer/gosling.ts index dd18f0021..dde64ddd4 100644 --- a/demo/renderer/gosling.ts +++ b/demo/renderer/gosling.ts @@ -22,9 +22,9 @@ export function processGoslingTrack( )[] = []; // Adds the axis tracks - const [newTrackBbox, axisTrackDef] = getAxisTrackDef(track, boundingBox, theme); - if (axisTrackDef) { - trackDefs.push(axisTrackDef); + const [newTrackBbox, axisTrackDefs] = getAxisTrackDef(track, boundingBox, theme); + if (axisTrackDefs) { + trackDefs.push(...axisTrackDefs); // modify the bounding box to exclude the axis track boundingBox = newTrackBbox; } diff --git a/demo/renderer/heatmap.ts b/demo/renderer/heatmap.ts index 2a566a858..b62747b8d 100644 --- a/demo/renderer/heatmap.ts +++ b/demo/renderer/heatmap.ts @@ -14,9 +14,9 @@ export function processHeatmapTrack( const trackDefs: (TrackDef | TrackDef)[] = []; // Adds the axis tracks if needed - const [newTrackBbox, axisTrackDef] = getAxisTrackDef(track, boundingBox, theme); - if (axisTrackDef) { - trackDefs.push(axisTrackDef); + const [newTrackBbox, axisTrackDefs] = getAxisTrackDef(track, boundingBox, theme); + if (axisTrackDefs) { + trackDefs.push(...axisTrackDefs); // modify the bounding box to exclude the axis track boundingBox = newTrackBbox; } diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index c441ef625..525ba299a 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -144,11 +144,14 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE ); } if (type === TrackType.Axis) { - const domain = getEncodingSignal(trackDef.trackId, 'x', linkedEncodings); - if (!domain) return; + const domain = getEncodingSignal(trackDef.trackId, options.encoding, linkedEncodings); + if (!domain) { + console.warn(`No domain found for track ${trackDef.trackId}`); + return; + } - new AxisTrack(options, domain, pixiManager.makeContainer(boundingBox), options.orientation).addInteractor(plot => - panZoom(plot, domain) + new AxisTrack(options, domain, pixiManager.makeContainer(boundingBox), options.orientation).addInteractor( + plot => panZoom(plot, domain) ); } if (type === TrackType.BrushLinear) { From ca6486224916de993ecccae777805ef2565633ac Mon Sep 17 00:00:00 2001 From: etowahadams Date: Thu, 27 Jun 2024 16:43:03 -0400 Subject: [PATCH 091/139] feat: static brushes --- demo/App.tsx | 2 +- demo/renderer/axis.ts | 4 +++- demo/renderer/main.ts | 15 +++++++++++---- src/tracks/brush-circular/brush-circular-plot.ts | 2 ++ 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index 0a465b093..b96dc02e4 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -63,7 +63,7 @@ function App() { }; // Compile the spec - compile(matrix2, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); + compile(cancer, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); }, []); return ( diff --git a/demo/renderer/axis.ts b/demo/renderer/axis.ts index be104d15f..6b12f8776 100644 --- a/demo/renderer/axis.ts +++ b/demo/renderer/axis.ts @@ -27,7 +27,6 @@ export function getAxisTrackDef( theme: Required ): [trackBbox: { x: number; y: number; width: number; height: number }, TrackDef[] | undefined] { const { xAxisPosition, yAxisPosition } = getAxisPositions(track); - console.warn('xAxisPosition', xAxisPosition, 'yAxisPosition', yAxisPosition); // This is a copy of the original bounding box. It will be modified if an axis is added const trackBbox = { ...boundingBox }; const trackDefs: TrackDef[] = []; @@ -106,6 +105,7 @@ function getAxisTrackLinearOptions( const options: AxisTrackOptions = { orientation: getAxisOrientation(encoding, track.orientation), encoding: encoding, + static: track.static, innerRadius: 0, outerRadius: 0, width: boundingBox.width, @@ -163,6 +163,8 @@ function getAxisTrackCircularOptions( const options: AxisTrackOptions = { layout: 'circular', + encoding: 'x', + static: track.static, innerRadius, outerRadius, width: boundingBox.width, diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index 525ba299a..6ed0dfd39 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -135,7 +135,6 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE if (type === TrackType.Heatmap) { const xDomain = getEncodingSignal(trackDef.trackId, 'x', linkedEncodings); const yDomain = getEncodingSignal(trackDef.trackId, 'y', linkedEncodings); - console.warn('domains,', xDomain, yDomain); if (!xDomain || !yDomain) return; const datafetcher = getDataFetcher(options.spec); @@ -146,7 +145,7 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE if (type === TrackType.Axis) { const domain = getEncodingSignal(trackDef.trackId, options.encoding, linkedEncodings); if (!domain) { - console.warn(`No domain found for track ${trackDef.trackId}`); + console.warn(`No domain found for axis ${trackDef.trackId}. Skipping...`); return; } @@ -168,7 +167,15 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE const brushDomain = getEncodingSignal(trackDef.trackId, 'brush', linkedEncodings); if (!domain || !brushDomain || !hasLinkedTracks(trackDef.trackId, linkedEncodings)) return; // We only want to add the brush track if it is linked to another track - new BrushCircularTrack(options, brushDomain, pixiManager.makeContainer(boundingBox).overlayDiv, domain); + const brush = new BrushCircularTrack( + options, + brushDomain, + pixiManager.makeContainer(boundingBox).overlayDiv, + domain + ); + if (!options.static) { + brush.addInteractor(plot => panZoom(plot, domain)); + } } }); } @@ -195,7 +202,7 @@ function getEncodingSignal( link.tracks.find(t => t.id === trackDefId && t.encoding === encodingType) ); if (!linkedEncoding) { - console.warn(`No linked encoding found for track ${trackDefId}`); + console.warn(`No linked encoding "${encodingType}" found for track ${trackDefId}`); return undefined; } if (!linkedEncoding.signal) { diff --git a/src/tracks/brush-circular/brush-circular-plot.ts b/src/tracks/brush-circular/brush-circular-plot.ts index ceb7af322..64224591a 100644 --- a/src/tracks/brush-circular/brush-circular-plot.ts +++ b/src/tracks/brush-circular/brush-circular-plot.ts @@ -13,6 +13,7 @@ export class BrushCircularTrack extends CircularBrushTrackClass { domOverlay: HTMLElement; // This is the div that we're going to apply the zoom behavior to width: number; height: number; + orientation: 'horizontal'; constructor( options: BrushCircularTrackOptions, @@ -41,6 +42,7 @@ export class BrushCircularTrack extends CircularBrushTrackClass { }; super(context, options); + this.orientation = 'horizontal'; // always horizontal for now this.width = domOverlay.clientWidth; this.height = domOverlay.clientHeight; this.xDomain = xDomain; From 6847ced39316432906a141178c00b08afd026846 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Fri, 28 Jun 2024 12:06:26 -0400 Subject: [PATCH 092/139] fix: unused imports --- demo/App.tsx | 243 ++++++++++++++++++++++++++- demo/examples/left-track-example.ts | 1 - demo/renderer/main.ts | 6 +- src/tracks/utils.ts | 252 ---------------------------- 4 files changed, 245 insertions(+), 257 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index b96dc02e4..6549327bb 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -63,7 +63,7 @@ function App() { }; // Compile the spec - compile(cancer, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); + compile(matrix2, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); }, []); return ( @@ -79,6 +79,247 @@ function App() { export default App; +const doubleMatrix = { + arrangement: 'horizontal', + xDomain: { chromosome: 'chr7', interval: [77700000, 81000000] }, + spacing: 1, + linkingId: '-', + views: [ + { + spacing: 30, + views: [ + { + spacing: 0, + arrangement: 'vertical', + views: [ + { + tracks: [ + { + alignment: 'overlay', + tracks: [ + { + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/HFFC6_CTCF.mRp.clN.bigWig', + type: 'bigwig', + column: 'position', + value: 'peak', + binSize: 8 + }, + mark: 'bar', + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + y: { + field: 'peak', + type: 'quantitative', + axis: 'none' + }, + color: { value: '#0072B2' } + }, + { + style: { backgroundOpacity: 0 }, + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ] + }, + dataTransform: [{ type: 'filter', field: 'strand', oneOf: ['+'] }], + mark: 'triangleRight', + x: { field: 'start', type: 'genomic' }, + size: { value: 13 }, + stroke: { value: 'white' }, + strokeWidth: { value: 1 }, + row: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'] + }, + color: { value: '#CB7AA7' } + }, + { + style: { backgroundOpacity: 0 }, + title: 'HFFC6_CTCF', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ] + }, + dataTransform: [{ type: 'filter', field: 'strand', oneOf: ['-'] }], + mark: 'triangleLeft', + x: { field: 'start', type: 'genomic' }, + stroke: { value: 'white' }, + strokeWidth: { value: 1 }, + size: { value: 13 }, + row: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'] + }, + color: { value: '#029F73' } + } + ], + width: 600, + height: 40 + } + ] + }, + { + tracks: [ + { + title: 'HFFc6_Micro-C', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=hffc6-microc-hg38', + type: 'matrix' + }, + mark: 'bar', + x: { field: 'xs', type: 'genomic', axis: 'none' }, + xe: { field: 'xe', type: 'genomic', axis: 'none' }, + y: { field: 'ys', type: 'genomic', axis: 'none' }, + ye: { field: 'ye', type: 'genomic', axis: 'none' }, + color: { + field: 'value', + type: 'quantitative', + range: 'warm' + }, + width: 600, + height: 600 + } + ] + } + ] + }, + { + arrangement: 'vertical', + spacing: 0, + views: [ + { + tracks: [ + { + alignment: 'overlay', + tracks: [ + { + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/HFFC6_CTCF.mRp.clN.bigWig', + type: 'bigwig', + column: 'position', + value: 'peak', + binSize: 8 + }, + mark: 'bar', + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + y: { + field: 'peak', + type: 'quantitative', + axis: 'none' + }, + color: { value: '#0072B2' } + }, + { + style: { backgroundOpacity: 0 }, + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ] + }, + dataTransform: [{ type: 'filter', field: 'strand', oneOf: ['+'] }], + mark: 'triangleRight', + x: { field: 'start', type: 'genomic' }, + size: { value: 13 }, + stroke: { value: 'white' }, + strokeWidth: { value: 1 }, + row: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'] + }, + color: { value: '#CB7AA7' } + }, + { + style: { backgroundOpacity: 0 }, + title: 'HFFC6_CTCF', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ] + }, + dataTransform: [{ type: 'filter', field: 'strand', oneOf: ['-'] }], + mark: 'triangleLeft', + x: { field: 'start', type: 'genomic' }, + size: { value: 13 }, + stroke: { value: 'white' }, + strokeWidth: { value: 1 }, + row: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'] + }, + color: { value: '#029F73' } + } + ], + width: 600, + height: 40 + } + ] + }, + { + tracks: [ + { + title: 'HFFc6_Hi-C', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=hffc6-hic-hg38', + type: 'matrix' + }, + mark: 'bar', + x: { field: 'xs', type: 'genomic', axis: 'none' }, + xe: { field: 'xe', type: 'genomic', axis: 'none' }, + y: { field: 'ys', type: 'genomic', axis: 'none' }, + ye: { field: 'ye', type: 'genomic', axis: 'none' }, + color: { + field: 'value', + type: 'quantitative', + range: 'warm' + }, + width: 600, + height: 600 + } + ] + } + ] + } + ] + } + ], + style: { outlineWidth: 0, background: '#F6F6F6' } +}; + const matrix2 = { xDomain: { chromosome: 'chr7', interval: [77700000, 81000000] }, arrangement: 'serial', diff --git a/demo/examples/left-track-example.ts b/demo/examples/left-track-example.ts index a171ac490..5d5341464 100644 --- a/demo/examples/left-track-example.ts +++ b/demo/examples/left-track-example.ts @@ -1,7 +1,6 @@ import { PixiManager } from '@pixi-manager'; import { signal } from '@preact/signals-core'; import { AxisTrack } from '@gosling-lang/genomic-axis'; -import { LeftTrackModifier } from '../../src/tracks/utils'; import { panZoom } from '@gosling-lang/interactors'; export function addLeftAxisTrack(pixiManager: PixiManager) { diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index 6ed0dfd39..cc2d29288 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -1,12 +1,12 @@ import type { PixiManager } from '@pixi-manager'; import { TextTrack, type TextTrackOptions } from '@gosling-lang/text-track'; -import { DummyTrack, type DummyTrackOptions } from '@gosling-lang/dummy-track'; +import { type DummyTrackOptions } from '@gosling-lang/dummy-track'; import { GoslingTrack } from '@gosling-lang/gosling-track'; import { AxisTrack, type AxisTrackOptions } from '@gosling-lang/genomic-axis'; import { BrushLinearTrack, type BrushLinearTrackOptions } from '@gosling-lang/brush-linear'; -import { Signal, signal } from '@preact/signals-core'; +import { Signal } from '@preact/signals-core'; -import { cursor, panZoom, panZoomHeatmap } from '@gosling-lang/interactors'; +import { panZoom, panZoomHeatmap } from '@gosling-lang/interactors'; import type { TrackInfo } from '../../src/compiler/bounding-box'; import type { CompleteThemeDeep } from '../../src/core/utils/theme'; import type { GoslingTrackOptions } from '../../src/tracks/gosling-track/gosling-track'; diff --git a/src/tracks/utils.ts b/src/tracks/utils.ts index 1089856ac..b2e3af420 100644 --- a/src/tracks/utils.ts +++ b/src/tracks/utils.ts @@ -1,6 +1,5 @@ import { type Signal } from '@preact/signals-core'; import { type ScaleLinear } from 'd3-scale'; -import * as PIXI from 'pixi.js'; // Default d3 zoom feels slow so we use this instead // https://d3js.org/d3-zoom#zoom_wheelDelta @@ -43,254 +42,3 @@ export interface HeatmapPlot { ty: number ): void; } - -/** - * This rotates a track 90 degrees to the left - */ -export class LeftTrackModifier { - scene: PIXI.Container; - originalTrack: any; - pBase: PIXI.Graphics; - moveToOrigin: PIXI.Graphics; - svgOutput: any; - dimensions: any; - position: any; - - constructor(originalTrack) { - this.scene = originalTrack.scene; - - this.originalTrack = originalTrack; - this.pBase = new PIXI.Graphics(); - - this.scene.removeChild(originalTrack.pBase); - this.scene.addChild(this.pBase); - - this.moveToOrigin = new PIXI.Graphics(); - this.moveToOrigin.addChild(originalTrack.pBase); - - this.pBase.addChild(this.moveToOrigin); - - this.moveToOrigin.rotation = Math.PI / 2; - - // Indicate that the track has been flipped. This is generally the same as - // `originalTrack.flipText` but `flipText` is semantically not that clear - originalTrack.isLeftModified = true; - - // If the original track has text labels, we need to flip - // them horizontally, otherwise they'll be mirrored. - originalTrack.flipText = true; - this.svgOutput = null; - - if (originalTrack.gBase && originalTrack.gMain) { - this.originalTrack.gBase.attr( - 'transform', - `translate(${this.moveToOrigin.position.x},${this.moveToOrigin.position.y}) - rotate(90) - scale(${this.moveToOrigin.scale.x},${this.moveToOrigin.scale.y})` - ); - this.originalTrack.gMain.attr( - 'transform', - `translate(${this.originalTrack.pBase.position.x},${this.originalTrack.pBase.position.y})` - ); - } - } - - remove() { - this.originalTrack.remove(); - - this.pBase.clear(); - this.scene.removeChild(this.pBase); - } - - setDimensions(newDimensions) { - this.dimensions = newDimensions; - - const reversedDimensions = [newDimensions[1], newDimensions[0]]; - - this.originalTrack.setDimensions(reversedDimensions); - } - - setPosition(newPosition) { - this.position = newPosition; - - this.originalTrack.setPosition(newPosition); - - this.originalTrack.pBase.position.x = -this.originalTrack.position[0]; - this.originalTrack.pBase.position.y = -this.originalTrack.position[1]; - - this.moveToOrigin.scale.y = -1; - this.moveToOrigin.scale.x = 1; - this.moveToOrigin.position.x = this.originalTrack.position[0]; - this.moveToOrigin.position.y = this.originalTrack.position[1]; - - if (this.originalTrack.gMain) { - this.originalTrack.gBase.attr( - 'transform', - `translate(${this.moveToOrigin.position.x},${this.moveToOrigin.position.y}) - rotate(90) - scale(${this.moveToOrigin.scale.x},${this.moveToOrigin.scale.y})` - ); - this.originalTrack.gMain.attr( - 'transform', - `translate(${this.originalTrack.pBase.position.x},${this.originalTrack.pBase.position.y})` - ); - } - } - - refXScale(_) { - /** - * Either get or set the reference xScale - */ - if (!arguments.length) { - return this.originalTrack._refYScale; - } - - this.originalTrack._refXScale = _; - - return this; - } - - refYScale(_) { - /** - * Either get or set the reference yScale - */ - if (!arguments.length) { - return this.originalTrack._refXScale; - } - - this.originalTrack._refYScale = _; - - return this; - } - - xScale(_) { - /** - * Either get or set the xScale - */ - if (!arguments.length) { - return this.originalTrack._xScale; - } - - this.originalTrack._yScale = _; - - return this; - } - - yScale(_) { - /** - * Either get or set the yScale - */ - if (!arguments.length) { - return this.originalTrack._yScale; - } - - this.originalTrack._xScale = _; - - return this; - } - - getMouseOverHtml(trackX, trackY) { - return this.originalTrack.getMouseOverHtml(trackY, trackX); - } - - clickOutside() { - this.originalTrack.clickOutside(); - } - - click(...args) { - this.originalTrack.click(...args); - } - - draw() { - this.originalTrack.draw(); - } - - zoomed(newXScale, newYScale, k = 1, tx = 0, ty = 0, xPositionOffset = 0, yPositionOffset = 0) { - this.xScale(newXScale); - this.yScale(newYScale); - - if (this.originalTrack.leftTrackZoomed) { - if (this.originalTrack.refreshTiles) { - // some tracks don't have refreshTiles (e.g. PixiTrack) - this.originalTrack.refreshTiles(); - } - // the track implements its own left-oriented zooming and scrolling - this.originalTrack.leftTrackZoomed(newXScale, newYScale, k, tx, ty); - this.originalTrack.draw(); - return; - } - - const offset = this.originalTrack._xScale(0) - k * this.originalTrack._refXScale(0); - this.originalTrack.pMobile.position.x = offset + this.originalTrack.position[0]; - this.originalTrack.pMobile.position.y = this.originalTrack.position[1] + this.originalTrack.dimensions[1]; - - this.originalTrack.pMobile.scale.x = k; - this.originalTrack.pMobile.scale.y = k; - - if (this.originalTrack.options.oneDHeatmapFlipped) { - this.originalTrack.pMobile.scale.y = -k; - this.originalTrack.pMobile.position.y = this.originalTrack.position[1]; - } - - if (this.originalTrack.leftTrackDraw) { - // if the track implements leftTrackDraw we just redraw the track and - // won't call the track's zoomed method - if (this.originalTrack.refreshTiles) { - this.originalTrack.refreshTiles(); - } - this.originalTrack.leftTrackDraw(); - return; - } - - this.originalTrack.zoomed(this.xScale(), this.yScale()); - } - - zoomedY(yPos, kMultiplier) { - this.originalTrack.zoomedY(yPos, kMultiplier); - } - - movedY(dY) { - this.originalTrack.movedY(dY); - } - - refScalesChanged(refXScale, refYScale) { - this.originalTrack.refScalesChanged(refYScale, refXScale); - } - - rerender(options) { - this.originalTrack.rerender(options); - } - - exportSVG() { - const output = document.createElement('g'); - output.setAttribute( - 'transform', - `translate(${this.moveToOrigin.position.x},${this.moveToOrigin.position.y}) - rotate(90) - scale(${this.moveToOrigin.scale.x},${this.moveToOrigin.scale.y})` - ); - - if (this.originalTrack.exportSVG) { - const g = document.createElement('g'); - g.setAttribute( - 'transform', - `translate(${this.originalTrack.pBase.position.x}, ${this.originalTrack.pBase.position.y})` - ); - - g.appendChild(this.originalTrack.exportSVG()[0]); - output.appendChild(g); - } - - return [output, output]; - } - - respondsToPosition(x, y) { - return ( - x >= this.position[0] && - x <= this.dimensions[0] + this.position[0] && - y >= this.position[1] && - y <= this.dimensions[1] + this.position[1] - ); - } -} - From 390e5f72ac55aa25c890ed04af23deae94e22ea2 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Fri, 28 Jun 2024 13:14:25 -0400 Subject: [PATCH 093/139] refine matrix linking --- demo/App.tsx | 589 ++++++++++++++++++++++++++- demo/renderer/gosling.ts | 3 +- demo/renderer/linkedEncoding.test.ts | 65 ++- demo/renderer/linkedEncoding.ts | 50 ++- 4 files changed, 672 insertions(+), 35 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index 6549327bb..cec75159b 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -30,7 +30,7 @@ function App() { const plotElement = document.getElementById('plot') as HTMLDivElement; plotElement.innerHTML = ''; // Initialize the PixiManager. This will be used to get containers and overlay divs for the plots - const pixiManager = new PixiManager(1000, 1500, plotElement, setFps); + const pixiManager = new PixiManager(2000, 1500, plotElement, setFps); // addTextTrack(pixiManager); // addDummyTrack(pixiManager); // addCircularBrush(pixiManager); @@ -63,7 +63,7 @@ function App() { }; // Compile the spec - compile(matrix2, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); + compile(fullMatrix, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); }, []); return ( @@ -79,11 +79,590 @@ function App() { export default App; -const doubleMatrix = { +const fullMatrix = { + title: 'Matrix Visualization', + subtitle: 'Comparison of Micro-C and Hi-C for HFFc6 Cells', arrangement: 'horizontal', xDomain: { chromosome: 'chr7', interval: [77700000, 81000000] }, spacing: 1, linkingId: '-', + views: [ + { + orientation: 'vertical', + yOffset: 75, + views: [ + { + linkingId: 'y-link', + tracks: [ + { + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/HFFc6_H3K4me3.bigWig', + type: 'bigwig', + column: 'position', + value: 'peak', + binSize: 8 + }, + title: 'HFFc6_H3K4me3', + mark: 'bar', + x: { field: 'start', type: 'genomic', axis: 'top' }, + xe: { field: 'end', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative', axis: 'none' }, + color: { value: 'darkgreen' }, + height: 600, + width: 40 + }, + { + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/HFFc6_Atacseq.mRp.clN.bigWig', + type: 'bigwig', + column: 'position', + value: 'peak', + binSize: 8 + }, + title: 'HFFc6_ATAC', + mark: 'bar', + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + y: { field: 'peak', type: 'quantitative', axis: 'none' }, + color: { value: '#E79F00' }, + height: 600, + width: 40 + }, + { + alignment: 'overlay', + tracks: [ + { + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/HFFC6_CTCF.mRp.clN.bigWig', + type: 'bigwig', + column: 'position', + value: 'peak', + binSize: 8 + }, + mark: 'bar', + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + y: { + field: 'peak', + type: 'quantitative', + axis: 'none' + }, + color: { value: '#0072B2' } + }, + { + style: { backgroundOpacity: 0 }, + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ] + }, + dataTransform: [{ type: 'filter', field: 'strand', oneOf: ['+'] }], + mark: 'triangleRight', + x: { field: 'start', type: 'genomic' }, + size: { value: 13 }, + stroke: { value: 'white' }, + strokeWidth: { value: 1 }, + row: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'] + }, + color: { value: '#CB7AA7' } + }, + { + style: { backgroundOpacity: 0 }, + title: 'HFFC6_CTCF', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ] + }, + dataTransform: [{ type: 'filter', field: 'strand', oneOf: ['-'] }], + mark: 'triangleLeft', + x: { field: 'start', type: 'genomic' }, + size: { value: 13 }, + stroke: { value: 'white' }, + strokeWidth: { value: 1 }, + row: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'] + }, + color: { value: '#029F73' } + } + ], + height: 600, + width: 40 + } + ] + } + ] + }, + { + spacing: 30, + views: [ + { + spacing: 0, + arrangement: 'vertical', + views: [ + { + tracks: [ + { + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/HFFc6_H3K4me3.bigWig', + type: 'bigwig', + column: 'position', + value: 'peak', + binSize: 8 + }, + title: 'HFFc6_H3K4me3', + mark: 'bar', + x: { field: 'start', type: 'genomic', axis: 'top' }, + xe: { field: 'end', type: 'genomic' }, + y: { + field: 'peak', + type: 'quantitative', + axis: 'none' + }, + color: { value: 'darkgreen' }, + width: 570, + height: 40 + }, + { + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/HFFc6_Atacseq.mRp.clN.bigWig', + type: 'bigwig', + column: 'position', + value: 'peak', + binSize: 8 + }, + title: 'HFFc6_ATAC', + mark: 'bar', + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + y: { + field: 'peak', + type: 'quantitative', + axis: 'none' + }, + color: { value: '#E79F00' }, + width: 600, + height: 40 + }, + { + alignment: 'overlay', + tracks: [ + { + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/HFFC6_CTCF.mRp.clN.bigWig', + type: 'bigwig', + column: 'position', + value: 'peak', + binSize: 8 + }, + mark: 'bar', + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + y: { + field: 'peak', + type: 'quantitative', + axis: 'none' + }, + color: { value: '#0072B2' } + }, + { + style: { backgroundOpacity: 0 }, + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ] + }, + dataTransform: [{ type: 'filter', field: 'strand', oneOf: ['+'] }], + mark: 'triangleRight', + x: { field: 'start', type: 'genomic' }, + size: { value: 13 }, + stroke: { value: 'white' }, + strokeWidth: { value: 1 }, + row: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'] + }, + color: { value: '#CB7AA7' } + }, + { + style: { backgroundOpacity: 0 }, + title: 'HFFC6_CTCF', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ] + }, + dataTransform: [{ type: 'filter', field: 'strand', oneOf: ['-'] }], + mark: 'triangleLeft', + x: { field: 'start', type: 'genomic' }, + stroke: { value: 'white' }, + strokeWidth: { value: 1 }, + size: { value: 13 }, + row: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'] + }, + color: { value: '#029F73' } + } + ], + width: 600, + height: 40 + } + ] + }, + { + tracks: [ + { + title: 'HFFc6_Micro-C', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=hffc6-microc-hg38', + type: 'matrix' + }, + mark: 'bar', + x: { field: 'xs', type: 'genomic', axis: 'none' }, + xe: { field: 'xe', type: 'genomic', axis: 'none' }, + y: { field: 'ys', type: 'genomic', axis: 'none', linkingId: 'y-link' }, + ye: { field: 'ye', type: 'genomic', axis: 'none' }, + color: { + field: 'value', + type: 'quantitative', + range: 'warm' + }, + width: 600, + height: 600 + } + ] + }, + { + tracks: [ + { + title: 'Epilogos (hg38)', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=epilogos-hg38', + type: 'multivec', + row: 'category', + column: 'position', + value: 'value', + categories: [ + 'Active TSS', + 'Flanking Active TSS', + "Transcr at gene 5\\' and 3\\'", + 'Strong transcription', + 'Weak transcription', + 'Genic enhancers', + 'Enhancers', + 'ZNF genes & repeats', + 'Heterochromatin', + 'Bivalent/Poised TSS', + 'Flanking Bivalent TSS/Enh', + 'Bivalent Enhancer', + 'Repressed PolyComb', + 'Weak Repressed PolyComb', + 'Quiescent/Low' + ], + binSize: 8 + }, + dataTransform: [{ type: 'filter', field: 'value', inRange: [0, 999999] }], + mark: 'bar', + x: { field: 'start', type: 'genomic', axis: 'none' }, + xe: { field: 'end', type: 'genomic' }, + y: { + field: 'value', + type: 'quantitative', + axis: 'none' + }, + color: { + field: 'category', + type: 'nominal', + range: [ + '#FF0000', + '#FF4500', + '#32CD32', + '#008000', + '#006400', + '#C2E105', + '#FFFF00', + '#66CDAA', + '#8A91D0', + '#CD5C5C', + '#E9967A', + '#BDB76B', + '#808080', + '#C0C0C0', + 'gray' + ] + }, + width: 600, + height: 40 + } + ] + } + ] + }, + { + arrangement: 'vertical', + spacing: 0, + views: [ + { + tracks: [ + { + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/HFFc6_H3K4me3.bigWig', + type: 'bigwig', + column: 'position', + value: 'peak', + binSize: 8 + }, + title: 'HFFc6_H3K4me3', + mark: 'bar', + x: { field: 'start', type: 'genomic', axis: 'top' }, + xe: { field: 'end', type: 'genomic' }, + y: { + field: 'peak', + type: 'quantitative', + axis: 'none' + }, + color: { value: 'darkgreen' }, + width: 600, + height: 40 + }, + { + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/HFFc6_Atacseq.mRp.clN.bigWig', + type: 'bigwig', + column: 'position', + value: 'peak', + binSize: 8 + }, + title: 'HFFc6_ATAC', + mark: 'bar', + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + y: { + field: 'peak', + type: 'quantitative', + axis: 'none' + }, + color: { value: '#E79F00' }, + width: 600, + height: 40 + }, + { + alignment: 'overlay', + tracks: [ + { + data: { + url: 'https://s3.amazonaws.com/gosling-lang.org/data/HFFC6_CTCF.mRp.clN.bigWig', + type: 'bigwig', + column: 'position', + value: 'peak', + binSize: 8 + }, + mark: 'bar', + x: { field: 'start', type: 'genomic' }, + xe: { field: 'end', type: 'genomic' }, + y: { + field: 'peak', + type: 'quantitative', + axis: 'none' + }, + color: { value: '#0072B2' } + }, + { + style: { backgroundOpacity: 0 }, + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ] + }, + dataTransform: [{ type: 'filter', field: 'strand', oneOf: ['+'] }], + mark: 'triangleRight', + x: { field: 'start', type: 'genomic' }, + size: { value: 13 }, + stroke: { value: 'white' }, + strokeWidth: { value: 1 }, + row: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'] + }, + color: { value: '#CB7AA7' } + }, + { + style: { backgroundOpacity: 0 }, + title: 'HFFC6_CTCF', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', + type: 'beddb', + genomicFields: [ + { index: 1, name: 'start' }, + { index: 2, name: 'end' } + ], + valueFields: [ + { index: 5, name: 'strand', type: 'nominal' }, + { index: 3, name: 'name', type: 'nominal' } + ] + }, + dataTransform: [{ type: 'filter', field: 'strand', oneOf: ['-'] }], + mark: 'triangleLeft', + x: { field: 'start', type: 'genomic' }, + size: { value: 13 }, + stroke: { value: 'white' }, + strokeWidth: { value: 1 }, + row: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'] + }, + color: { value: '#029F73' } + } + ], + width: 600, + height: 40 + } + ] + }, + { + tracks: [ + { + title: 'HFFc6_Hi-C', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=hffc6-hic-hg38', + type: 'matrix' + }, + mark: 'bar', + x: { field: 'xs', type: 'genomic', axis: 'none' }, + xe: { field: 'xe', type: 'genomic', axis: 'none' }, + y: { field: 'ys', type: 'genomic', axis: 'none', linkingId: 'y-link' }, + ye: { field: 'ye', type: 'genomic', axis: 'none' }, + color: { + field: 'value', + type: 'quantitative', + range: 'warm' + }, + width: 600, + height: 600 + } + ] + }, + { + tracks: [ + { + title: 'Epilogos (hg38)', + data: { + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=epilogos-hg38', + type: 'multivec', + row: 'category', + column: 'position', + value: 'value', + categories: [ + 'Active TSS', + 'Flanking Active TSS', + "Transcr at gene 5\\' and 3\\'", + 'Strong transcription', + 'Weak transcription', + 'Genic enhancers', + 'Enhancers', + 'ZNF genes & repeats', + 'Heterochromatin', + 'Bivalent/Poised TSS', + 'Flanking Bivalent TSS/Enh', + 'Bivalent Enhancer', + 'Repressed PolyComb', + 'Weak Repressed PolyComb', + 'Quiescent/Low' + ], + binSize: 8 + }, + dataTransform: [{ type: 'filter', field: 'value', inRange: [0, 999999] }], + mark: 'bar', + x: { field: 'start', type: 'genomic', axis: 'none' }, + xe: { field: 'end', type: 'genomic' }, + y: { + field: 'value', + type: 'quantitative', + axis: 'none' + }, + color: { + field: 'category', + type: 'nominal', + range: [ + '#FF0000', + '#FF4500', + '#32CD32', + '#008000', + '#006400', + '#C2E105', + '#FFFF00', + '#66CDAA', + '#8A91D0', + '#CD5C5C', + '#E9967A', + '#BDB76B', + '#808080', + '#C0C0C0', + 'gray' + ] + }, + width: 600, + height: 40 + } + ] + } + ] + } + ] + } + ], + style: { outlineWidth: 0, background: '#F6F6F6' } +}; +const doubleMatrix = { + arrangement: 'horizontal', + xDomain: { chromosome: 'chr7', interval: [77700000, 81000000] }, + spacing: 1, + linkingId: 'all', views: [ { spacing: 30, @@ -187,7 +766,7 @@ const doubleMatrix = { mark: 'bar', x: { field: 'xs', type: 'genomic', axis: 'none' }, xe: { field: 'xe', type: 'genomic', axis: 'none' }, - y: { field: 'ys', type: 'genomic', axis: 'none' }, + y: { field: 'ys', type: 'genomic', axis: 'none', linkingId: 'y-link' }, ye: { field: 'ye', type: 'genomic', axis: 'none' }, color: { field: 'value', @@ -300,7 +879,7 @@ const doubleMatrix = { mark: 'bar', x: { field: 'xs', type: 'genomic', axis: 'none' }, xe: { field: 'xe', type: 'genomic', axis: 'none' }, - y: { field: 'ys', type: 'genomic', axis: 'none' }, + y: { field: 'ys', type: 'genomic', axis: 'none', linkingId: 'y-link' }, ye: { field: 'ye', type: 'genomic', axis: 'none' }, color: { field: 'value', diff --git a/demo/renderer/gosling.ts b/demo/renderer/gosling.ts index dde64ddd4..5f4d74995 100644 --- a/demo/renderer/gosling.ts +++ b/demo/renderer/gosling.ts @@ -24,7 +24,8 @@ export function processGoslingTrack( // Adds the axis tracks const [newTrackBbox, axisTrackDefs] = getAxisTrackDef(track, boundingBox, theme); if (axisTrackDefs) { - trackDefs.push(...axisTrackDefs); + // Only add the axis track if it is not overlayed on top of the Gosling track + if (!track.overlayOnPreviousTrack) trackDefs.push(...axisTrackDefs); // modify the bounding box to exclude the axis track boundingBox = newTrackBbox; } diff --git a/demo/renderer/linkedEncoding.test.ts b/demo/renderer/linkedEncoding.test.ts index bb7da2164..ec02cec03 100644 --- a/demo/renderer/linkedEncoding.test.ts +++ b/demo/renderer/linkedEncoding.test.ts @@ -192,6 +192,47 @@ describe('Link tracks', () => { ] `); }); + it('track has no x-encoding, but the overlay does', () => { + const spec = { + views: [ + { + tracks: [ + { + id: 'track-1', + // no x, must use the x in overlay + y: { field: 'b', type: 'quantitative' }, + _overlay: [ + { + mark: 'line', + id: 'overlay-1', + x: { field: 'a', type: 'genomic', linkingId: 'link1' }, + y: { field: 'b', type: 'quantitative' } + } + ] + }, + ] + } + ] + }; + const result = getLinkedEncodings(spec); + expect(result).toMatchInlineSnapshot(` + [ + { + "linkingId": "link1", + "signal": [ + 0, + 3088269832, + ], + "tracks": [ + { + "encoding": "x", + "id": "track-1", + }, + ], + }, + ] + `); + }); }); describe('Link brushes', () => { @@ -328,8 +369,8 @@ describe('Heatmap', () => { { "linkingId": undefined, "signal": [ - 0, - 3088269832, + 1309704303, + 1313004303, ], "tracks": [ { @@ -339,18 +380,18 @@ describe('Heatmap', () => { ], }, { - linkingId: undefined, - signal: [ - 0, - 3088269832 + "linkingId": undefined, + "signal": [ + 1309704303, + 1313004303, ], - tracks: [ + "tracks": [ { - encoding: "y", - id: "matrix-1" - } - ] - } + "encoding": "y", + "id": "matrix-1", + }, + ], + }, ] `); }); diff --git a/demo/renderer/linkedEncoding.ts b/demo/renderer/linkedEncoding.ts index 2bf4ffd25..c0012562e 100644 --- a/demo/renderer/linkedEncoding.ts +++ b/demo/renderer/linkedEncoding.ts @@ -147,6 +147,23 @@ function getLinkedFeaturesRecursive(gs: GoslingSpec): LinkInfo { * Extracts the linkingId from tracks that have a brush overlay */ function getSingleViewTrackLinks(gs: SingleView): TrackLink[] { + // Helper function to create a track link for the x encoding + function createXTrackLink(trackId: string, track: Track, trackType: TrackType, gs: SingleView) { + const trackLink = { + trackId: trackId, + linkingId: track.x.linkingId, + trackType, + encoding: 'x' + } as TrackLink; + // If the track has a domain, we create a signal and add it to the trackLink + if (track.x.domain !== undefined) { + const { assembly } = gs; + const domain = getDomain(track.x.domain, assembly); + trackLink.signal = signal(domain); + } + return trackLink; + } + const { tracks } = gs; const trackLinks: TrackLink[] = []; tracks.forEach(track => { @@ -159,7 +176,7 @@ function getSingleViewTrackLinks(gs: SingleView): TrackLink[] { const trackLink = { trackId: track.id, linkingId: track.y.linkingId, // we may or may not have a linkingId - trackType: TrackType.Heatmap, + trackType, encoding: 'y', signal: signal(trackDomain) } as TrackLink; @@ -168,23 +185,20 @@ function getSingleViewTrackLinks(gs: SingleView): TrackLink[] { // Handle x domain if ('x' in track && track.x && 'linkingId' in track.x && track.x?.linkingId !== undefined) { if (track.mark === 'brush') console.warn('Track with brush mark should only be used as an overlay'); - const trackLink = { - trackId: track.id, - linkingId: track.x.linkingId, - trackType, - encoding: 'x' - } as TrackLink; - // If the track has a domain, we create a signal and add it to the trackLink - if (track.x.domain !== undefined) { - const { assembly } = gs; - const domain = getDomain(track.x.domain, assembly); - trackLink.signal = signal(domain); - } + const trackLink = createXTrackLink(track.id, track, trackType, gs); trackLinks.push(trackLink); } // Handle linking in the brushes which are defined in the overlay tracks if (!('_overlay' in track)) return; + // Handle special case where we have a single overlay track that is not a brush + if (track._overlay.length === 1 && track._overlay[0].mark !== 'brush') { + const firstOverlay = track._overlay[0]; + const trackLink = createXTrackLink(track.id, firstOverlay, trackType, gs); + trackLinks.push(trackLink); + return; + } + // Handle case where we have multiple overlay tracks (we only care about the brushes) track._overlay!.forEach(overlay => { if (overlay.mark === 'brush') { const trackType = gs.layout === 'linear' ? TrackType.BrushLinear : TrackType.BrushCircular; @@ -221,10 +235,12 @@ function getSingleViewLinks(gs: SingleView): ViewLink { }; // Add each track to the link tracks.forEach(track => { - // If the track is already linked, we don't need to add it again - if ('x' in track && track.x && 'linkingId' in track.x && track.x?.linkingId) { - return; - } + const hasXEncoding = 'x' in track; + const hasLinkingId = hasXEncoding && track.x && 'linkingId' in track.x && track.x?.linkingId; + + // If the track is already linked to something else, we don't need to add it again + if (!hasXEncoding || hasLinkingId) return; + // Add overlaid brush tracks to the link if ('_overlay' in track) { track._overlay?.forEach(overlay => { From d75df4d635c9e50f6ffd8b276bd2f29cb1a3261e Mon Sep 17 00:00:00 2001 From: etowahadams Date: Fri, 28 Jun 2024 13:57:00 -0400 Subject: [PATCH 094/139] feat: basic gosling component --- demo/App.tsx | 45 ++-------------------------- demo/GoslingComponent.tsx | 63 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 43 deletions(-) create mode 100644 demo/GoslingComponent.tsx diff --git a/demo/App.tsx b/demo/App.tsx index cec75159b..e91e41dd9 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -21,57 +21,16 @@ import { createTrackDefs, renderTrackDefs, showTrackInfoPositions } from './rend import type { TrackInfo } from 'src/compiler/bounding-box'; import type { GoslingSpec } from 'gosling.js'; import { getLinkedEncodings } from './renderer/linkedEncoding'; +import { GoslingComponent } from './GoslingComponent'; function App() { const [fps, setFps] = useState(120); - - useEffect(() => { - // Create the new plot - const plotElement = document.getElementById('plot') as HTMLDivElement; - plotElement.innerHTML = ''; - // Initialize the PixiManager. This will be used to get containers and overlay divs for the plots - const pixiManager = new PixiManager(2000, 1500, plotElement, setFps); - // addTextTrack(pixiManager); - // addDummyTrack(pixiManager); - // addCircularBrush(pixiManager); - // addGoslingTrack(pixiManager); - // addAxisTrack(pixiManager); - // addLinearBrush(pixiManager); - // addBigwig(pixiManager); - // addHeatmap(pixiManager); - // addLeftAxisTrack(pixiManager); - // addGoslingVertical(pixiManager); - - const callback = ( - hg: HiGlassSpec, - size, - gs: GoslingSpec, - tracksAndViews, - idTable, - trackInfos: TrackInfo[], - theme: Require - ) => { - console.warn(trackInfos); - console.warn(tracksAndViews); - console.warn(gs); - // showTrackInfoPositions(trackInfos, pixiManager); - const linkedEncodings = getLinkedEncodings(gs); - console.warn('linkedEncodings', linkedEncodings); - const trackDefs = createTrackDefs(trackInfos, theme); - console.warn('trackDefs', trackDefs); - renderTrackDefs(trackDefs, linkedEncodings, pixiManager); - }; - - // Compile the spec - compile(fullMatrix, callback, [], getTheme('light'), { containerSize: { width: 300, height: 300 } }); - }, []); - return ( <>

HiGlass/Gosling tracks with new renderer

-
+
); diff --git a/demo/GoslingComponent.tsx b/demo/GoslingComponent.tsx new file mode 100644 index 000000000..86b414a85 --- /dev/null +++ b/demo/GoslingComponent.tsx @@ -0,0 +1,63 @@ +import React, { useState, useEffect } from 'react'; +import { PixiManager } from '@pixi-manager'; + +import { compile } from '../src/compiler/compile'; +import { getTheme } from '../src/core/utils/theme'; + +import type { HiGlassSpec } from '@gosling-lang/higlass-schema'; +import { createTrackDefs, renderTrackDefs, showTrackInfoPositions } from './renderer/main'; +import type { TrackInfo } from 'src/compiler/bounding-box'; +import type { GoslingSpec } from 'gosling.js'; +import { getLinkedEncodings } from './renderer/linkedEncoding'; + +interface GoslingComponentProps { + spec: GoslingSpec; + width: number; + height: number; +} +export function GoslingComponent({ spec, width, height }: GoslingComponentProps) { + const [fps, setFps] = useState(120); + + useEffect(() => { + // Create the new plot + const plotElement = document.getElementById('plot') as HTMLDivElement; + plotElement.innerHTML = ''; + // Initialize the PixiManager. This will be used to get containers and overlay divs for the plots + const pixiManager = new PixiManager(width, height, plotElement, setFps); + // addTextTrack(pixiManager); + // addDummyTrack(pixiManager); + // addCircularBrush(pixiManager); + // addGoslingTrack(pixiManager); + // addAxisTrack(pixiManager); + // addLinearBrush(pixiManager); + // addBigwig(pixiManager); + // addHeatmap(pixiManager); + // addLeftAxisTrack(pixiManager); + // addGoslingVertical(pixiManager); + + const callback = ( + hg: HiGlassSpec, + size, + gs: GoslingSpec, + tracksAndViews, + idTable, + trackInfos: TrackInfo[], + theme: Require + ) => { + console.warn(trackInfos); + console.warn(tracksAndViews); + console.warn(gs); + // showTrackInfoPositions(trackInfos, pixiManager); + const linkedEncodings = getLinkedEncodings(gs); + console.warn('linkedEncodings', linkedEncodings); + const trackDefs = createTrackDefs(trackInfos, theme); + console.warn('trackDefs', trackDefs); + renderTrackDefs(trackDefs, linkedEncodings, pixiManager); + }; + + // Compile the spec + compile(spec, callback, [], getTheme('light'), { containerSize: { width: width, height: height } }); + }, []); + + return
; +} From 608a0dc1dd8f5b41bf2b493adc0c037efaaf8bc7 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Fri, 28 Jun 2024 14:41:27 -0400 Subject: [PATCH 095/139] feat: basic editor --- demo/GoslingComponent.tsx | 26 +++++++++++--------------- editor/Editor.tsx | 15 ++------------- index.html | 2 +- src/index.ts | 6 +++--- 4 files changed, 17 insertions(+), 32 deletions(-) diff --git a/demo/GoslingComponent.tsx b/demo/GoslingComponent.tsx index 86b414a85..70d8162a8 100644 --- a/demo/GoslingComponent.tsx +++ b/demo/GoslingComponent.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { PixiManager } from '@pixi-manager'; import { compile } from '../src/compiler/compile'; @@ -11,7 +11,7 @@ import type { GoslingSpec } from 'gosling.js'; import { getLinkedEncodings } from './renderer/linkedEncoding'; interface GoslingComponentProps { - spec: GoslingSpec; + spec: GoslingSpec | undefined; width: number; height: number; } @@ -19,21 +19,13 @@ export function GoslingComponent({ spec, width, height }: GoslingComponentProps) const [fps, setFps] = useState(120); useEffect(() => { - // Create the new plot + console.warn('got spec', spec); + if (!spec) return; + const plotElement = document.getElementById('plot') as HTMLDivElement; plotElement.innerHTML = ''; // Initialize the PixiManager. This will be used to get containers and overlay divs for the plots const pixiManager = new PixiManager(width, height, plotElement, setFps); - // addTextTrack(pixiManager); - // addDummyTrack(pixiManager); - // addCircularBrush(pixiManager); - // addGoslingTrack(pixiManager); - // addAxisTrack(pixiManager); - // addLinearBrush(pixiManager); - // addBigwig(pixiManager); - // addHeatmap(pixiManager); - // addLeftAxisTrack(pixiManager); - // addGoslingVertical(pixiManager); const callback = ( hg: HiGlassSpec, @@ -57,7 +49,11 @@ export function GoslingComponent({ spec, width, height }: GoslingComponentProps) // Compile the spec compile(spec, callback, [], getTheme('light'), { containerSize: { width: width, height: height } }); - }, []); + }, [spec]); - return
; + return ( +
+
+
+ ); } diff --git a/editor/Editor.tsx b/editor/Editor.tsx index c681760e2..7eade1ebe 100644 --- a/editor/Editor.tsx +++ b/editor/Editor.tsx @@ -25,6 +25,7 @@ import { traverseTracksAndViews } from '../src/compiler/spec-preprocess'; import { examples, type Example } from './example'; import EditorPanel, { type EditorLangauge } from './EditorPanel'; import EditorExamples from './EditorExamples'; +import { GoslingComponent } from '../demo/GoslingComponent'; import './Editor.css'; import { uuid } from '../src/core/utils/uuid'; @@ -1211,19 +1212,7 @@ function Editor(props: RouteComponentProps) { background: isResponsive ? 'white' : 'none' }} > - { - setHg(h); - }} - /> + {showViews && !isResponsive ? VisHierarchy : null} {/* {expertMode && false ? ( diff --git a/index.html b/index.html index 793e9ff70..8e8b18537 100644 --- a/index.html +++ b/index.html @@ -19,6 +19,6 @@
- + diff --git a/src/index.ts b/src/index.ts index 7a73b0293..04880733e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,9 +8,9 @@ export { GoslingTemplates } from './core/utils/template'; export type { Theme } from './core/utils/theme'; export { Themes, isThereTheme, getTheme } from '@gosling-lang/gosling-theme'; -export { init } from './core/init'; +// export { init } from './core/init'; export { compile } from './compiler/compile'; export { validateGoslingSpec } from '@gosling-lang/gosling-schema'; -export { GoslingComponent } from './core/gosling-component'; +// export { GoslingComponent } from './core/gosling-component'; export type { GoslingRef } from './core/gosling-component'; -export { embed } from './core/gosling-embed'; +// export { embed } from './core/gosling-embed'; From 16ee5b746c0a7f2dd7606c96a8552e35de956295 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Fri, 28 Jun 2024 15:35:54 -0400 Subject: [PATCH 096/139] fix: x domain --- demo/renderer/linkedEncoding.test.ts | 81 ++++++++++++++++++++-------- demo/renderer/linkedEncoding.ts | 12 +++-- 2 files changed, 67 insertions(+), 26 deletions(-) diff --git a/demo/renderer/linkedEncoding.test.ts b/demo/renderer/linkedEncoding.test.ts index ec02cec03..d6dd169ae 100644 --- a/demo/renderer/linkedEncoding.test.ts +++ b/demo/renderer/linkedEncoding.test.ts @@ -193,29 +193,29 @@ describe('Link tracks', () => { `); }); it('track has no x-encoding, but the overlay does', () => { - const spec = { - views: [ - { - tracks: [ - { - id: 'track-1', - // no x, must use the x in overlay - y: { field: 'b', type: 'quantitative' }, - _overlay: [ - { - mark: 'line', - id: 'overlay-1', - x: { field: 'a', type: 'genomic', linkingId: 'link1' }, - y: { field: 'b', type: 'quantitative' } - } - ] - }, - ] - } - ] - }; - const result = getLinkedEncodings(spec); - expect(result).toMatchInlineSnapshot(` + const spec = { + views: [ + { + tracks: [ + { + id: 'track-1', + // no x, must use the x in overlay + y: { field: 'b', type: 'quantitative' }, + _overlay: [ + { + mark: 'line', + id: 'overlay-1', + x: { field: 'a', type: 'genomic', linkingId: 'link1' }, + y: { field: 'b', type: 'quantitative' } + } + ] + } + ] + } + ] + }; + const result = getLinkedEncodings(spec); + expect(result).toMatchInlineSnapshot(` [ { "linkingId": "link1", @@ -233,6 +233,41 @@ describe('Link tracks', () => { ] `); }); + + it('domain in x encoding', () => { + // When there is a domain in the x encoding we expect it to be used as the signal + const spec = { + tracks: [ + { + id: 'track-9', + mark: 'withinLink', + x: { + field: 's1', + type: 'genomic', + domain: { chromosome: 'chr1', interval: [103900000, 104100000] } + }, + } + ] + }; + const result = getLinkedEncodings(spec); + expect(result).toMatchInlineSnapshot(` + [ + { + "linkingId": undefined, + "signal": [ + 103900000, + 104100000, + ], + "tracks": [ + { + "encoding": "x", + "id": "track-9", + }, + ], + }, + ] + `); + }); }); describe('Link brushes', () => { diff --git a/demo/renderer/linkedEncoding.ts b/demo/renderer/linkedEncoding.ts index c0012562e..9ad751ab8 100644 --- a/demo/renderer/linkedEncoding.ts +++ b/demo/renderer/linkedEncoding.ts @@ -182,9 +182,14 @@ function getSingleViewTrackLinks(gs: SingleView): TrackLink[] { } as TrackLink; trackLinks.push(trackLink); } + const hasLinkingId = 'x' in track && track.x && 'linkingId' in track.x && track.x?.linkingId !== undefined; + const hasXDomain = 'x' in track && track.x && 'domain' in track.x && track.x?.domain !== undefined; // Handle x domain - if ('x' in track && track.x && 'linkingId' in track.x && track.x?.linkingId !== undefined) { - if (track.mark === 'brush') console.warn('Track with brush mark should only be used as an overlay'); + if (hasLinkingId || hasXDomain) { + if (track.mark === 'brush') { + console.warn('Track with brush mark should only be used as an overlay'); + return; + } const trackLink = createXTrackLink(track.id, track, trackType, gs); trackLinks.push(trackLink); } @@ -237,9 +242,10 @@ function getSingleViewLinks(gs: SingleView): ViewLink { tracks.forEach(track => { const hasXEncoding = 'x' in track; const hasLinkingId = hasXEncoding && track.x && 'linkingId' in track.x && track.x?.linkingId; + const hasXDomain = 'x' in track && track.x && 'domain' in track.x && track.x?.domain !== undefined; // If the track is already linked to something else, we don't need to add it again - if (!hasXEncoding || hasLinkingId) return; + if (!hasXEncoding || hasLinkingId || hasXDomain) return; // Add overlaid brush tracks to the link if ('_overlay' in track) { From 426f8cccbadaed2c4bf2c735f5278ff8be24990b Mon Sep 17 00:00:00 2001 From: etowahadams Date: Fri, 28 Jun 2024 17:20:36 -0400 Subject: [PATCH 097/139] feat: fix linking bugs --- demo/renderer/linkedEncoding.ts | 63 +++++++++++++++++++++------------ demo/renderer/main.ts | 7 ++-- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/demo/renderer/linkedEncoding.ts b/demo/renderer/linkedEncoding.ts index 9ad751ab8..872371980 100644 --- a/demo/renderer/linkedEncoding.ts +++ b/demo/renderer/linkedEncoding.ts @@ -69,6 +69,7 @@ export function getLinkedEncodings(gs: GoslingSpec) { tracks: [...linkedTracks, ...viewTracks] } as LinkedEncoding; }); + console.warn('linked Encodings from view', [...linkedEncodings]); // Combine trackLinks that do not belong to any viewLink const unlinkedTracks = trackLinks.filter( trackLink => @@ -164,28 +165,25 @@ function getSingleViewTrackLinks(gs: SingleView): TrackLink[] { return trackLink; } - const { tracks } = gs; + const { assembly, xDomain, yDomain, tracks } = gs; + const viewXDomain = getDomain(xDomain, assembly); const trackLinks: TrackLink[] = []; tracks.forEach(track => { const trackType = isHeatmapTrack(track) ? TrackType.Heatmap : TrackType.Gosling; - // Handle the y domain when we have a heatmap track if (trackType === TrackType.Heatmap) { - const { assembly, xDomain, yDomain } = gs; - const trackDomain = getDomain(yDomain ?? xDomain, assembly); // default to the xDomain if no yDomain + const trackYDomain = getDomain(yDomain ?? xDomain, assembly); // default to the xDomain if no yDomain const trackLink = { trackId: track.id, linkingId: track.y.linkingId, // we may or may not have a linkingId trackType, encoding: 'y', - signal: signal(trackDomain) + signal: signal(trackYDomain) } as TrackLink; trackLinks.push(trackLink); } - const hasLinkingId = 'x' in track && track.x && 'linkingId' in track.x && track.x?.linkingId !== undefined; - const hasXDomain = 'x' in track && track.x && 'domain' in track.x && track.x?.domain !== undefined; // Handle x domain - if (hasLinkingId || hasXDomain) { + if (hasDiffXDomainThanView(track, assembly, viewXDomain)) { if (track.mark === 'brush') { console.warn('Track with brush mark should only be used as an overlay'); return; @@ -196,13 +194,13 @@ function getSingleViewTrackLinks(gs: SingleView): TrackLink[] { // Handle linking in the brushes which are defined in the overlay tracks if (!('_overlay' in track)) return; - // Handle special case where we have a single overlay track that is not a brush - if (track._overlay.length === 1 && track._overlay[0].mark !== 'brush') { - const firstOverlay = track._overlay[0]; - const trackLink = createXTrackLink(track.id, firstOverlay, trackType, gs); - trackLinks.push(trackLink); - return; - } + // // Handle special case where we have a single overlay track that is not a brush + // if (track._overlay.length === 1 && track._overlay[0].mark !== 'brush') { + // const firstOverlay = track._overlay[0]; + // const trackLink = createXTrackLink(track.id, firstOverlay, trackType, gs); + // trackLinks.push(trackLink); + // return; + // } // Handle case where we have multiple overlay tracks (we only care about the brushes) track._overlay!.forEach(overlay => { if (overlay.mark === 'brush') { @@ -230,25 +228,29 @@ function getSingleViewTrackLinks(gs: SingleView): TrackLink[] { */ function getSingleViewLinks(gs: SingleView): ViewLink { const { tracks, xDomain, assembly } = gs; - const domain = getDomain(xDomain, assembly); + const viewXDomain = getDomain(xDomain, assembly); const newLink: ViewLink = { linkingId: gs.linkingId, encoding: 'x', - signal: signal(domain), + signal: signal(viewXDomain), trackIds: [] }; + console.warn('single view', gs); // Add each track to the link tracks.forEach(track => { - const hasXEncoding = 'x' in track; - const hasLinkingId = hasXEncoding && track.x && 'linkingId' in track.x && track.x?.linkingId; - const hasXDomain = 'x' in track && track.x && 'domain' in track.x && track.x?.domain !== undefined; - // If the track is already linked to something else, we don't need to add it again - if (!hasXEncoding || hasLinkingId || hasXDomain) return; + console.warn(hasDiffXDomainThanView(track, assembly, viewXDomain)); + if (hasDiffXDomainThanView(track, assembly, viewXDomain)) return; + // Some tracks don't have any encodings but they do have overlaid tracks + // const hasXEncoding = 'x' in track; + const hasOverlaidTracks = '_overlay' in track; + // if (!hasXEncoding && hasOverlaidTracks) { + // newLink.trackIds.push(track.id); + // } // Add overlaid brush tracks to the link - if ('_overlay' in track) { + if (hasOverlaidTracks) { track._overlay?.forEach(overlay => { if (overlay.mark === 'brush') { newLink.trackIds.push(overlay.id); @@ -261,6 +263,21 @@ function getSingleViewLinks(gs: SingleView): ViewLink { return newLink; } +function hasDiffXDomainThanView(track: Track, assembly: Assembly | undefined, viewXDomain: [number, number]) { + // If the track has a linkingId, it def has a different domain than the view + const hasLinkingId = 'x' in track && track.x && 'linkingId' in track.x && track.x?.linkingId !== undefined; + if (hasLinkingId) return true; + + // If the x encoding as a domain, we need to check whether it is different than the view + const hasXEncodingDomain = 'x' in track && track.x && 'domain' in track.x && track.x?.domain !== undefined; + + if (hasXEncodingDomain) { + const xEncodingDomain = getDomain(track.x?.domain, assembly); + return !viewXDomain.every((val, index) => val === xEncodingDomain[index]); + } + return false; +} + /** * For a given xDomain and Assembly, return the the absolute domain [start, end] */ diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index cc2d29288..6dc5b944c 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -149,9 +149,10 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE return; } - new AxisTrack(options, domain, pixiManager.makeContainer(boundingBox), options.orientation).addInteractor( - plot => panZoom(plot, domain) - ); + const axisTrack = new AxisTrack(options, domain, pixiManager.makeContainer(boundingBox), options.orientation); + if (!options.static) { + axisTrack.addInteractor(plot => panZoom(plot, domain)); + } } if (type === TrackType.BrushLinear) { const domain = getEncodingSignal(trackDef.trackId, 'x', linkedEncodings); From 79a885f23107b8e39b832adc30738cd2658ca326 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Fri, 28 Jun 2024 17:21:13 -0400 Subject: [PATCH 098/139] remove old test --- demo/renderer/linkedEncoding.test.ts | 41 ---------------------------- 1 file changed, 41 deletions(-) diff --git a/demo/renderer/linkedEncoding.test.ts b/demo/renderer/linkedEncoding.test.ts index d6dd169ae..eafe613ae 100644 --- a/demo/renderer/linkedEncoding.test.ts +++ b/demo/renderer/linkedEncoding.test.ts @@ -192,47 +192,6 @@ describe('Link tracks', () => { ] `); }); - it('track has no x-encoding, but the overlay does', () => { - const spec = { - views: [ - { - tracks: [ - { - id: 'track-1', - // no x, must use the x in overlay - y: { field: 'b', type: 'quantitative' }, - _overlay: [ - { - mark: 'line', - id: 'overlay-1', - x: { field: 'a', type: 'genomic', linkingId: 'link1' }, - y: { field: 'b', type: 'quantitative' } - } - ] - } - ] - } - ] - }; - const result = getLinkedEncodings(spec); - expect(result).toMatchInlineSnapshot(` - [ - { - "linkingId": "link1", - "signal": [ - 0, - 3088269832, - ], - "tracks": [ - { - "encoding": "x", - "id": "track-1", - }, - ], - }, - ] - `); - }); it('domain in x encoding', () => { // When there is a domain in the x encoding we expect it to be used as the signal From 7ffb851c234352a5cff3ae039f880f0e3dc845f2 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Mon, 1 Jul 2024 14:11:03 -0400 Subject: [PATCH 099/139] fix: csv datafetcher options --- demo/renderer/dataFetcher.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/demo/renderer/dataFetcher.ts b/demo/renderer/dataFetcher.ts index 337c330ad..3c9997760 100644 --- a/demo/renderer/dataFetcher.ts +++ b/demo/renderer/dataFetcher.ts @@ -16,6 +16,28 @@ export function getDataFetcher(spec: Track) { return new BigWigDataFetcher(spec.data); } if (spec.data.type == 'csv') { - return new CsvDataFetcher(spec.data); + console.warn('csv', spec.data); + const fields = getFields(spec); + return new CsvDataFetcher({ ...spec.data, ...fields }); } } + +/** + * Some datafetchers need to know which encoding corresponds to which field + */ +function getFields(spec: Track) { + const fields: { x?: string; xe?: string; y?: string; ye?: string } = {}; + if ('x' in spec && spec.x && 'field' in spec.x) { + fields.x = spec.x.field; + } + if ('xe' in spec && spec.xe && 'field' in spec.xe) { + fields.xe = spec.xe.field; + } + if ('y' in spec && spec.y && 'field' in spec.y) { + fields.y = spec.y.field; + } + if ('ye' in spec && spec.ye && 'field' in spec.ye) { + fields.ye = spec.ye.field; + } + return fields; +} From 314f4be0836513555b7326616b5511c4e0bd7e1f Mon Sep 17 00:00:00 2001 From: etowahadams Date: Tue, 2 Jul 2024 09:58:58 -0400 Subject: [PATCH 100/139] feat: add csv example --- demo/examples/csv-track-example.ts | 279 +++++++++++++++++++++++++++++ demo/examples/index.ts | 1 + demo/renderer/dataFetcher.ts | 1 - 3 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 demo/examples/csv-track-example.ts diff --git a/demo/examples/csv-track-example.ts b/demo/examples/csv-track-example.ts new file mode 100644 index 000000000..1cb22cc11 --- /dev/null +++ b/demo/examples/csv-track-example.ts @@ -0,0 +1,279 @@ +import { PixiManager } from '@pixi-manager'; +import { GoslingTrack } from '@gosling-lang/gosling-track'; +import { DataFetcher } from '@higlass/datafetcher'; +import { fakePubSub } from '@higlass/utils'; +import { signal } from '@preact/signals-core'; +import { panZoom } from '@gosling-lang/interactors'; +import { CsvDataFetcher } from '@data-fetchers'; + +export function addCSVTrack(pixiManager: PixiManager) { + const domain = signal<[number, number]>([2490980562, 2491580562]); + const dataFetcher = new CsvDataFetcher(dataOptions); + + const pos0 = { x: 10, y: 10, width: 700, height: 70 }; + new GoslingTrack(goslingTrackOptions, dataFetcher, pixiManager.makeContainer(pos0)).addInteractor(plot => + panZoom(plot, domain) + ); +} + +const dataOptions = { + url: 'https://raw.githubusercontent.com/vigsterkr/circos/master/data/5/segdup.txt', + type: 'csv', + headerNames: ['id', 'chr', 'p1', 'p2'], + chromosomePrefix: 'hs', + chromosomeField: 'chr', + genomicFields: ['p1', 'p2'], + separator: ' ', + longToWideId: 'id', + x: 'p1', + xe: 'p2', + urlFetchOptions: {}, + indexUrlFetchOptions: {} +}; +export const goslingTrackOptions = { + id: 'dcac90ed-3aab-4ab2-9f58-4978bfbca8a3', + siblingIds: ['dcac90ed-3aab-4ab2-9f58-4978bfbca8a3'], + showMousePosition: true, + mousePositionColor: '#000000', + labelPosition: 'none', + labelShowResolution: false, + labelColor: 'black', + labelBackgroundColor: 'white', + labelBackgroundOpacity: 0.5, + labelTextOpacity: 1, + labelLeftMargin: 1, + labelTopMargin: 1, + labelRightMargin: 0, + labelBottomMargin: 0, + backgroundColor: 'transparent', + spec: { + layout: 'linear', + xDomain: { + chromosome: 'chr17', + interval: [200000, 800000] + }, + assembly: 'hg38', + orientation: 'horizontal', + static: false, + zoomLimits: [1, null], + centerRadius: 0.3, + xOffset: 0, + yOffset: 0, + style: { outlineWidth: 0 }, + data: { + url: 'https://raw.githubusercontent.com/vigsterkr/circos/master/data/5/segdup.txt', + type: 'csv', + headerNames: ['id', 'chr', 'p1', 'p2'], + chromosomePrefix: 'hs', + chromosomeField: 'chr', + genomicFields: ['p1', 'p2'], + separator: ' ', + longToWideId: 'id' + }, + dataTransform: [{ type: 'filter', field: 'chr', oneOf: ['hs17'] }], + mark: 'rect', + x: { + field: 'p1', + type: 'genomic', + domain: { + chromosome: 'chr17', + interval: [200000, 800000] + }, + axis: 'top' + }, + xe: { field: 'p2', type: 'genomic' }, + color: { + field: 'chr_2', + type: 'nominal', + domain: [ + 'chr1', + 'chr2', + 'chr3', + 'chr4', + 'chr5', + 'chr6', + 'chr7', + 'chr8', + 'chr9', + 'chr10', + 'chr11', + 'chr12', + 'chr13', + 'chr14', + 'chr15', + 'chr16', + 'chr17', + 'chr18', + 'chr19', + 'chr20', + 'chr21', + 'chr22', + 'chrX', + 'chrY' + ] + }, + opacity: { value: 0.5 }, + size: { value: 14 }, + overlayOnPreviousTrack: false, + width: 700, + height: 70, + id: 'dcac90ed-3aab-4ab2-9f58-4978bfbca8a3', + _renderingId: '620ebc27-bc9f-45c5-b358-ea59bf89537f' + }, + theme: { + base: 'light', + root: { + background: 'white', + titleColor: 'black', + titleBackgroundColor: 'transparent', + titleFontSize: 18, + titleFontFamily: 'Arial', + titleAlign: 'left', + titleFontWeight: 'bold', + subtitleColor: 'gray', + subtitleBackgroundColor: 'transparent', + subtitleFontSize: 16, + subtitleFontFamily: 'Arial', + subtitleFontWeight: 'normal', + subtitleAlign: 'left', + showMousePosition: true, + mousePositionColor: '#000000' + }, + track: { + background: 'transparent', + alternatingBackground: 'transparent', + titleColor: 'black', + titleBackground: 'white', + titleFontSize: 24, + titleAlign: 'left', + outline: 'black', + outlineWidth: 1 + }, + legend: { + position: 'top', + background: 'white', + backgroundOpacity: 0.7, + labelColor: 'black', + labelFontSize: 12, + labelFontWeight: 'normal', + labelFontFamily: 'Arial', + backgroundStroke: '#DBDBDB', + tickColor: 'black' + }, + axis: { + tickColor: 'black', + labelColor: 'black', + labelMargin: 5, + labelExcludeChrPrefix: false, + labelFontSize: 12, + labelFontWeight: 'normal', + labelFontFamily: 'Arial', + baselineColor: 'black', + gridColor: '#E3E3E3', + gridStrokeWidth: 1, + gridStrokeType: 'solid', + gridStrokeDash: [4, 4] + }, + markCommon: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + point: { + color: '#E79F00', + size: 3, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + rect: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + triangle: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + area: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + line: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + bar: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + rule: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 1, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + link: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 1, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + }, + text: { + color: '#E79F00', + size: 1, + stroke: 'black', + strokeWidth: 0, + opacity: 1, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6], + textAnchor: 'middle', + textFontWeight: 'normal' + }, + brush: { + color: 'gray', + size: 1, + stroke: 'black', + strokeWidth: 1, + opacity: 0.3, + nominalColorRange: ['#E79F00', '#029F73', '#0072B2', '#CB7AA7', '#D45E00', '#57B4E9', '#EFE441'], + quantitativeSizeRange: [2, 6] + } + } +}; diff --git a/demo/examples/index.ts b/demo/examples/index.ts index 039ebdafe..525ad0f0f 100644 --- a/demo/examples/index.ts +++ b/demo/examples/index.ts @@ -8,3 +8,4 @@ export { addBigwig } from './bigwig-data-example'; export { addHeatmap } from './heatmap-example'; export { addLeftAxisTrack } from './left-track-example'; export { addGoslingVertical } from './gosling-track-vertical'; +export { addCSVTrack } from './csv-track-example'; diff --git a/demo/renderer/dataFetcher.ts b/demo/renderer/dataFetcher.ts index 3c9997760..0923a8e95 100644 --- a/demo/renderer/dataFetcher.ts +++ b/demo/renderer/dataFetcher.ts @@ -16,7 +16,6 @@ export function getDataFetcher(spec: Track) { return new BigWigDataFetcher(spec.data); } if (spec.data.type == 'csv') { - console.warn('csv', spec.data); const fields = getFields(spec); return new CsvDataFetcher({ ...spec.data, ...fields }); } From 9c7f6b82ff87d8b3dd0aeda6162fe2842f8ce832 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Tue, 2 Jul 2024 09:59:37 -0400 Subject: [PATCH 101/139] fix: give spec --- demo/renderer/gosling.ts | 1 + editor/example/json-spec/give.ts | 50 +++++++++++++++----------------- 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/demo/renderer/gosling.ts b/demo/renderer/gosling.ts index 5f4d74995..6af1cfb40 100644 --- a/demo/renderer/gosling.ts +++ b/demo/renderer/gosling.ts @@ -27,6 +27,7 @@ export function processGoslingTrack( // Only add the axis track if it is not overlayed on top of the Gosling track if (!track.overlayOnPreviousTrack) trackDefs.push(...axisTrackDefs); // modify the bounding box to exclude the axis track + // warning: there could be some weirdness around overlayOnPreviousTrack here that needs to be tested boundingBox = newTrackBbox; } diff --git a/editor/example/json-spec/give.ts b/editor/example/json-spec/give.ts index b1628d62f..ce4604feb 100644 --- a/editor/example/json-spec/give.ts +++ b/editor/example/json-spec/give.ts @@ -7,6 +7,7 @@ export const EX_SPEC_GIVE: GoslingSpec = { arrangement: 'vertical', views: [ { + xDomain: { chromosome: 'chr17', interval: [200000, 800000] }, layout: 'linear', tracks: [ { @@ -35,12 +36,7 @@ export const EX_SPEC_GIVE: GoslingSpec = { { type: 'filter', field: 'strand', oneOf: ['+'] } ], mark: 'rect', - x: { - field: 'end', - type: 'genomic', - domain: { chromosome: 'chr17', interval: [200000, 800000] }, - axis: 'top' - }, + x: { field: 'end', type: 'genomic', axis: 'top' }, size: { value: 7 } }, { @@ -137,7 +133,14 @@ export const EX_SPEC_GIVE: GoslingSpec = { tracks: [ { mark: 'rect', - dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen'], not: true }] + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen'], + not: true + } + ] }, { mark: 'triangleRight', @@ -154,11 +157,7 @@ export const EX_SPEC_GIVE: GoslingSpec = { ] } ], - x: { - field: 'chromStart', - type: 'genomic', - domain: { chromosome: 'chr17', interval: [20000000, 50000000] } - }, + x: { field: 'chromStart', type: 'genomic' }, xe: { field: 'chromEnd', type: 'genomic' }, color: { value: 'white' }, size: { value: 14 }, @@ -177,7 +176,6 @@ export const EX_SPEC_GIVE: GoslingSpec = { genomicFields: ['p1', 'p2'], separator: ' ', longToWideId: 'id' - //sampleLength: 5000 }, dataTransform: [{ type: 'filter', field: 'chr', oneOf: ['hs17'] }], mark: 'rect', @@ -223,6 +221,7 @@ export const EX_SPEC_GIVE: GoslingSpec = { }, { layout: 'linear', + xDomain: { chromosome: 'chr1', interval: [109000000, 112000000] }, tracks: [ { alignment: 'overlay', @@ -235,7 +234,14 @@ export const EX_SPEC_GIVE: GoslingSpec = { tracks: [ { mark: 'rect', - dataTransform: [{ type: 'filter', field: 'Stain', oneOf: ['acen'], not: true }] + dataTransform: [ + { + type: 'filter', + field: 'Stain', + oneOf: ['acen'], + not: true + } + ] }, { mark: 'triangleRight', @@ -252,11 +258,7 @@ export const EX_SPEC_GIVE: GoslingSpec = { ] } ], - x: { - field: 'chromStart', - type: 'genomic', - axis: 'none' - }, + x: { field: 'chromStart', type: 'genomic', axis: 'top' }, xe: { field: 'chromEnd', type: 'genomic' }, color: { value: 'white' }, size: { value: 14 }, @@ -275,11 +277,10 @@ export const EX_SPEC_GIVE: GoslingSpec = { genomicFields: ['p1', 'p2'], separator: ' ', longToWideId: 'id' - //sampleLength: 5000 }, dataTransform: [{ type: 'filter', field: 'chr_2', oneOf: ['hs1'] }], mark: 'rect', - x: { field: 'p1_2', type: 'genomic' }, + x: { field: 'p1_2', type: 'genomic', axis: 'top' }, xe: { field: 'p2_2', type: 'genomic' }, color: { field: 'chr', @@ -397,12 +398,7 @@ export const EX_SPEC_GIVE: GoslingSpec = { { type: 'filter', field: 'strand', oneOf: ['+'] } ], mark: 'rect', - x: { - field: 'end', - type: 'genomic', - domain: { chromosome: 'chr1', interval: [109000000, 112000000] }, - axis: 'bottom' - }, + x: { field: 'end', type: 'genomic', axis: 'bottom' }, size: { value: 7 } }, { From 50f11bb1103bd3ec6ddcfae79a9bff2fe5ce3195 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Tue, 2 Jul 2024 10:48:03 -0400 Subject: [PATCH 102/139] fix: json data fetcher --- demo/renderer/dataFetcher.ts | 6 +++++- src/data-fetchers/json/json-data-fetcher.ts | 10 ++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/demo/renderer/dataFetcher.ts b/demo/renderer/dataFetcher.ts index 0923a8e95..fd6281a0c 100644 --- a/demo/renderer/dataFetcher.ts +++ b/demo/renderer/dataFetcher.ts @@ -1,6 +1,6 @@ import { DataFetcher } from '@higlass/datafetcher'; import { fakePubSub } from '@higlass/utils'; -import { BigWigDataFetcher, CsvDataFetcher } from '@data-fetchers'; +import { BigWigDataFetcher, CsvDataFetcher, JsonDataFetcher } from '@data-fetchers'; export function getDataFetcher(spec: Track) { if (!('data' in spec)) { @@ -19,6 +19,10 @@ export function getDataFetcher(spec: Track) { const fields = getFields(spec); return new CsvDataFetcher({ ...spec.data, ...fields }); } + if (spec.data.type == 'json') { + const fields = getFields(spec); + return new JsonDataFetcher({ ...spec.data, ...fields, assembly: spec.assembly }); + } } /** diff --git a/src/data-fetchers/json/json-data-fetcher.ts b/src/data-fetchers/json/json-data-fetcher.ts index b803b918b..01e0fc653 100644 --- a/src/data-fetchers/json/json-data-fetcher.ts +++ b/src/data-fetchers/json/json-data-fetcher.ts @@ -3,25 +3,24 @@ import { sampleSize } from 'lodash-es'; import type { Assembly, JsonData } from '@gosling-lang/gosling-schema'; import { type CommonDataConfig, filterUsingGenoPos, sanitizeChrName } from '../utils'; -type CsvDataConfig = JsonData & CommonDataConfig; +type JsonDataConfig = JsonData & CommonDataConfig; /** * HiGlass data fetcher specific for Gosling which ultimately will accept any types of data other than JSON values. */ export class JsonDataFetcherClass { - private dataConfig: CsvDataConfig; + private dataConfig: JsonDataConfig; // @ts-ignore private tilesetInfoLoading: boolean; private chromSizes: any; private values: any; private assembly: Assembly; - constructor(params: any[]) { - const [dataConfig] = params; + constructor(params: JsonDataConfig) { + const dataConfig = params; this.dataConfig = dataConfig; this.tilesetInfoLoading = false; this.assembly = this.dataConfig.assembly; - if (!dataConfig.values) { console.error('Please provide `values` of the JSON data'); return; @@ -52,7 +51,6 @@ export class JsonDataFetcherClass { totalLength: prevEndPosition, chromLengths: chromosomeSizes }; - const { chromosomeField, genomicFields, genomicFieldsToConvert } = this.dataConfig; this.values = dataConfig.values.map((row: any) => { try { From 0d13bfa5ec7d2530abc00ece26c2a13e1beff150 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Tue, 2 Jul 2024 12:10:45 -0400 Subject: [PATCH 103/139] fix: single overlay --- demo/renderer/linkedEncoding.ts | 9 ++++++++- src/compiler/spec-preprocess.ts | 15 +++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/demo/renderer/linkedEncoding.ts b/demo/renderer/linkedEncoding.ts index 872371980..216b63d37 100644 --- a/demo/renderer/linkedEncoding.ts +++ b/demo/renderer/linkedEncoding.ts @@ -173,14 +173,16 @@ function getSingleViewTrackLinks(gs: SingleView): TrackLink[] { // Handle the y domain when we have a heatmap track if (trackType === TrackType.Heatmap) { const trackYDomain = getDomain(yDomain ?? xDomain, assembly); // default to the xDomain if no yDomain + const linkingId = track.y && 'linkingId' in track.y ? track.y.linkingId : undefined; const trackLink = { trackId: track.id, - linkingId: track.y.linkingId, // we may or may not have a linkingId + linkingId, trackType, encoding: 'y', signal: signal(trackYDomain) } as TrackLink; trackLinks.push(trackLink); + console.warn('pushed'); } // Handle x domain if (hasDiffXDomainThanView(track, assembly, viewXDomain)) { @@ -283,8 +285,13 @@ function hasDiffXDomainThanView(track: Track, assembly: Assembly | undefined, vi */ function getDomain(xDomain: GoslingSpec['xDomain'], assembly?: Assembly): [number, number] { let domain = [0, 0] as [number, number]; + const hasOnlyInterval = xDomain && 'interval' in xDomain && !('chromosome' in xDomain); if (!xDomain) { domain = [0, computeChromSizes(assembly).total]; + } else if (hasOnlyInterval) { + // If we are only given the interval, then we assume that the interval is already in absolute coordinates + const { interval } = xDomain; + domain = interval; } else { const position = createDomainString(xDomain); const manager = GenomicPositionHelper.fromString(position); diff --git a/src/compiler/spec-preprocess.ts b/src/compiler/spec-preprocess.ts index 8cc541b0b..e37432b98 100644 --- a/src/compiler/spec-preprocess.ts +++ b/src/compiler/spec-preprocess.ts @@ -121,12 +121,19 @@ export function convertToFlatTracks(spec: SingleView): Track[] { } }); } else { - newTracks.push({ + const overlays = [...spec.tracks.filter(track => !track._invalidTrack)]; + let newTrack = { ...spec, - _overlay: [...spec.tracks.filter(track => !track._invalidTrack)], tracks: undefined, - alignment: undefined - } as Track); + alignment: undefined, + } as any; + if (overlays.length === 1) { + // If there is only a single overlay, we just merge it with the track. + newTrack = { ...newTrack, ...overlays[0] }; + } else { + newTrack._overlay = overlays; + } + newTracks.push(newTrack as Track); } return JSON.parse(JSON.stringify(newTracks)); From bb86b7d47571de53f023279a1e52b99c0e683bf4 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Tue, 2 Jul 2024 12:12:11 -0400 Subject: [PATCH 104/139] fix: no show heatmap label --- demo/renderer/heatmap.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/renderer/heatmap.ts b/demo/renderer/heatmap.ts index b62747b8d..b60522e8f 100644 --- a/demo/renderer/heatmap.ts +++ b/demo/renderer/heatmap.ts @@ -43,7 +43,7 @@ function getHeatmapOptions(spec: Track, theme: Required): Hea showMousePosition: false, mousePositionColor: '#000000', name: spec.title, - labelPosition: 'topLeft', + labelPosition: 'none', labelShowResolution: false, labelColor: 'black', labelBackgroundColor: 'white', From 5875e2b27f84a72b90a5aecd879368aba5b1a412 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Tue, 2 Jul 2024 16:33:48 -0400 Subject: [PATCH 105/139] fix: move effect to tracks --- src/interactors/panZoom.ts | 6 ------ src/tracks/brush-circular/brush-circular-plot.ts | 5 +++++ src/tracks/brush-linear/brush-linear-plot.ts | 5 +++++ src/tracks/genomic-axis/axis-track-plot.ts | 7 +++++-- src/tracks/gosling-track/gosling-track-plot.ts | 8 +++++++- 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/interactors/panZoom.ts b/src/interactors/panZoom.ts index 32f0b2a32..28afc3e70 100644 --- a/src/interactors/panZoom.ts +++ b/src/interactors/panZoom.ts @@ -46,10 +46,4 @@ export function panZoom(plot: Plot, xDomain: Signal<[number, number]>) { // Apply the zoom behavior to the overlay div select(plot.domOverlay).call(zoomBehavior); - - // Every time the domain gets changed we want to update the zoom - effect(() => { - const newScale = baseScale.domain(plot.xDomain.value); - plot.zoomed(newScale, scaleLinear()); - }); } diff --git a/src/tracks/brush-circular/brush-circular-plot.ts b/src/tracks/brush-circular/brush-circular-plot.ts index 64224591a..ff7e176ed 100644 --- a/src/tracks/brush-circular/brush-circular-plot.ts +++ b/src/tracks/brush-circular/brush-circular-plot.ts @@ -65,6 +65,11 @@ export class BrushCircularTrack extends CircularBrushTrackClass { const newXDomain = scaleLinear().domain(this.xBrushDomain.value); this.viewportChanged(newXDomain, scaleLinear()); }); + // Every time the domain gets changed we want to update the zoom + effect(() => { + const newScale = this._refXScale.domain(this.xDomain.value); + this.zoomed(newScale, scaleLinear()); + }); } addInteractor(interactor: (plot: BrushCircularTrack) => void) { diff --git a/src/tracks/brush-linear/brush-linear-plot.ts b/src/tracks/brush-linear/brush-linear-plot.ts index 1a7ff8ec4..3781349ae 100644 --- a/src/tracks/brush-linear/brush-linear-plot.ts +++ b/src/tracks/brush-linear/brush-linear-plot.ts @@ -63,6 +63,11 @@ export class BrushLinearTrack extends BrushLinearTrackClass { + const newScale = this._refXScale.domain(this.xDomain.value); + this.zoomed(newScale, scaleLinear()); + }); } addInteractor(interactor: (plot: BrushLinearTrack) => void) { diff --git a/src/tracks/genomic-axis/axis-track-plot.ts b/src/tracks/genomic-axis/axis-track-plot.ts index 6960df5ca..4e3764906 100644 --- a/src/tracks/genomic-axis/axis-track-plot.ts +++ b/src/tracks/genomic-axis/axis-track-plot.ts @@ -89,8 +89,11 @@ export class AxisTrack extends AxisTrackClass { this.zoomed(refXScale, refYScale); this.refScalesChanged(refXScale, refYScale); - // Add the zoom - // this.#addZoom(); + // Every time the domain gets changed we want to update the zoom + effect(() => { + const newScale = this._refXScale.domain(this.xDomain.value); + this.zoomed(newScale, scaleLinear()); + }); } addInteractor(interactor: (plot: AxisTrack) => void) { diff --git a/src/tracks/gosling-track/gosling-track-plot.ts b/src/tracks/gosling-track/gosling-track-plot.ts index 78230875b..46d947e1e 100644 --- a/src/tracks/gosling-track/gosling-track-plot.ts +++ b/src/tracks/gosling-track/gosling-track-plot.ts @@ -6,7 +6,7 @@ import { type Signal } from '@preact/signals-core'; import { DataFetcher } from '@higlass/datafetcher'; import { type Plot } from '../utils'; -import { signal } from '@preact/signals-core'; +import { signal, effect } from '@preact/signals-core'; export class GoslingTrack extends GoslingTrackClass implements Plot { xDomain: Signal<[number, number]>; // This has to be a signal because it will potentially be updated by interactors @@ -86,6 +86,12 @@ export class GoslingTrack extends GoslingTrackClass implements Plot { // Set the scales this.zoomed(refXScale, refYScale); this.refScalesChanged(refXScale, refYScale); + + // Every time the domain gets changed we want to update the zoom + effect(() => { + const newScale = this._refXScale.domain(this.xDomain.value); + this.zoomed(newScale, scaleLinear()); + }); } addInteractor(interactor: (plot: GoslingTrack) => void) { From f7f9747c63cedbe8c0f119d4778f2193abfbf6e6 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Tue, 2 Jul 2024 16:34:33 -0400 Subject: [PATCH 106/139] only put interactor if not overlaid on previous --- demo/renderer/main.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index 6dc5b944c..5eb9db3ba 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -128,7 +128,9 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE domain, options.spec.orientation ); - if (!options.spec.static) { + const isOverlayedOnPrevious = + 'overlayOnPreviousTrack' in options.spec && options.spec.overlayOnPreviousTrack; + if (!options.spec.static && !isOverlayedOnPrevious) { gosPlot.addInteractor(plot => panZoom(plot, domain)); } } @@ -149,7 +151,12 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE return; } - const axisTrack = new AxisTrack(options, domain, pixiManager.makeContainer(boundingBox), options.orientation); + const axisTrack = new AxisTrack( + options, + domain, + pixiManager.makeContainer(boundingBox), + options.orientation + ); if (!options.static) { axisTrack.addInteractor(plot => panZoom(plot, domain)); } From 6bdc6f9182edd66d5eb0128406254ea5f3703fdc Mon Sep 17 00:00:00 2001 From: etowahadams Date: Tue, 2 Jul 2024 16:34:59 -0400 Subject: [PATCH 107/139] fix: bounding box calculation --- src/compiler/bounding-box.ts | 37 +++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/src/compiler/bounding-box.ts b/src/compiler/bounding-box.ts index 604161e7f..610935b0c 100644 --- a/src/compiler/bounding-box.ts +++ b/src/compiler/bounding-box.ts @@ -216,26 +216,26 @@ function traverseAndCollectTrackInfo( cumWidth = Math.max(...tracks.map(d => d.width)); //forceWidth ? forceWidth : spec.tracks[0]?.width; tracks.forEach((track, i, array) => { // let scaledHeight = track.height; - if (getNumOfXAxes([track]) === 1) { track.height += HIGLASS_AXIS_SIZE; } + const boundingBox = { + x: dx, + y: dy + cumHeight, + width: cumWidth, + height: track.height + }; const singleTrack = resolveSuperposedTracks(track); if (singleTrack.length > 0 && Is2DTrack(singleTrack[0]) && getNumOfYAxes([track]) === 1) { // If this is a 2D track (e.g., matrix), we need to reserve a space for the y-axis track - cumWidth += HIGLASS_AXIS_SIZE; + boundingBox.width += HIGLASS_AXIS_SIZE; } - track.width = cumWidth; + track.width = boundingBox.width; output.push({ track, - boundingBox: { - x: dx, - y: dy + cumHeight, - width: cumWidth, - height: track.height - }, + boundingBox, layout: { x: 0, y: 0, w: 0, h: 0 } // Just put a dummy info here, this should be added after entire bounding box has been determined }); @@ -248,6 +248,7 @@ function traverseAndCollectTrackInfo( } } }); + adjustOverlaidTrackPosition(output); } } else { // We did not reach a track definition, so continue traversing. @@ -373,6 +374,24 @@ function traverseAndCollectTrackInfo( return { x: dx, y: dy, width: cumWidth, height: cumHeight }; } +/** + * Adjusts the x and y position of the overlaid tracks + * Problem: Some overlaid tracks have an axis. Some do not. If an overlaid track does not have an axis + * then the (x, y) position of the bounding box is possibly incorrect. + */ +function adjustOverlaidTrackPosition(output) { + const overlaidTracks = output.filter(t => t.track.overlayOnPreviousTrack); + const hasOverlaidTracks = overlaidTracks.length > 0; + if (!hasOverlaidTracks) return output; + console.warn('overlaidTracks', overlaidTracks); + const baseTrack = output.filter(t => !t.track.overlayOnPreviousTrack)[0]; + // overlaidTracks[0].boundingBox.x += 30; + // overlaidTracks[0].boundingBox.y += 30; + // if (baseTrack.boundingBox.width > overlaidTracks[0].boundingBox.width) { + // overlaidTracks[0].boundingBox.x += 30; + // } +} + export function getNumOfXAxes(tracks: Track[]): number { return tracks.filter(t => IsXAxis(t)).length; } From 2874b2093eb0866fb715fb5ad27d64e45e2d88c7 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 3 Jul 2024 12:51:15 -0400 Subject: [PATCH 108/139] feat: add basic y domain --- src/interactors/panZoom.ts | 29 +++++++++++++------ .../gosling-track/gosling-track-plot.ts | 18 +++++++----- src/tracks/utils.ts | 1 + 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/interactors/panZoom.ts b/src/interactors/panZoom.ts index 28afc3e70..43aae153d 100644 --- a/src/interactors/panZoom.ts +++ b/src/interactors/panZoom.ts @@ -1,4 +1,4 @@ -import { type Signal, effect } from '@preact/signals-core'; +import { type Signal, batch, signal } from '@preact/signals-core'; import { scaleLinear } from 'd3-scale'; import { ZoomTransform, type D3ZoomEvent, zoom } from 'd3-zoom'; import { select } from 'd3-selection'; @@ -8,20 +8,30 @@ import { zoomWheelBehavior, type Plot } from '../tracks/utils'; * This interactor allows the user to pan and zoom the plot */ -export function panZoom(plot: Plot, xDomain: Signal<[number, number]>) { +export function panZoom(plot: Plot, xDomain: Signal<[number, number]>, yDomain?: Signal<[number, number]>) { plot.xDomain = xDomain; // Update the xDomain with the signal - const baseScale = scaleLinear().range([0, plot.width]); + if (plot.yDomain) yDomain = plot.yDomain; + // This will store the xDomain when the user starts zooming - const zoomStartScale = scaleLinear(); + const zoomStartScaleX = scaleLinear(); + const zoomStartScaleY = scaleLinear(); // This function will be called every time the user zooms const zoomed = (event: D3ZoomEvent) => { if (plot.orientation === undefined || plot.orientation === 'horizontal') { - const newXDomain = event.transform.rescaleX(zoomStartScale).domain(); - xDomain.value = newXDomain as [number, number]; + const newXDomain = event.transform.rescaleX(zoomStartScaleX).domain(); + const newYDomain = event.transform.rescaleY(zoomStartScaleY).domain(); + batch(() => { + xDomain.value = newXDomain as [number, number]; + if (yDomain) yDomain.value = newYDomain as [number, number]; + }); } if (plot.orientation === 'vertical') { - const newXDomain = event.transform.rescaleY(zoomStartScale).domain(); - xDomain.value = newXDomain as [number, number]; + const newXDomain = event.transform.rescaleY(zoomStartScaleX).domain(); + const newYDomain = event.transform.rescaleX(zoomStartScaleY).domain(); + batch(() => { + xDomain.value = newXDomain as [number, number]; + if (yDomain) yDomain.value = newYDomain as [number, number]; + }); } }; // Create the zoom behavior @@ -40,7 +50,8 @@ export function panZoom(plot: Plot, xDomain: Signal<[number, number]>) { // @ts-expect-error We need to reset the transform when the user stops zooming .on('end', () => (plot.domOverlay.__zoom = new ZoomTransform(1, 0, 0))) .on('start', () => { - zoomStartScale.domain(xDomain.value).range([0, plot.width]); + zoomStartScaleX.domain(xDomain.value).range([0, plot.width]); + if (yDomain) zoomStartScaleY.domain(yDomain.value).range([0, plot.height]); }) .on('zoom', zoomed); diff --git a/src/tracks/gosling-track/gosling-track-plot.ts b/src/tracks/gosling-track/gosling-track-plot.ts index 46d947e1e..478ca5d81 100644 --- a/src/tracks/gosling-track/gosling-track-plot.ts +++ b/src/tracks/gosling-track/gosling-track-plot.ts @@ -9,9 +9,10 @@ import { type Plot } from '../utils'; import { signal, effect } from '@preact/signals-core'; export class GoslingTrack extends GoslingTrackClass implements Plot { - xDomain: Signal<[number, number]>; // This has to be a signal because it will potentially be updated by interactors + xDomain: Signal<[number, number]>; // Stores the genomic x-domain + yDomain: Signal<[number, number]>; // Stores the genomic y-domain zoomStartScale = scaleLinear(); - domOverlay: HTMLElement; + domOverlay: HTMLElement; // This is the HTML element that covers the plot. Zoom behavior gets attached to this width: number; height: number; orientation: 'horizontal' | 'vertical'; @@ -29,6 +30,7 @@ export class GoslingTrack extends GoslingTrackClass implements Plot { const { pixiContainer, overlayDiv } = containers; // If there is already an svg element, use it. Otherwise, create a new one + // If we do not reuse the same SVG element, we cannot have multiple brushes on the same track. const existingSvgElement = overlayDiv.querySelector('svg'); const svgElement = existingSvgElement || document.createElementNS('http://www.w3.org/2000/svg', 'svg'); if (!existingSvgElement) { @@ -76,21 +78,23 @@ export class GoslingTrack extends GoslingTrackClass implements Plot { } this.xDomain = xDomain; + this.yDomain = signal<[number, number]>(xDomain.value); this.domOverlay = overlayDiv; // Now we need to initialize all of the properties that would normally be set by HiGlassComponent this.setDimensions([this.width, this.height]); this.setPosition([0, 0]); - // Create some scales which span the whole genome - const refXScale = scaleLinear().domain(xDomain.value).range([0, this.width]); - const refYScale = scaleLinear(); // This doesn't get used anywhere but we need to pass it in + // Create some scales where the range is the height/width of the plot + const refXScale = scaleLinear().domain(this.xDomain.value).range([0, this.width]); + const refYScale = scaleLinear().domain(this.yDomain.value).range([0, this.height]); // Set the scales this.zoomed(refXScale, refYScale); this.refScalesChanged(refXScale, refYScale); // Every time the domain gets changed we want to update the zoom effect(() => { - const newScale = this._refXScale.domain(this.xDomain.value); - this.zoomed(newScale, scaleLinear()); + const newScaleX = this._refXScale.domain(this.xDomain.value); + const newScaleY = this._refYScale.domain(this.yDomain.value); + this.zoomed(newScaleX, newScaleY); }); } diff --git a/src/tracks/utils.ts b/src/tracks/utils.ts index b2e3af420..b3fd8659f 100644 --- a/src/tracks/utils.ts +++ b/src/tracks/utils.ts @@ -22,6 +22,7 @@ export interface Plot { width: number; height: number; xDomain: Signal<[number, number]>; + yDomain?: Signal<[number, number]>; zoomed(xScale: ScaleLinear, yScale: ScaleLinear): void; } From 25f17b2ce4979225a9182bbbb90db4178cdfcd08 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 3 Jul 2024 15:06:51 -0400 Subject: [PATCH 109/139] feat: add test --- demo/renderer/linkedEncoding.test.ts | 65 ++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/demo/renderer/linkedEncoding.test.ts b/demo/renderer/linkedEncoding.test.ts index eafe613ae..1d157bcd1 100644 --- a/demo/renderer/linkedEncoding.test.ts +++ b/demo/renderer/linkedEncoding.test.ts @@ -349,9 +349,6 @@ describe('Heatmap', () => { xe: { field: 'xe', type: 'genomic', axis: 'none' }, y: { field: 'ys', type: 'genomic', axis: 'none' }, ye: { field: 'ye', type: 'genomic', axis: 'none' }, - color: { field: 'value', type: 'quantitative', range: 'warm' }, - width: 600, - height: 600 } ] }; @@ -389,4 +386,66 @@ describe('Heatmap', () => { ] `); }); + it('multiple y linking', () => { + const matrix = { + xDomain: { chromosome: 'chr7', interval: [77700000, 81000000] }, + tracks: [ + { + id: 'matrix-1', + mark: 'bar', + x: { field: 'xs', type: 'genomic', axis: 'none' }, + xe: { field: 'xe', type: 'genomic', axis: 'none' }, + y: { field: 'ys', type: 'genomic', axis: 'none' }, + ye: { field: 'ye', type: 'genomic', axis: 'none' }, + }, + { + id: 'matrix-2', + mark: 'bar', + x: { field: 'xs', type: 'genomic', axis: 'none' }, + y: { field: 'ys', type: 'genomic', axis: 'none' }, + } + ] + }; + + const result = getLinkedEncodings(matrix); + + expect(result).toMatchInlineSnapshot(` + [ + { + "linkingId": undefined, + "signal": [ + 1309704303, + 1313004303, + ], + "tracks": [ + { + "encoding": "x", + "id": "matrix-1", + }, + { + "encoding": "x", + "id": "matrix-2", + }, + ], + }, + { + "linkingId": undefined, + "signal": [ + 1309704303, + 1313004303, + ], + "tracks": [ + { + "encoding": "y", + "id": "matrix-1", + }, + { + "encoding": "y", + "id": "matrix-2", + }, + ], + }, + ] + `); + }); }); From d39eaf6c28658dd03f50cff68e6474eb408e0bba Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 3 Jul 2024 15:07:13 -0400 Subject: [PATCH 110/139] fix panZoom yDomain --- src/interactors/panZoom.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interactors/panZoom.ts b/src/interactors/panZoom.ts index 43aae153d..f568ad3ce 100644 --- a/src/interactors/panZoom.ts +++ b/src/interactors/panZoom.ts @@ -10,7 +10,7 @@ import { zoomWheelBehavior, type Plot } from '../tracks/utils'; export function panZoom(plot: Plot, xDomain: Signal<[number, number]>, yDomain?: Signal<[number, number]>) { plot.xDomain = xDomain; // Update the xDomain with the signal - if (plot.yDomain) yDomain = plot.yDomain; + if ('yDomain' in plot && yDomain !== undefined) plot.yDomain = yDomain; // This will store the xDomain when the user starts zooming const zoomStartScaleX = scaleLinear(); From 7d9828a05245572517b7e042c21b5cd90b0f50c4 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 3 Jul 2024 15:08:06 -0400 Subject: [PATCH 111/139] feat: yDomain for gosling plot --- demo/renderer/linkedEncoding.ts | 144 +++++++++++------- demo/renderer/main.ts | 19 ++- .../gosling-track/gosling-track-plot.ts | 3 +- 3 files changed, 101 insertions(+), 65 deletions(-) diff --git a/demo/renderer/linkedEncoding.ts b/demo/renderer/linkedEncoding.ts index 216b63d37..40e122279 100644 --- a/demo/renderer/linkedEncoding.ts +++ b/demo/renderer/linkedEncoding.ts @@ -23,7 +23,7 @@ export interface LinkedEncoding { */ interface ViewLink { linkingId?: string; - encoding: 'x'; + encoding: 'x' | 'y'; trackIds: string[]; signal: Signal; } @@ -62,7 +62,7 @@ export function getLinkedEncodings(gs: GoslingSpec) { id: track.trackId, encoding: track.encoding })); - const viewTracks = viewLink.trackIds.map(trackId => ({ id: trackId, encoding: 'x' })); + const viewTracks = viewLink.trackIds.map(trackId => ({ id: trackId, encoding: viewLink.encoding })); return { linkingId: viewLink.linkingId, signal: viewLink.signal, @@ -130,7 +130,7 @@ function getLinkedFeaturesRecursive(gs: GoslingSpec): LinkInfo { if (IsSingleView(gs)) { const viewLinks = getSingleViewLinks(gs); const trackLinks = getSingleViewTrackLinks(gs); - return { viewLinks: [viewLinks], trackLinks }; + return { viewLinks: viewLinks, trackLinks }; } const linked: LinkInfo = { viewLinks: [], trackLinks: [] }; // Recursive case: multiple views @@ -149,7 +149,7 @@ function getLinkedFeaturesRecursive(gs: GoslingSpec): LinkInfo { */ function getSingleViewTrackLinks(gs: SingleView): TrackLink[] { // Helper function to create a track link for the x encoding - function createXTrackLink(trackId: string, track: Track, trackType: TrackType, gs: SingleView) { + function createTrackLinkX(trackId: string, track: Track, trackType: TrackType, gs: SingleView) { const trackLink = { trackId: trackId, linkingId: track.x.linkingId, @@ -164,25 +164,31 @@ function getSingleViewTrackLinks(gs: SingleView): TrackLink[] { } return trackLink; } + function createTrackLinkY(trackId: string, track: Track, trackType: TrackType, gs: SingleView) { + const { assembly } = gs; + const trackLink = { + trackId: trackId, + linkingId: track.y.linkingId, + trackType, + encoding: 'y' + } as TrackLink; + // If the track has a domain, we create a signal and add it to the trackLink + if (track.y.domain !== undefined) { + const domain = getDomain(track.y.domain, assembly); + trackLink.signal = signal(domain); + } + return trackLink; + } const { assembly, xDomain, yDomain, tracks } = gs; const viewXDomain = getDomain(xDomain, assembly); const trackLinks: TrackLink[] = []; tracks.forEach(track => { const trackType = isHeatmapTrack(track) ? TrackType.Heatmap : TrackType.Gosling; - // Handle the y domain when we have a heatmap track - if (trackType === TrackType.Heatmap) { - const trackYDomain = getDomain(yDomain ?? xDomain, assembly); // default to the xDomain if no yDomain - const linkingId = track.y && 'linkingId' in track.y ? track.y.linkingId : undefined; - const trackLink = { - trackId: track.id, - linkingId, - trackType, - encoding: 'y', - signal: signal(trackYDomain) - } as TrackLink; + // Handle the y domain + if (isGenomicEncoding(track, 'y') && hasDiffYDomainThanView(track)) { + const trackLink = createTrackLinkY(track.id, track, trackType, gs); trackLinks.push(trackLink); - console.warn('pushed'); } // Handle x domain if (hasDiffXDomainThanView(track, assembly, viewXDomain)) { @@ -190,19 +196,12 @@ function getSingleViewTrackLinks(gs: SingleView): TrackLink[] { console.warn('Track with brush mark should only be used as an overlay'); return; } - const trackLink = createXTrackLink(track.id, track, trackType, gs); + const trackLink = createTrackLinkX(track.id, track, trackType, gs); trackLinks.push(trackLink); } // Handle linking in the brushes which are defined in the overlay tracks if (!('_overlay' in track)) return; - // // Handle special case where we have a single overlay track that is not a brush - // if (track._overlay.length === 1 && track._overlay[0].mark !== 'brush') { - // const firstOverlay = track._overlay[0]; - // const trackLink = createXTrackLink(track.id, firstOverlay, trackType, gs); - // trackLinks.push(trackLink); - // return; - // } // Handle case where we have multiple overlay tracks (we only care about the brushes) track._overlay!.forEach(overlay => { if (overlay.mark === 'brush') { @@ -228,41 +227,61 @@ function getSingleViewTrackLinks(gs: SingleView): TrackLink[] { /** * Links all of the tracks in a single view together */ -function getSingleViewLinks(gs: SingleView): ViewLink { - const { tracks, xDomain, assembly } = gs; +function getSingleViewLinks(gs: SingleView): ViewLink[] { + function addLinkY(tracks: Track[], viewYDomain: [number, number]): ViewLink { + const viewLinkY: ViewLink = { + linkingId: undefined, + encoding: 'y', + signal: signal(viewYDomain), + trackIds: [] + }; + // Add each track to the link + tracks.forEach(track => { + // Edge case: The first track in a view with "alignment": "overlay" can + // sometimes not have a y encoding but it has a single overlay track which contains the y encoding + const missingX = !('x' in track) || track.x === undefined; + const missingY = !('y' in track) || track.y === undefined; + const hasOverlay = '_overlay' in track && track._overlay.length == 1; + if (missingX && missingY && hasOverlay) track = { ...track, y: track._overlay[0].y }; + // Continue as usual + if (isGenomicEncoding(track, 'y') && !hasDiffYDomainThanView(track)) { + viewLinkY.trackIds.push(track.id); + } + }); + return viewLinkY; + } + function addLinkX(tracks: Track[], assembly: Assembly | undefined, viewXDomain: [number, number]): ViewLink { + const viewLinkX: ViewLink = { + linkingId: gs.linkingId, + encoding: 'x', + signal: signal(viewXDomain), + trackIds: [] + }; + // Add each track to the link + tracks.forEach(track => { + // If the track is already linked to something else, we don't need to add it again + if (hasDiffXDomainThanView(track, assembly, viewXDomain)) return; + const hasOverlaidTracks = '_overlay' in track; + // Add overlaid brush tracks to the link + if (hasOverlaidTracks) { + track._overlay?.forEach(overlay => { + if (overlay.mark === 'brush') { + viewLinkX.trackIds.push(overlay.id); + } + }); + } + viewLinkX.trackIds.push(track.id); + }); + return viewLinkX; + } + const { tracks, xDomain, yDomain, assembly } = gs; const viewXDomain = getDomain(xDomain, assembly); + const viewYDomain = getDomain(yDomain ?? xDomain, assembly); - const newLink: ViewLink = { - linkingId: gs.linkingId, - encoding: 'x', - signal: signal(viewXDomain), - trackIds: [] - }; - console.warn('single view', gs); - // Add each track to the link - tracks.forEach(track => { - // If the track is already linked to something else, we don't need to add it again - console.warn(hasDiffXDomainThanView(track, assembly, viewXDomain)); - if (hasDiffXDomainThanView(track, assembly, viewXDomain)) return; - - // Some tracks don't have any encodings but they do have overlaid tracks - // const hasXEncoding = 'x' in track; - const hasOverlaidTracks = '_overlay' in track; - // if (!hasXEncoding && hasOverlaidTracks) { - // newLink.trackIds.push(track.id); - // } - // Add overlaid brush tracks to the link - if (hasOverlaidTracks) { - track._overlay?.forEach(overlay => { - if (overlay.mark === 'brush') { - newLink.trackIds.push(overlay.id); - } - }); - } - - newLink.trackIds.push(track.id); - }); - return newLink; + const xLink = addLinkX(tracks, assembly, viewXDomain); + const yLink = addLinkY(tracks, viewYDomain); + console.warn('ylink', yLink); + return [xLink, yLink].filter(link => link.trackIds.length > 0); } function hasDiffXDomainThanView(track: Track, assembly: Assembly | undefined, viewXDomain: [number, number]) { @@ -280,6 +299,17 @@ function hasDiffXDomainThanView(track: Track, assembly: Assembly | undefined, vi return false; } +function isGenomicEncoding(track: Track, encoding: 'x' | 'y') { + const isGenomic = + encoding in track && track[encoding] && 'type' in track[encoding] && track[encoding].type === 'genomic'; + return isGenomic; +} + +function hasDiffYDomainThanView(track: Track) { + const hasLinkingId = 'y' in track && track.y && 'linkingId' in track.y && track.y?.linkingId !== undefined; + return hasLinkingId; +} + /** * For a given xDomain and Assembly, return the the absolute domain [start, end] */ diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index 5eb9db3ba..e0bb676ba 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -117,21 +117,23 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE new TextTrack(options, pixiManager.makeContainer(boundingBox)); } if (type === TrackType.Gosling) { - const domain = getEncodingSignal(trackDef.trackId, 'x', linkedEncodings); - if (!domain) return; + const xDomain = getEncodingSignal(trackDef.trackId, 'x', linkedEncodings); + const yDomain = getEncodingSignal(trackDef.trackId, 'y', linkedEncodings); + if (!xDomain) return; const datafetcher = getDataFetcher(options.spec); const gosPlot = new GoslingTrack( options, datafetcher, pixiManager.makeContainer(boundingBox), - domain, + xDomain, + yDomain, options.spec.orientation ); const isOverlayedOnPrevious = 'overlayOnPreviousTrack' in options.spec && options.spec.overlayOnPreviousTrack; if (!options.spec.static && !isOverlayedOnPrevious) { - gosPlot.addInteractor(plot => panZoom(plot, domain)); + gosPlot.addInteractor(plot => panZoom(plot, xDomain, yDomain)); } } if (type === TrackType.Heatmap) { @@ -166,9 +168,13 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE const brushDomain = getEncodingSignal(trackDef.trackId, 'brush', linkedEncodings); if (!domain || !brushDomain || !hasLinkedTracks(trackDef.trackId, linkedEncodings)) return; // We only want to add the brush track if it is linked to another track - new BrushLinearTrack(options, brushDomain, pixiManager.makeContainer(boundingBox).overlayDiv).addInteractor( - plot => panZoom(plot, domain) + const brush = new BrushLinearTrack( + options, + brushDomain, + pixiManager.makeContainer(boundingBox).overlayDiv, + domain ); + if (!options.static) brush.addInteractor(plot => panZoom(plot, domain)); } if (type === TrackType.BrushCircular) { const domain = getEncodingSignal(trackDef.trackId, 'x', linkedEncodings); @@ -210,7 +216,6 @@ function getEncodingSignal( link.tracks.find(t => t.id === trackDefId && t.encoding === encodingType) ); if (!linkedEncoding) { - console.warn(`No linked encoding "${encodingType}" found for track ${trackDefId}`); return undefined; } if (!linkedEncoding.signal) { diff --git a/src/tracks/gosling-track/gosling-track-plot.ts b/src/tracks/gosling-track/gosling-track-plot.ts index 478ca5d81..e68e2aa50 100644 --- a/src/tracks/gosling-track/gosling-track-plot.ts +++ b/src/tracks/gosling-track/gosling-track-plot.ts @@ -25,6 +25,7 @@ export class GoslingTrack extends GoslingTrackClass implements Plot { overlayDiv: HTMLElement; }, xDomain = signal<[number, number]>([0, 3088269832]), + yDomain?: Signal<[number, number]>, orientation: 'horizontal' | 'vertical' = 'horizontal' ) { const { pixiContainer, overlayDiv } = containers; @@ -78,7 +79,7 @@ export class GoslingTrack extends GoslingTrackClass implements Plot { } this.xDomain = xDomain; - this.yDomain = signal<[number, number]>(xDomain.value); + this.yDomain = yDomain ?? signal<[number, number]>(xDomain.value); this.domOverlay = overlayDiv; // Now we need to initialize all of the properties that would normally be set by HiGlassComponent this.setDimensions([this.width, this.height]); From dd14c20595b27c771277003445e0983f37bfb0fc Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 3 Jul 2024 15:45:00 -0400 Subject: [PATCH 112/139] update matrix example --- editor/example/json-spec/matrix.ts | 2444 +++++++++++++++++++++++++++- 1 file changed, 2432 insertions(+), 12 deletions(-) diff --git a/editor/example/json-spec/matrix.ts b/editor/example/json-spec/matrix.ts index bd261adbd..fe57c20bd 100644 --- a/editor/example/json-spec/matrix.ts +++ b/editor/example/json-spec/matrix.ts @@ -53,7 +53,8 @@ export const EX_SPEC_MATRIX: GoslingSpec = { genomicFields: ['p'] }, mark: 'rule', - x: { field: 'p', type: 'genomic', axis: 'none' }, + x: { field: 'p', type: 'genomic', axis: 'top' }, + y: { type: 'genomic', axis: 'left' }, strokeWidth: { value: 2 }, color: { value: 'red' } }, @@ -69,7 +70,8 @@ export const EX_SPEC_MATRIX: GoslingSpec = { genomicFields: ['p'] }, mark: 'rule', - y: { field: 'p', type: 'genomic' }, + y: { field: 'p', type: 'genomic', axis: 'left' }, + x: { type: 'genomic', axis: 'top', field: 'p' }, strokeWidth: { value: 2 }, color: { value: 'blue' } }, @@ -77,17 +79,38 @@ export const EX_SPEC_MATRIX: GoslingSpec = { data: { type: 'json', values: [ - { c: 'chr6', x: 0, xe: 170805979, y: 0, ye: 170805979, v: 1 }, - { c: 'chr7', x: 0, xe: 159345973, y: 0, ye: 159345973, v: 100 }, - { c: 'chr8', x: 0, xe: 145138636, y: 0, ye: 145138636, v: 21 } + { + c: 'chr6', + x: 0, + xe: 170805979, + y: 0, + ye: 170805979, + v: 1 + }, + { + c: 'chr7', + x: 0, + xe: 159345973, + y: 0, + ye: 159345973, + v: 100 + }, + { + c: 'chr8', + x: 0, + xe: 145138636, + y: 0, + ye: 145138636, + v: 21 + } ], chromosomeField: 'c', genomicFields: ['x', 'xe', 'y', 'ye'] }, mark: 'bar', - x: { field: 'x', type: 'genomic' }, + x: { field: 'x', type: 'genomic', axis: 'top' }, xe: { field: 'xe', type: 'genomic' }, - y: { field: 'y', type: 'genomic' }, + y: { field: 'y', type: 'genomic', axis: 'left' }, ye: { field: 'ye', type: 'genomic' }, stroke: { field: 'c', type: 'nominal' }, strokeWidth: { value: 4 }, @@ -98,17 +121,2414 @@ export const EX_SPEC_MATRIX: GoslingSpec = { data: { type: 'json', values: [ - ...generateToyJson(100, 'chr6', 170805979), - ...generateToyJson(100, 'chr7', 159345973), - ...generateToyJson(100, 'chr8', 145138636) + { + c: 'chr6', + x: 94806654.20073074, + xe: 81079.09212207455, + y: 85828395.38649301, + ye: 93089269.73887427, + v: 0.5350895484328914 + }, + { + c: 'chr6', + x: 164705696.65272287, + xe: 143895772.40479854, + y: 57794470.29766761, + ye: 146622394.77564675, + v: 0.3816316990183064 + }, + { + c: 'chr6', + x: 94364006.96450834, + xe: 81083752.5213105, + y: 101540624.07948898, + ye: 25220201.99027065, + v: 0.801391364386786 + }, + { + c: 'chr6', + x: 143771825.04478562, + xe: 8822810.165605793, + y: 45702250.02854258, + ye: 28545137.56986844, + v: 0.7576094376150819 + }, + { + c: 'chr6', + x: 145456136.9816973, + xe: 132196664.10394478, + y: 167096653.2071868, + ye: 70572350.61368516, + v: 0.6066276359013565 + }, + { + c: 'chr6', + x: 29513267.69570543, + xe: 846966.3396755967, + y: 148559656.35602567, + ye: 169961924.75237092, + v: 0.4634109146287343 + }, + { + c: 'chr6', + x: 102932020.07800165, + xe: 109205369.02734572, + y: 124445873.47440934, + ye: 127922137.71812366, + v: 0.8713038255951461 + }, + { + c: 'chr6', + x: 127968487.78541622, + xe: 22473599.530795585, + y: 28871496.514878366, + ye: 22988034.254220255, + v: 0.07413154824637735 + }, + { + c: 'chr6', + x: 18927935.11756696, + xe: 92641074.81157994, + y: 63016782.785938926, + ye: 6222781.752014111, + v: 0.06626292910287745 + }, + { + c: 'chr6', + x: 55395662.0333149, + xe: 169335213.6283563, + y: 145820229.21188217, + ye: 33167408.391689833, + v: 0.16709137218822867 + }, + { + c: 'chr6', + x: 87873273.64815529, + xe: 60902544.91509318, + y: 38505879.795740046, + ye: 33862322.67137321, + v: 0.2701944717291175 + }, + { + c: 'chr6', + x: 75439634.48828693, + xe: 115104774.3164396, + y: 140147526.3846991, + ye: 104573834.81329511, + v: 0.9792755894723416 + }, + { + c: 'chr6', + x: 89433058.53504884, + xe: 100171350.9597928, + y: 55899821.58681252, + ye: 33556240.47739128, + v: 0.8079168884080596 + }, + { + c: 'chr6', + x: 67708532.07718189, + xe: 124055845.35633408, + y: 86686383.68659101, + ye: 29636397.796711065, + v: 0.7225362174020412 + }, + { + c: 'chr6', + x: 115176364.21136145, + xe: 17802696.692206234, + y: 147348378.00226292, + ye: 44741934.53071979, + v: 0.5454021564038583 + }, + { + c: 'chr6', + x: 62837056.09114243, + xe: 26133008.11394129, + y: 72953410.44198796, + ye: 32798810.836549375, + v: 0.3846689136841709 + }, + { + c: 'chr6', + x: 73389776.9281407, + xe: 122028159.13662817, + y: 98965904.10172345, + ye: 8636403.620805442, + v: 0.8173408279082665 + }, + { + c: 'chr6', + x: 166348116.14961037, + xe: 77427580.15070356, + y: 136234697.7342235, + ye: 148731762.50492972, + v: 0.49799651706652515 + }, + { + c: 'chr6', + x: 95423139.94477129, + xe: 61653460.17155597, + y: 102432308.10735516, + ye: 160215792.66751015, + v: 0.9570623454092337 + }, + { + c: 'chr6', + x: 65798041.81727243, + xe: 14015500.900075769, + y: 43513240.659880586, + ye: 24885987.454443764, + v: 0.27789013331002466 + }, + { + c: 'chr6', + x: 40740853.42810836, + xe: 10993571.726650115, + y: 168136432.16170868, + ye: 103967064.86089337, + v: 0.04626564834954505 + }, + { + c: 'chr6', + x: 48364151.97946941, + xe: 155750819.02301785, + y: 92102148.19491732, + ye: 53016967.19719529, + v: 0.30349481738427464 + }, + { + c: 'chr6', + x: 8221955.755477873, + xe: 139017444.28415003, + y: 117137114.35828836, + ye: 159477917.97919828, + v: 0.7494183919160606 + }, + { + c: 'chr6', + x: 7531534.43814794, + xe: 37137558.50737791, + y: 31776670.467503868, + ye: 85522898.02594993, + v: 0.29815478008744944 + }, + { + c: 'chr6', + x: 147792552.77626994, + xe: 65177164.203718394, + y: 62079831.486335315, + ye: 32721766.65190317, + v: 0.9220623767681088 + }, + { + c: 'chr6', + x: 36666647.367560945, + xe: 70142500.44680493, + y: 14552118.549644915, + ye: 56500921.94891609, + v: 0.5298921993837763 + }, + { + c: 'chr6', + x: 164699620.5462074, + xe: 70584027.08873774, + y: 89925394.93808755, + ye: 136901524.21174392, + v: 0.9199753927849151 + }, + { + c: 'chr6', + x: 8695863.914226247, + xe: 53761967.276968434, + y: 130462634.35943675, + ye: 86743489.36500572, + v: 0.48975680822977274 + }, + { + c: 'chr6', + x: 84617502.191566, + xe: 19198841.878332824, + y: 154897369.90906614, + ye: 124753482.22608271, + v: 0.6324862077290112 + }, + { + c: 'chr6', + x: 4149384.082113235, + xe: 88886555.84547962, + y: 54491513.438885756, + ye: 79701171.21722282, + v: 0.7445352558189925 + }, + { + c: 'chr6', + x: 166723203.9284819, + xe: 152883853.74665737, + y: 8121502.731363576, + ye: 166331476.50343493, + v: 0.47634752828834637 + }, + { + c: 'chr6', + x: 156849952.765878, + xe: 1410202.8446918973, + y: 38095390.4039858, + ye: 164688832.02180532, + v: 0.26406416820950485 + }, + { + c: 'chr6', + x: 88827254.98877056, + xe: 89161796.82645394, + y: 122571611.19223627, + ye: 73200051.44629374, + v: 0.7446907459037804 + }, + { + c: 'chr6', + x: 79410391.97200519, + xe: 25437526.738909516, + y: 110322838.07951072, + ye: 168076291.05156305, + v: 0.9733151056901577 + }, + { + c: 'chr6', + x: 12703917.548024228, + xe: 166047719.89614257, + y: 53312397.76408736, + ye: 158082083.58767232, + v: 0.836585154011274 + }, + { + c: 'chr6', + x: 101601523.24460422, + xe: 99990632.73786522, + y: 138635619.30976227, + ye: 32222871.41669295, + v: 0.5750217039339056 + }, + { + c: 'chr6', + x: 39756561.2875116, + xe: 53402728.549700625, + y: 45956738.92976172, + ye: 169295847.2547223, + v: 0.4685857579319329 + }, + { + c: 'chr6', + x: 245372.78633055728, + xe: 49217792.72696211, + y: 92818882.2044769, + ye: 17859964.21707857, + v: 0.09542437321942043 + }, + { + c: 'chr6', + x: 163698805.79473126, + xe: 24704221.356608875, + y: 140667723.8231578, + ye: 55412675.74481373, + v: 0.20799317919710503 + }, + { + c: 'chr6', + x: 67515773.54363336, + xe: 160034299.75567162, + y: 151109721.7786741, + ye: 119207051.21589768, + v: 0.05279056344772726 + }, + { + c: 'chr6', + x: 48788468.99758006, + xe: 76328858.5711358, + y: 16701947.594341306, + ye: 135618224.8971849, + v: 0.24317181431905388 + }, + { + c: 'chr6', + x: 47643619.90457776, + xe: 13079811.229864351, + y: 122097504.23164186, + ye: 16464672.218727568, + v: 0.658453989687587 + }, + { + c: 'chr6', + x: 20161692.927864753, + xe: 126694299.67349464, + y: 151908722.59833193, + ye: 50975088.5615341, + v: 0.2824209840758797 + }, + { + c: 'chr6', + x: 167390841.650492, + xe: 138537375.7029475, + y: 149571047.2524186, + ye: 99921924.98897219, + v: 0.6269273668196591 + }, + { + c: 'chr6', + x: 109729278.95434286, + xe: 138256310.39560696, + y: 124927688.98051831, + ye: 65804368.2268985, + v: 0.1255992211543645 + }, + { + c: 'chr6', + x: 81499218.75644803, + xe: 125545597.8818042, + y: 147494032.62468666, + ye: 68091621.93401624, + v: 0.42196199674069657 + }, + { + c: 'chr6', + x: 126563732.59117717, + xe: 94019142.61748844, + y: 9373856.303036502, + ye: 128532885.28390048, + v: 0.6984818024661789 + }, + { + c: 'chr6', + x: 71931332.47758226, + xe: 117391825.75173244, + y: 77579856.42511831, + ye: 154445715.3817866, + v: 0.23980689217519768 + }, + { + c: 'chr6', + x: 111942993.42323558, + xe: 79299467.07707828, + y: 84875076.62654601, + ye: 114956306.97032854, + v: 0.12308222901719323 + }, + { + c: 'chr6', + x: 52408119.139988765, + xe: 85279724.01056449, + y: 54053883.1140105, + ye: 166991689.78285703, + v: 0.2191629956963177 + }, + { + c: 'chr6', + x: 35839390.934110805, + xe: 84495523.61002466, + y: 55629595.14989901, + ye: 79023280.42045389, + v: 0.3461279543278454 + }, + { + c: 'chr6', + x: 25939496.719755728, + xe: 69923709.74161234, + y: 86608272.61733355, + ye: 16658069.317998344, + v: 0.34451922759369835 + }, + { + c: 'chr6', + x: 57494709.33116686, + xe: 40716724.39890066, + y: 147812108.344582, + ye: 130243467.97885141, + v: 0.3364141181896707 + }, + { + c: 'chr6', + x: 67848615.57520881, + xe: 72618341.6599725, + y: 54551938.76233936, + ye: 129521443.414057, + v: 0.4892077800791078 + }, + { + c: 'chr6', + x: 10202377.16215592, + xe: 154309743.9313579, + y: 104476040.9408027, + ye: 13006588.208305791, + v: 0.4708082143009531 + }, + { + c: 'chr6', + x: 51664928.731411465, + xe: 67128576.43955031, + y: 79591555.12461928, + ye: 60149380.05487684, + v: 0.7277035036108482 + }, + { + c: 'chr6', + x: 62014433.766756035, + xe: 33745341.67567962, + y: 104204840.13021857, + ye: 33927524.890551746, + v: 0.7755436815994735 + }, + { + c: 'chr6', + x: 62817713.676086485, + xe: 111715705.2751027, + y: 45830113.15339963, + ye: 62202685.37611873, + v: 0.01812549747754655 + }, + { + c: 'chr6', + x: 161680332.5618511, + xe: 64989899.90601988, + y: 66947334.16737185, + ye: 104875715.36220564, + v: 0.7150424251095057 + }, + { + c: 'chr6', + x: 133263710.97718246, + xe: 111545439.06048748, + y: 56642008.45960254, + ye: 37703947.137314446, + v: 0.028409276318403065 + }, + { + c: 'chr6', + x: 123462326.36300975, + xe: 143978246.95453945, + y: 62489895.63571578, + ye: 79191297.12521124, + v: 0.7404691074534112 + }, + { + c: 'chr6', + x: 61821892.886805154, + xe: 130201242.14917049, + y: 98294055.65739742, + ye: 103757150.07919194, + v: 0.8543947228773375 + }, + { + c: 'chr6', + x: 13634979.927003115, + xe: 108668444.56038648, + y: 65016849.274389625, + ye: 92744484.48763327, + v: 0.8723306528106484 + }, + { + c: 'chr6', + x: 37642123.032537, + xe: 36100961.351511106, + y: 97388405.09293939, + ye: 51280321.05682149, + v: 0.13793686065479538 + }, + { + c: 'chr6', + x: 55593191.43020232, + xe: 160086431.66518164, + y: 126899475.62601312, + ye: 43673833.69195432, + v: 0.2929516703937035 + }, + { + c: 'chr6', + x: 98070945.73924348, + xe: 98054990.34547529, + y: 104658766.1921194, + ye: 37882615.214017645, + v: 0.6338664929899985 + }, + { + c: 'chr6', + x: 6921448.411308444, + xe: 157608191.76058313, + y: 34621496.578025565, + ye: 126744016.49804024, + v: 0.1475510434836773 + }, + { + c: 'chr6', + x: 39826761.957073346, + xe: 120953047.45657697, + y: 101664208.90176678, + ye: 19629690.15999706, + v: 0.9692824272136153 + }, + { + c: 'chr6', + x: 53348548.83026182, + xe: 112444420.16373412, + y: 72461577.34745017, + ye: 168246485.14883035, + v: 0.09281753377304935 + }, + { + c: 'chr6', + x: 80668253.94496109, + xe: 81256619.38458812, + y: 151130597.09674162, + ye: 125991509.19663428, + v: 0.9341553276677697 + }, + { + c: 'chr6', + x: 74782486.72930346, + xe: 91172765.23642707, + y: 157349970.5000896, + ye: 101622057.99218684, + v: 0.39244234644256637 + }, + { + c: 'chr6', + x: 45068518.87248729, + xe: 144405911.7282128, + y: 108843903.04016306, + ye: 93801532.92853229, + v: 0.7129202321896626 + }, + { + c: 'chr6', + x: 110635894.68623951, + xe: 3076096.3493298297, + y: 93493327.04829186, + ye: 74780854.365488, + v: 0.8406161694105565 + }, + { + c: 'chr6', + x: 17681943.148999576, + xe: 139338971.85833532, + y: 142187343.4733413, + ye: 39437706.63274971, + v: 0.7745498270396383 + }, + { + c: 'chr6', + x: 156277739.2187354, + xe: 211068.20368372215, + y: 125405729.93982616, + ye: 129910020.82911217, + v: 0.0062251700579415425 + }, + { + c: 'chr6', + x: 154516597.33921713, + xe: 97857721.62621507, + y: 56121376.71500377, + ye: 118116411.71071745, + v: 0.4422921995177066 + }, + { + c: 'chr6', + x: 78705544.45961127, + xe: 154116801.18579495, + y: 52972861.08662878, + ye: 43504220.051593415, + v: 0.5095255235663386 + }, + { + c: 'chr6', + x: 109982598.81722803, + xe: 43733989.52899636, + y: 5274018.298954498, + ye: 158101287.5162016, + v: 0.8128441507867111 + }, + { + c: 'chr6', + x: 32928278.068765204, + xe: 54064025.700957455, + y: 57380281.68686666, + ye: 24948603.609145276, + v: 0.1085087211291017 + }, + { + c: 'chr6', + x: 51737228.1751258, + xe: 12447644.251245894, + y: 157266377.0415371, + ye: 32145469.786801156, + v: 0.47608967557314474 + }, + { + c: 'chr6', + x: 149744256.4818876, + xe: 166410784.83297628, + y: 112167412.51798098, + ye: 56927308.54125804, + v: 0.6844195125187523 + }, + { + c: 'chr6', + x: 58852134.04751112, + xe: 116126405.49020985, + y: 13673843.891215177, + ye: 23260388.565413583, + v: 0.7267721478708433 + }, + { + c: 'chr6', + x: 121336452.95562755, + xe: 108717287.1282083, + y: 18364942.09851653, + ye: 39596212.91691148, + v: 0.20876335715216543 + }, + { + c: 'chr6', + x: 67947772.66757059, + xe: 92478730.29817174, + y: 33866853.38474834, + ye: 107268624.82426728, + v: 0.29715272519290115 + }, + { + c: 'chr6', + x: 73661630.0278671, + xe: 68934739.53073356, + y: 106909570.3576211, + ye: 74703734.5828432, + v: 0.038696483701080475 + }, + { + c: 'chr6', + x: 79192441.15109517, + xe: 136016704.78228694, + y: 75331220.25110583, + ye: 31856765.283419807, + v: 0.9345336830766292 + }, + { + c: 'chr6', + x: 22119832.017001368, + xe: 155436346.16077596, + y: 91827601.94348668, + ye: 110216169.62711225, + v: 0.08131954551391818 + }, + { + c: 'chr6', + x: 20598751.53805264, + xe: 41757565.912663326, + y: 13249671.446784485, + ye: 114408206.35023116, + v: 0.2592935653973749 + }, + { + c: 'chr6', + x: 35641274.27268896, + xe: 45671265.065753296, + y: 67694742.26577444, + ye: 38095688.69976158, + v: 0.09168166532397215 + }, + { + c: 'chr6', + x: 8421697.389763013, + xe: 74160904.90799218, + y: 167384766.39423537, + ye: 137061908.1618013, + v: 0.04345563282125964 + }, + { + c: 'chr6', + x: 81202544.57261871, + xe: 19743297.175787814, + y: 47060141.94884085, + ye: 25738314.63832783, + v: 0.18087504227775753 + }, + { + c: 'chr6', + x: 144568671.4254253, + xe: 90515678.63404231, + y: 150181498.59730187, + ye: 71121170.02477431, + v: 0.9345402930230123 + }, + { + c: 'chr6', + x: 159844470.3004839, + xe: 164704448.8630201, + y: 114901682.25336961, + ye: 151201827.06641415, + v: 0.8119791537622377 + }, + { + c: 'chr6', + x: 4826889.015764315, + xe: 12046442.532789467, + y: 133757393.30357407, + ye: 66308820.663608484, + v: 0.7750771096346794 + }, + { + c: 'chr6', + x: 84314275.74080627, + xe: 114083210.40627721, + y: 17823751.613818984, + ye: 118292557.35699561, + v: 0.09636568258646128 + }, + { + c: 'chr6', + x: 149967747.87487727, + xe: 102361012.16993228, + y: 63903380.61571507, + ye: 107080059.23417203, + v: 0.36437159985219136 + }, + { + c: 'chr6', + x: 158974584.73920643, + xe: 6540933.868984455, + y: 124095789.15976194, + ye: 67218834.85930346, + v: 0.9805263172656928 + }, + { + c: 'chr6', + x: 22238856.91068611, + xe: 32359805.141998734, + y: 137439335.04243964, + ye: 36907925.026992515, + v: 0.556038405338008 + }, + { + c: 'chr6', + x: 170169851.0739741, + xe: 38146402.79467438, + y: 115654607.15889579, + ye: 31348593.43553803, + v: 0.2561238675967836 + }, + { + c: 'chr6', + x: 47131591.08918487, + xe: 60029191.510103345, + y: 96958894.3477925, + ye: 93231579.68404377, + v: 0.5416818265416458 + }, + { + c: 'chr7', + x: 2594535.623553705, + xe: 156157764.47498092, + y: 109253763.72701742, + ye: 118648300.60027334, + v: 0.39955858844943315 + }, + { + c: 'chr7', + x: 117808336.6864913, + xe: 73692603.6278854, + y: 118931186.29155977, + ye: 146632.5550259643, + v: 0.9295307370318914 + }, + { + c: 'chr7', + x: 120244263.11290352, + xe: 133805981.55719429, + y: 4240763.9724780945, + ye: 52648720.08371655, + v: 0.05725983286921699 + }, + { + c: 'chr7', + x: 8316077.088972834, + xe: 127936280.56714946, + y: 38230038.175649606, + ye: 156530840.61719957, + v: 0.9145108316477806 + }, + { + c: 'chr7', + x: 110418163.69690359, + xe: 104282215.05995966, + y: 139809750.78624064, + ye: 32499345.881625436, + v: 0.627339305394573 + }, + { + c: 'chr7', + x: 22485382.56403598, + xe: 155321227.13620657, + y: 98578423.7061224, + ye: 132043165.81595668, + v: 0.3925883747766695 + }, + { + c: 'chr7', + x: 53826560.54616771, + xe: 147911879.77914217, + y: 92658562.65905361, + ye: 158077016.29659125, + v: 0.8493583378129963 + }, + { + c: 'chr7', + x: 26416368.047763832, + xe: 156568255.48936373, + y: 138502197.3564388, + ye: 37355978.617633305, + v: 0.8237698988185993 + }, + { + c: 'chr7', + x: 63715895.53669791, + xe: 139117702.8747342, + y: 68802580.94811444, + ye: 10437911.571852932, + v: 0.17148349922264683 + }, + { + c: 'chr7', + x: 29306363.593437828, + xe: 83501465.50941543, + y: 42847917.58175226, + ye: 26706925.80476601, + v: 0.006825578586584613 + }, + { + c: 'chr7', + x: 109290160.35526265, + xe: 113108113.52567248, + y: 56262810.45806676, + ye: 17367769.306702185, + v: 0.09110957244864448 + }, + { + c: 'chr7', + x: 45480896.87529465, + xe: 103634933.08770488, + y: 46255310.82025852, + ye: 23319465.81610172, + v: 0.6561988915251905 + }, + { + c: 'chr7', + x: 122249946.58625732, + xe: 63170475.77803186, + y: 121028188.93699065, + ye: 62352772.76556986, + v: 0.7228007003665456 + }, + { + c: 'chr7', + x: 105487163.0279637, + xe: 30316455.06085321, + y: 138783497.2005161, + ye: 118990478.73895839, + v: 0.6529970402123569 + }, + { + c: 'chr7', + x: 60394044.553506255, + xe: 123955409.81856054, + y: 126538305.16861737, + ye: 82934616.92467768, + v: 0.14068830687682632 + }, + { + c: 'chr7', + x: 135641424.61334428, + xe: 102143575.2724032, + y: 117536119.31556115, + ye: 100700665.50392315, + v: 0.22268411961572843 + }, + { + c: 'chr7', + x: 124267666.57736431, + xe: 55690027.01071102, + y: 80845715.65544723, + ye: 139680145.67358598, + v: 0.48754212688554166 + }, + { + c: 'chr7', + x: 46377977.14531396, + xe: 13873609.039224338, + y: 97991041.60538332, + ye: 108356054.64252244, + v: 0.6055063621072351 + }, + { + c: 'chr7', + x: 98009471.20121574, + xe: 82030067.79266256, + y: 23491894.700014126, + ye: 126952297.93783863, + v: 0.683865741041542 + }, + { + c: 'chr7', + x: 37925992.421726175, + xe: 148755054.0713564, + y: 51171650.75402412, + ye: 23875577.52398521, + v: 0.8929961512903274 + }, + { + c: 'chr7', + x: 81596686.93542896, + xe: 149769780.767478, + y: 25465663.79033799, + ye: 40916841.99828672, + v: 0.4119562459895606 + }, + { + c: 'chr7', + x: 6130179.760538333, + xe: 34015256.51552309, + y: 46995395.42985454, + ye: 111483082.3374631, + v: 0.7122655792852737 + }, + { + c: 'chr7', + x: 88235691.2240849, + xe: 11420627.882652165, + y: 110476856.98857488, + ye: 9510431.521796292, + v: 0.8133838998250884 + }, + { + c: 'chr7', + x: 128786778.56771311, + xe: 68186275.91434078, + y: 105645447.92241095, + ye: 156167654.51658553, + v: 0.21883783023475478 + }, + { + c: 'chr7', + x: 96309140.64724743, + xe: 18550209.509747814, + y: 30111787.13644541, + ye: 19198876.601277076, + v: 0.5327216860796444 + }, + { + c: 'chr7', + x: 146468944.8060466, + xe: 124642452.2763134, + y: 115792499.5324722, + ye: 36660497.16232225, + v: 0.6825992647495264 + }, + { + c: 'chr7', + x: 14333050.976487169, + xe: 10835475.127720045, + y: 10560509.95322304, + ye: 112580260.68651147, + v: 0.7776804929611019 + }, + { + c: 'chr7', + x: 52330614.32835498, + xe: 95850020.05281654, + y: 111429063.79792446, + ye: 156811743.02921838, + v: 0.44550720042631964 + }, + { + c: 'chr7', + x: 75669728.87268776, + xe: 125457345.61005892, + y: 96119840.35847563, + ye: 54954862.160727836, + v: 0.39289911981753367 + }, + { + c: 'chr7', + x: 51081289.618541695, + xe: 21925446.9845624, + y: 122008589.91068083, + ye: 104167581.96409392, + v: 0.2546449121745539 + }, + { + c: 'chr7', + x: 131155156.61287792, + xe: 112765253.57506715, + y: 129517341.5073749, + ye: 14318261.56342463, + v: 0.555088617114028 + }, + { + c: 'chr7', + x: 64019050.38185962, + xe: 83366837.85375261, + y: 127286098.7583174, + ye: 56710200.90976039, + v: 0.29865051824578626 + }, + { + c: 'chr7', + x: 1084111.4613507383, + xe: 93980779.065704, + y: 90588879.6105104, + ye: 46368069.585939355, + v: 0.4070399893837022 + }, + { + c: 'chr7', + x: 139761179.2391312, + xe: 50990884.13919961, + y: 104298874.36681142, + ye: 158120600.23308036, + v: 0.31367074370787185 + }, + { + c: 'chr7', + x: 111948168.4396956, + xe: 86489441.91401652, + y: 67464666.97935297, + ye: 34950337.093748845, + v: 0.47388741267471257 + }, + { + c: 'chr7', + x: 151397638.9965743, + xe: 112745798.10119332, + y: 115863114.3837723, + ye: 109276334.65849084, + v: 0.687886171953837 + }, + { + c: 'chr7', + x: 17588602.590748034, + xe: 118024491.4509778, + y: 136852937.02505463, + ye: 83843039.82636286, + v: 0.7337881011241972 + }, + { + c: 'chr7', + x: 71520527.62523049, + xe: 150732077.85789347, + y: 84957958.4144124, + ye: 75109109.6266716, + v: 0.9167235838307706 + }, + { + c: 'chr7', + x: 41503799.52571324, + xe: 20567589.12269379, + y: 152907124.86535943, + ye: 129541929.84943983, + v: 0.7804361932589031 + }, + { + c: 'chr7', + x: 31976571.84221899, + xe: 121939444.21945237, + y: 37851723.95294511, + ye: 79318885.90916863, + v: 0.4391099109497085 + }, + { + c: 'chr7', + x: 33185572.016142264, + xe: 108518162.49959229, + y: 81198526.9298305, + ye: 101882420.41025305, + v: 0.659368572265451 + }, + { + c: 'chr7', + x: 15371279.738402616, + xe: 65160499.86023032, + y: 136113491.3810135, + ye: 37133000.44526425, + v: 0.8140600061095122 + }, + { + c: 'chr7', + x: 115895365.33541013, + xe: 22856322.266151443, + y: 106035.57456588151, + ye: 134570615.35963103, + v: 0.9779333832045028 + }, + { + c: 'chr7', + x: 124624517.59528853, + xe: 12314814.193822514, + y: 49704153.268162794, + ye: 19575474.158323325, + v: 0.8020245968991653 + }, + { + c: 'chr7', + x: 131771206.23828726, + xe: 156844623.79248527, + y: 149218653.8299544, + ye: 153462377.96982995, + v: 0.4790632299174459 + }, + { + c: 'chr7', + x: 70402703.23898406, + xe: 55885445.78097348, + y: 91828585.5602437, + ye: 21786435.777145844, + v: 0.8007298443908238 + }, + { + c: 'chr7', + x: 95599573.37937982, + xe: 12346281.98600224, + y: 78958549.69605868, + ye: 136648361.7288386, + v: 0.4440500261934389 + }, + { + c: 'chr7', + x: 66276368.114203304, + xe: 52673558.62460901, + y: 58460313.74559164, + ye: 66566333.58154897, + v: 0.7943872281373913 + }, + { + c: 'chr7', + x: 76477644.53513339, + xe: 119140619.85003954, + y: 129763414.25922984, + ye: 130449510.78202856, + v: 0.9656754863856972 + }, + { + c: 'chr7', + x: 48545292.58456851, + xe: 74305957.19838518, + y: 22690963.924013283, + ye: 43286927.32427615, + v: 0.21442678373456592 + }, + { + c: 'chr7', + x: 132069729.38140056, + xe: 134882378.5343007, + y: 104636647.62240277, + ye: 10020380.005076705, + v: 0.5170962326794705 + }, + { + c: 'chr7', + x: 100793887.99190463, + xe: 42089062.37164148, + y: 72092964.50736834, + ye: 148574779.90972733, + v: 0.05057445970791319 + }, + { + c: 'chr7', + x: 73316409.31817643, + xe: 15371587.956431603, + y: 146844675.31188717, + ye: 10721861.56302364, + v: 0.9610543993245911 + }, + { + c: 'chr7', + x: 41062165.66927184, + xe: 107562382.54687528, + y: 113490694.78067845, + ye: 24404253.333102126, + v: 0.7486259233213809 + }, + { + c: 'chr7', + x: 126768103.1945108, + xe: 30727903.08334513, + y: 40315583.734119266, + ye: 96504005.7013457, + v: 0.12214314869330667 + }, + { + c: 'chr7', + x: 67661804.1348814, + xe: 49456366.78551893, + y: 55758054.53430744, + ye: 11249139.668805385, + v: 0.31332898263144116 + }, + { + c: 'chr7', + x: 62085840.1761284, + xe: 5030545.7170474185, + y: 11426682.092880322, + ye: 9197548.949227827, + v: 0.07748727286518364 + }, + { + c: 'chr7', + x: 33603471.71284827, + xe: 127486518.61206885, + y: 112601749.25002222, + ye: 97864231.16249362, + v: 0.4012573363089431 + }, + { + c: 'chr7', + x: 40765104.66654619, + xe: 56004508.708676584, + y: 105563165.6947447, + ye: 33967634.00686435, + v: 0.661103072858426 + }, + { + c: 'chr7', + x: 11082318.119835457, + xe: 141321478.93416366, + y: 18413944.994935274, + ye: 127883169.42255892, + v: 0.26133983408751593 + }, + { + c: 'chr7', + x: 80612467.32752758, + xe: 132902401.65136467, + y: 56668895.33625715, + ye: 54689007.269413434, + v: 0.9109296688850798 + }, + { + c: 'chr7', + x: 112019553.83804841, + xe: 111753860.64710808, + y: 63257337.851739414, + ye: 72873637.41162337, + v: 0.28475093018416986 + }, + { + c: 'chr7', + x: 151187979.7229916, + xe: 118009257.297011, + y: 92111439.9106752, + ye: 100544301.60669434, + v: 0.2559767026375189 + }, + { + c: 'chr7', + x: 130481017.1733797, + xe: 123081404.71462275, + y: 69428220.46522257, + ye: 3567373.741461386, + v: 0.9703203595950698 + }, + { + c: 'chr7', + x: 14813836.865818221, + xe: 120700997.4924223, + y: 25338203.503036115, + ye: 103131710.9070094, + v: 0.6529535576018345 + }, + { + c: 'chr7', + x: 139149152.07439673, + xe: 83902863.47056629, + y: 64206267.5619902, + ye: 43165241.83301154, + v: 0.6857166146359963 + }, + { + c: 'chr7', + x: 4332319.662721508, + xe: 74579027.98732553, + y: 147192375.69115752, + ye: 143936475.08929393, + v: 0.09480882772981014 + }, + { + c: 'chr7', + x: 108858864.3004836, + xe: 102398088.70251572, + y: 8682535.130245157, + ye: 71430912.91844413, + v: 0.7115417200808939 + }, + { + c: 'chr7', + x: 21033745.282747805, + xe: 15022736.370966448, + y: 144458987.86245194, + ye: 92528968.0620199, + v: 0.9660687243498742 + }, + { + c: 'chr7', + x: 25652990.088033225, + xe: 157167144.79824632, + y: 6753995.918858561, + ye: 45152812.57077414, + v: 0.5040971350044282 + }, + { + c: 'chr7', + x: 153197812.05860206, + xe: 36712542.857403696, + y: 89153408.16879946, + ye: 81555130.6804538, + v: 0.7083209282700658 + }, + { + c: 'chr7', + x: 106644214.18830399, + xe: 51597007.81415904, + y: 117224696.17459372, + ye: 76248939.16470057, + v: 0.8698053532938369 + }, + { + c: 'chr7', + x: 140453709.65756962, + xe: 96908398.25148521, + y: 103150496.66527954, + ye: 139019489.3495953, + v: 0.14654359173335985 + }, + { + c: 'chr7', + x: 5449133.946941535, + xe: 21665858.053866662, + y: 144259300.92308384, + ye: 2502283.031127581, + v: 0.7250432788435094 + }, + { + c: 'chr7', + x: 2611401.8625310096, + xe: 63543419.09434765, + y: 158436650.6963261, + ye: 64091143.16625391, + v: 0.05969029028676098 + }, + { + c: 'chr7', + x: 31920170.824428253, + xe: 92377051.96055296, + y: 48495179.57055754, + ye: 23279698.47469913, + v: 0.6740820873427065 + }, + { + c: 'chr7', + x: 119555416.215283, + xe: 28837205.26093433, + y: 103431943.21305792, + ye: 41364968.389867246, + v: 0.9161680996395016 + }, + { + c: 'chr7', + x: 99096390.48412828, + xe: 105721053.0151008, + y: 83462228.62760764, + ye: 126873309.65143172, + v: 0.049710757154913465 + }, + { + c: 'chr7', + x: 135127374.31658015, + xe: 155524716.49821478, + y: 127408244.96092686, + ye: 142289539.07755256, + v: 0.7501453904726597 + }, + { + c: 'chr7', + x: 15934009.956793154, + xe: 153891650.85979807, + y: 157739564.95738038, + ye: 51686441.2572146, + v: 0.03428570804502773 + }, + { + c: 'chr7', + x: 42202186.33075724, + xe: 96006716.15412676, + y: 128812648.02784455, + ye: 138511971.1903277, + v: 0.9640821163535108 + }, + { + c: 'chr7', + x: 43216456.094648845, + xe: 61986906.563898064, + y: 145867974.14561716, + ye: 128252954.62592164, + v: 0.5411017413941157 + }, + { + c: 'chr7', + x: 71079624.56284186, + xe: 45176226.69490218, + y: 34263894.248202346, + ye: 7324801.120380155, + v: 0.47026778700430794 + }, + { + c: 'chr7', + x: 94509765.29124862, + xe: 39167424.27197837, + y: 145163057.28011966, + ye: 17043359.463429388, + v: 0.9197757629693798 + }, + { + c: 'chr7', + x: 24739154.03831507, + xe: 153016716.27346244, + y: 20117509.010807898, + ye: 117346110.60026203, + v: 0.6401363655448664 + }, + { + c: 'chr7', + x: 2126849.0445374404, + xe: 63211581.70100536, + y: 131979654.17765391, + ye: 69627534.18091714, + v: 0.4713142520077169 + }, + { + c: 'chr7', + x: 57149280.15789952, + xe: 61048650.249548845, + y: 136646614.145921, + ye: 111893521.52220935, + v: 0.9360707112538793 + }, + { + c: 'chr7', + x: 126636518.16321522, + xe: 18238029.49593065, + y: 86727971.91033058, + ye: 24828265.410291053, + v: 0.21050512581267022 + }, + { + c: 'chr7', + x: 6228105.18909973, + xe: 136552424.2420483, + y: 107690276.22621176, + ye: 31814847.4079671, + v: 0.47878274268583665 + }, + { + c: 'chr7', + x: 90272148.09961759, + xe: 54014348.65510429, + y: 133917522.89120172, + ye: 46964315.50022643, + v: 0.35162186363235026 + }, + { + c: 'chr7', + x: 154379543.71980065, + xe: 101985192.92899998, + y: 11748051.248622248, + ye: 116503619.51645102, + v: 0.8411698683591314 + }, + { + c: 'chr7', + x: 123393600.24356222, + xe: 154923289.63101277, + y: 83748789.23634051, + ye: 136535246.8095587, + v: 0.8191884298031615 + }, + { + c: 'chr7', + x: 3139238.1825093, + xe: 89242508.88867563, + y: 83043276.43436444, + ye: 129894487.37745108, + v: 0.7690534296935959 + }, + { + c: 'chr7', + x: 31641872.33605496, + xe: 108926317.45925398, + y: 63982442.98310266, + ye: 127945409.00739723, + v: 0.7413736852803778 + }, + { + c: 'chr7', + x: 150559187.06198582, + xe: 149031207.18899265, + y: 75089606.05099683, + ye: 156928422.9883809, + v: 0.9380072557056948 + }, + { + c: 'chr7', + x: 70345455.6024071, + xe: 33894035.4606419, + y: 9640587.104947139, + ye: 26600657.575003732, + v: 0.15231916982876215 + }, + { + c: 'chr7', + x: 22722977.17938931, + xe: 29597393.130384743, + y: 80985765.54342529, + ye: 67520950.8195321, + v: 0.788623526979054 + }, + { + c: 'chr7', + x: 105305588.680419, + xe: 78596818.50263587, + y: 55581974.46947028, + ye: 10347203.009426413, + v: 0.7477850225017026 + }, + { + c: 'chr7', + x: 97870771.7809815, + xe: 34119712.091742836, + y: 98878439.73297407, + ye: 92700991.43013573, + v: 0.16936228021097866 + }, + { + c: 'chr7', + x: 123161939.59882028, + xe: 141298724.99442378, + y: 7544425.569599543, + ye: 58419762.049239464, + v: 0.991963071685669 + }, + { + c: 'chr8', + x: 8896131.258496521, + xe: 68085509.3698464, + y: 122735067.04002129, + ye: 108512652.24000698, + v: 0.158600487605015 + }, + { + c: 'chr8', + x: 113196247.77821073, + xe: 117664802.90396832, + y: 132645150.74613649, + ye: 39494001.02406746, + v: 0.9151503343406996 + }, + { + c: 'chr8', + x: 96225730.88993439, + xe: 98115976.25579853, + y: 92297862.10357754, + ye: 62924299.05584218, + v: 0.1535940233671843 + }, + { + c: 'chr8', + x: 79459537.47331025, + xe: 27687231.49805357, + y: 29927804.14018873, + ye: 119430119.8260268, + v: 0.9038921215497828 + }, + { + c: 'chr8', + x: 113596053.75574777, + xe: 100346777.92593515, + y: 106669465.94400075, + ye: 136422877.80572858, + v: 0.9702111657017218 + }, + { + c: 'chr8', + x: 75923176.56146671, + xe: 31367178.11442543, + y: 70387244.67696449, + ye: 135802669.52026084, + v: 0.694014942463516 + }, + { + c: 'chr8', + x: 134407440.61210287, + xe: 67537427.49599706, + y: 109084525.56739949, + ye: 2088295.5419739515, + v: 0.19425713157111146 + }, + { + c: 'chr8', + x: 13998006.403872242, + xe: 113098082.35695665, + y: 72343135.64359528, + ye: 40514279.00460438, + v: 0.05015865315887069 + }, + { + c: 'chr8', + x: 63343627.3029601, + xe: 14310484.875283388, + y: 53790625.97701374, + ye: 28586425.076562278, + v: 0.8386788742702235 + }, + { + c: 'chr8', + x: 29954669.948571954, + xe: 134791026.1263, + y: 40375200.495961614, + ye: 63377512.44056924, + v: 0.27478913856440024 + }, + { + c: 'chr8', + x: 153176.35013655457, + xe: 53234268.880591385, + y: 28039541.74221792, + ye: 3214286.4402797986, + v: 0.5846976166347684 + }, + { + c: 'chr8', + x: 122053573.38774881, + xe: 48207632.63303576, + y: 45244832.86839027, + ye: 92296728.39277129, + v: 0.284598588300607 + }, + { + c: 'chr8', + x: 130325306.5614331, + xe: 88808622.43924424, + y: 78890175.67653255, + ye: 64293367.18296341, + v: 0.8221783624130952 + }, + { + c: 'chr8', + x: 73848860.32162635, + xe: 97676459.92408755, + y: 28691037.97524119, + ye: 28216085.904456854, + v: 0.8634081771287866 + }, + { + c: 'chr8', + x: 97610581.35041043, + xe: 81607031.59408145, + y: 75244498.87316869, + ye: 48859881.58512401, + v: 0.8608066570355526 + }, + { + c: 'chr8', + x: 40780223.4662169, + xe: 46008065.4757437, + y: 30311251.46751801, + ye: 94242010.20087172, + v: 0.9662047460309103 + }, + { + c: 'chr8', + x: 32038875.927376837, + xe: 129890041.28793994, + y: 8189211.792269915, + ye: 128031114.81164815, + v: 0.1553621438345556 + }, + { + c: 'chr8', + x: 6895039.143739805, + xe: 1610863.0252653335, + y: 142749020.2571339, + ye: 50775006.24301983, + v: 0.49606906292307307 + }, + { + c: 'chr8', + x: 76652623.73744471, + xe: 138307947.4812676, + y: 107851959.9319066, + ye: 1573421.1884184156, + v: 0.03556334597796884 + }, + { + c: 'chr8', + x: 91767457.37090047, + xe: 91429816.0826128, + y: 55647334.82959751, + ye: 125764881.45761262, + v: 0.1040000789554063 + }, + { + c: 'chr8', + x: 44507951.63586859, + xe: 14303999.982422506, + y: 73050422.12111686, + ye: 21874696.5411008, + v: 0.11048698979651927 + }, + { + c: 'chr8', + x: 42205879.62594641, + xe: 33670310.94132432, + y: 130582534.61342904, + ye: 89704035.40455388, + v: 0.7517784511413481 + }, + { + c: 'chr8', + x: 43509900.47389401, + xe: 40643223.76239708, + y: 118059403.20922443, + ye: 96094324.61299196, + v: 0.9033287010840334 + }, + { + c: 'chr8', + x: 11942305.21427402, + xe: 116626680.36426735, + y: 136479444.61925256, + ye: 103998169.2174904, + v: 0.17863042406593965 + }, + { + c: 'chr8', + x: 123445771.28843695, + xe: 56158037.168439604, + y: 82331137.99308613, + ye: 59809284.81927234, + v: 0.27102426778447364 + }, + { + c: 'chr8', + x: 45952649.75090984, + xe: 47580852.901991144, + y: 20508631.147802074, + ye: 50655371.14395713, + v: 0.8322293320372448 + }, + { + c: 'chr8', + x: 78249755.1963868, + xe: 9783271.200445691, + y: 1465993.5799612107, + ye: 29042050.85788993, + v: 0.4950509738626012 + }, + { + c: 'chr8', + x: 120083405.59034619, + xe: 22194293.759513754, + y: 66465495.76349909, + ye: 10714955.122708876, + v: 0.7866550410048717 + }, + { + c: 'chr8', + x: 30728531.7855323, + xe: 102089512.54450999, + y: 49680717.434562124, + ye: 62590634.35497338, + v: 0.4409688148358487 + }, + { + c: 'chr8', + x: 8023169.6887649195, + xe: 3866548.3392995265, + y: 23064838.88156003, + ye: 127250813.70616263, + v: 0.17891021875337643 + }, + { + c: 'chr8', + x: 30779289.856332537, + xe: 104933763.85621771, + y: 69282438.34673241, + ye: 17517032.613125447, + v: 0.11254746751382971 + }, + { + c: 'chr8', + x: 101451004.26989993, + xe: 19756276.969781697, + y: 51458263.35013961, + ye: 52764478.10611017, + v: 0.8089873245982099 + }, + { + c: 'chr8', + x: 130919293.67050447, + xe: 88304597.43462256, + y: 114227335.19107272, + ye: 56114417.21463879, + v: 0.7053669795710638 + }, + { + c: 'chr8', + x: 94272622.53035422, + xe: 115706086.5402152, + y: 80151274.63763385, + ye: 80356062.8970385, + v: 0.9199388669101938 + }, + { + c: 'chr8', + x: 34347575.25743507, + xe: 105708108.38188504, + y: 65504026.03136083, + ye: 26635607.36945646, + v: 0.5165720991159369 + }, + { + c: 'chr8', + x: 84340269.72963679, + xe: 139908501.89731947, + y: 6881313.613719798, + ye: 1251431.5718995158, + v: 0.23058085606532952 + }, + { + c: 'chr8', + x: 142677083.39318597, + xe: 24380321.602152776, + y: 47524949.67427112, + ye: 110022779.1940776, + v: 0.00530490549200624 + }, + { + c: 'chr8', + x: 109372083.45625804, + xe: 69814615.68302423, + y: 94990767.38991787, + ye: 119533282.0330955, + v: 0.8581553196774296 + }, + { + c: 'chr8', + x: 8346570.882783046, + xe: 94134455.01580706, + y: 13688208.346569812, + ye: 115127676.55129036, + v: 0.8527438330758078 + }, + { + c: 'chr8', + x: 145010668.857882, + xe: 58977168.29170823, + y: 33806760.99493343, + ye: 96006611.559112, + v: 0.8181816751210944 + }, + { + c: 'chr8', + x: 1869100.1610648024, + xe: 115776179.14348434, + y: 19578899.62856119, + ye: 51913652.82258104, + v: 0.9841359605161903 + }, + { + c: 'chr8', + x: 123306384.73301364, + xe: 99031190.28149119, + y: 109492456.55248459, + ye: 32365361.765830886, + v: 0.7935497167122919 + }, + { + c: 'chr8', + x: 144476671.3440404, + xe: 101558751.95028004, + y: 144464251.3578335, + ye: 130629081.14021188, + v: 0.3558042509132796 + }, + { + c: 'chr8', + x: 29197811.902821198, + xe: 142877345.9830835, + y: 105455434.59260587, + ye: 71882826.11459523, + v: 0.015449673829586175 + }, + { + c: 'chr8', + x: 120753360.01809391, + xe: 9193886.03552675, + y: 16010349.55321953, + ye: 18991255.24066553, + v: 0.5007850038765576 + }, + { + c: 'chr8', + x: 120798976.18424234, + xe: 62723023.91000031, + y: 139688108.29053515, + ye: 122300661.38374582, + v: 0.1088653209848941 + }, + { + c: 'chr8', + x: 14589561.868351107, + xe: 70267757.21453899, + y: 65507194.20945331, + ye: 25991182.100881934, + v: 0.735976437404309 + }, + { + c: 'chr8', + x: 110108912.46980678, + xe: 16050613.231246922, + y: 79850056.78829588, + ye: 21622761.68670384, + v: 0.4731088662799663 + }, + { + c: 'chr8', + x: 82080907.92635405, + xe: 58445901.486016504, + y: 99040944.21733846, + ye: 25503836.337950986, + v: 0.6033090228668148 + }, + { + c: 'chr8', + x: 14151209.867891172, + xe: 46399320.56338581, + y: 74895699.53610663, + ye: 56576561.86592719, + v: 0.48457240660394774 + }, + { + c: 'chr8', + x: 109353536.2181726, + xe: 42919354.91045749, + y: 97778891.36161815, + ye: 40182132.788822025, + v: 0.5780050263220118 + }, + { + c: 'chr8', + x: 59827918.70617419, + xe: 134493817.43308592, + y: 101915777.20859027, + ye: 142744167.48956674, + v: 0.7032336764321129 + }, + { + c: 'chr8', + x: 27099810.43782673, + xe: 32448727.093644008, + y: 64683862.038083635, + ye: 20194253.48395838, + v: 0.5300486293176405 + }, + { + c: 'chr8', + x: 130632525.52473818, + xe: 66280506.54017755, + y: 51990038.9638602, + ye: 96440150.23312056, + v: 0.43646707129702833 + }, + { + c: 'chr8', + x: 24458851.231277414, + xe: 121088069.35412785, + y: 13355945.272076013, + ye: 88218236.15267737, + v: 0.6796519525632277 + }, + { + c: 'chr8', + x: 114074318.57592322, + xe: 130215249.0220485, + y: 117457940.50440086, + ye: 39000241.65991433, + v: 0.39908988720383676 + }, + { + c: 'chr8', + x: 40657238.99716135, + xe: 110907492.39451721, + y: 95896003.88193132, + ye: 63066710.44788482, + v: 0.06920724828395619 + }, + { + c: 'chr8', + x: 10085859.22652453, + xe: 67644523.11831045, + y: 129634099.46095908, + ye: 11631606.74343739, + v: 0.9989122027856191 + }, + { + c: 'chr8', + x: 77001341.11948264, + xe: 83979624.02746695, + y: 52177134.860814184, + ye: 33429368.1576148, + v: 0.6073634563306778 + }, + { + c: 'chr8', + x: 131689592.58573905, + xe: 39983559.638808884, + y: 104586949.80983122, + ye: 44556781.67471237, + v: 0.33359212910425073 + }, + { + c: 'chr8', + x: 129620503.4106681, + xe: 26005543.588295676, + y: 78631787.1511484, + ye: 24803512.651131812, + v: 0.17123727068459182 + }, + { + c: 'chr8', + x: 78251090.5366029, + xe: 104355935.30650067, + y: 79295001.30345358, + ye: 10038286.628448786, + v: 0.9287998647110457 + }, + { + c: 'chr8', + x: 118152516.01449911, + xe: 130734114.51404294, + y: 97313132.64313965, + ye: 56426734.98806572, + v: 0.3783156449336358 + }, + { + c: 'chr8', + x: 32834163.601052828, + xe: 73018947.37712964, + y: 41848127.18505763, + ye: 12565920.551134668, + v: 0.11765022916187684 + }, + { + c: 'chr8', + x: 24048739.448769554, + xe: 47420151.31772247, + y: 70638647.06987129, + ye: 115269962.11434361, + v: 0.8425048634502535 + }, + { + c: 'chr8', + x: 11038451.111880766, + xe: 4748346.933347768, + y: 34957160.089364834, + ye: 76471096.0601551, + v: 0.058053337449467834 + }, + { + c: 'chr8', + x: 96805338.77079184, + xe: 60719220.91123767, + y: 32054696.727947347, + ye: 38924049.1336978, + v: 0.050842597516658206 + }, + { + c: 'chr8', + x: 22289661.712699328, + xe: 140291176.7609039, + y: 82463729.8582281, + ye: 26467890.030012097, + v: 0.9809633479637582 + }, + { + c: 'chr8', + x: 93415466.14329435, + xe: 95686389.12048608, + y: 22644880.68055917, + ye: 93952666.02642778, + v: 0.8807955095127046 + }, + { + c: 'chr8', + x: 22954818.412010588, + xe: 4756271.4977839785, + y: 84084743.90676367, + ye: 66439308.00326079, + v: 0.29093754287563234 + }, + { + c: 'chr8', + x: 51980552.4366571, + xe: 11304630.432076676, + y: 80330094.07890028, + ye: 87603468.85477301, + v: 0.11686093479956738 + }, + { + c: 'chr8', + x: 46046645.10029466, + xe: 106762822.66784954, + y: 131792136.78170751, + ye: 37070578.450238936, + v: 0.24855340421834682 + }, + { + c: 'chr8', + x: 7109628.478468537, + xe: 90268401.49238299, + y: 58723191.43770793, + ye: 135234746.87690753, + v: 0.058195133529167165 + }, + { + c: 'chr8', + x: 53423335.94898539, + xe: 35144948.409363195, + y: 81303599.41879371, + ye: 79445716.36895679, + v: 0.06561301530436991 + }, + { + c: 'chr8', + x: 128410614.88201064, + xe: 98408839.27091837, + y: 120687886.65048987, + ye: 37710015.29813272, + v: 0.9772941357900097 + }, + { + c: 'chr8', + x: 10462397.587683132, + xe: 82758250.72834753, + y: 125095396.99180932, + ye: 24411857.34844117, + v: 0.058662812324407176 + }, + { + c: 'chr8', + x: 115592673.27244559, + xe: 11504533.254537413, + y: 80946177.76201504, + ye: 99756889.48070596, + v: 0.2562118471711725 + }, + { + c: 'chr8', + x: 45905711.98077308, + xe: 116779232.93170679, + y: 71046245.62164973, + ye: 1271623.5466910219, + v: 0.8137727332199571 + }, + { + c: 'chr8', + x: 19706146.905129213, + xe: 15668301.833611326, + y: 109167442.53061122, + ye: 7532015.583936489, + v: 0.8898878788315382 + }, + { + c: 'chr8', + x: 43613855.62151475, + xe: 133583330.19572502, + y: 106321324.20337465, + ye: 30392842.12308258, + v: 0.2118018942527643 + }, + { + c: 'chr8', + x: 63592384.85638554, + xe: 6380954.918157619, + y: 133567654.16017033, + ye: 73107498.14224581, + v: 0.03055977277765065 + }, + { + c: 'chr8', + x: 31130442.753472127, + xe: 123819462.12639797, + y: 28369071.515285794, + ye: 87019471.85894194, + v: 0.9795673860162986 + }, + { + c: 'chr8', + x: 118946200.18684748, + xe: 21107422.729009263, + y: 67437784.03596869, + ye: 30082595.717111774, + v: 0.5230926143608947 + }, + { + c: 'chr8', + x: 17324961.593641292, + xe: 64917729.24494801, + y: 132495575.50308476, + ye: 144829445.98128667, + v: 0.6798469647654412 + }, + { + c: 'chr8', + x: 53492158.2499894, + xe: 481078.5086132212, + y: 31479856.90881318, + ye: 30822068.57368539, + v: 0.13664949781353675 + }, + { + c: 'chr8', + x: 56773572.56160189, + xe: 64880105.09290392, + y: 11730832.20015477, + ye: 121292900.20518042, + v: 0.4061377198358249 + }, + { + c: 'chr8', + x: 107152771.8790053, + xe: 103393451.47349614, + y: 137116882.7112983, + ye: 72236947.66697438, + v: 0.505041494169177 + }, + { + c: 'chr8', + x: 27128832.852436654, + xe: 101343837.58144908, + y: 30278330.548107583, + ye: 24131489.119098794, + v: 0.9535317781492895 + }, + { + c: 'chr8', + x: 142717108.44492182, + xe: 100271658.21547441, + y: 43403424.116368674, + ye: 79359469.79777884, + v: 0.4848686702385244 + }, + { + c: 'chr8', + x: 36277405.60242524, + xe: 10163442.604459492, + y: 22641392.730895456, + ye: 103294347.13245158, + v: 0.38563658896956876 + }, + { + c: 'chr8', + x: 110298994.24010871, + xe: 105694006.32229525, + y: 133241752.75391507, + ye: 100569316.07698715, + v: 0.9456092431592507 + }, + { + c: 'chr8', + x: 76334544.46396726, + xe: 35221769.755458385, + y: 19717567.07162784, + ye: 132705821.31633784, + v: 0.12509095283196792 + }, + { + c: 'chr8', + x: 64305447.37403282, + xe: 51322418.85261882, + y: 1106342.8483138313, + ye: 21152826.76654331, + v: 0.8571365138437915 + }, + { + c: 'chr8', + x: 116258790.90202942, + xe: 34607187.47881362, + y: 54576337.366198905, + ye: 7980610.402064041, + v: 0.6077053575364798 + }, + { + c: 'chr8', + x: 78923473.45440371, + xe: 2393104.6393636204, + y: 4400781.4994553365, + ye: 34504936.05965125, + v: 0.32237092695253333 + }, + { + c: 'chr8', + x: 137184295.28585783, + xe: 60840440.37489961, + y: 15554088.982730065, + ye: 54362532.03722195, + v: 0.34484546525107884 + }, + { + c: 'chr8', + x: 44408175.908012755, + xe: 25237258.629798643, + y: 66870074.91644609, + ye: 45624131.13797556, + v: 0.1996891723445391 + }, + { + c: 'chr8', + x: 8739833.96782615, + xe: 5763434.459116893, + y: 143188789.55011466, + ye: 52841760.50436476, + v: 0.6849762240239285 + }, + { + c: 'chr8', + x: 86422342.20669656, + xe: 137246052.38277307, + y: 32573368.807402454, + ye: 141302144.83726978, + v: 0.03462631430132623 + }, + { + c: 'chr8', + x: 87267243.22104019, + xe: 131606964.93915929, + y: 73344524.04219782, + ye: 58511091.4070913, + v: 0.16111142332891126 + } ], chromosomeField: 'c', genomicFields: ['x', 'xe', 'y', 'ye'] }, mark: 'point', - x: { field: 'x', type: 'genomic' }, + x: { field: 'x', type: 'genomic', axis: 'top' }, xe: { field: 'xe', type: 'genomic' }, - y: { field: 'y', type: 'genomic' }, + y: { field: 'y', type: 'genomic', axis: 'left' }, ye: { field: 'ye', type: 'genomic' }, size: { field: 'v', type: 'quantitative', range: [1, 4] }, stroke: { value: 'white' }, From 0a98c43cd1af0a8e4cda764e00abed965c136714 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 3 Jul 2024 18:20:54 -0400 Subject: [PATCH 113/139] fix: multiple same linkingId --- demo/renderer/linkedEncoding.test.ts | 66 ++++++++++++++++++++++++++++ demo/renderer/linkedEncoding.ts | 35 ++++++++++++--- 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/demo/renderer/linkedEncoding.test.ts b/demo/renderer/linkedEncoding.test.ts index 1d157bcd1..281862d58 100644 --- a/demo/renderer/linkedEncoding.test.ts +++ b/demo/renderer/linkedEncoding.test.ts @@ -192,6 +192,72 @@ describe('Link tracks', () => { ] `); }); + it('same linkingId across multiple views', () => { + const linkingTest = { + views: [ + { + linkingId: "link", + tracks: [ + { + id: 'track-1', + mark: 'line', + x: { field: 'position', type: 'genomic', axis: 'bottom' }, + y: { field: 'peak', type: 'quantitative', axis: 'right' } + } + ] + }, + { + linkingId: "link", + tracks: [ + { + id: 'track-2', + mark: 'line', + x: { field: 'position', type: 'genomic', axis: 'bottom' }, + y: { field: 'peak', type: 'quantitative', axis: 'right' } + } + ] + }, + { + linkingId: "link", + tracks: [ + { + id: 'track-3', + mark: 'line', + x: { field: 'position', type: 'genomic', axis: 'bottom' }, + y: { field: 'peak', type: 'quantitative', axis: 'right' } + }, + ] + } + ] + }; + // Test case 1 + const result1 = getLinkedEncodings(linkingTest); + expect(result1).toMatchInlineSnapshot(` + [ + { + "linkingId": "link", + "signal": [ + 0, + 3088269832, + ], + "tracks": [ + { + "encoding": "x", + "id": "track-1", + }, + { + "encoding": "x", + "id": "track-2", + }, + { + "encoding": "x", + "id": "track-3", + }, + ], + }, + ] + `); + }); it('domain in x encoding', () => { // When there is a domain in the x encoding we expect it to be used as the signal diff --git a/demo/renderer/linkedEncoding.ts b/demo/renderer/linkedEncoding.ts index 40e122279..26dc02da1 100644 --- a/demo/renderer/linkedEncoding.ts +++ b/demo/renderer/linkedEncoding.ts @@ -126,6 +126,20 @@ function getLinkedTracks(linkingId: string | undefined, trackLinks: TrackLink[]) * Traverses the gosling spec to find all the linked tracks and brushes */ function getLinkedFeaturesRecursive(gs: GoslingSpec): LinkInfo { + // Helper function to merge view links which have the same linkingId + function mergeViewLinks(existing: ViewLink[], newLinks: ViewLink[]) { + newLinks.forEach(newLink => { + const existingLink = existing.find( + link => link.linkingId !== undefined && link.linkingId === newLink.linkingId + ); + if (existingLink) { + existingLink.trackIds.push(...newLink.trackIds); + } else { + existing.push(newLink); + } + }); + return existing; + } // Base case: single view if (IsSingleView(gs)) { const viewLinks = getSingleViewLinks(gs); @@ -137,7 +151,7 @@ function getLinkedFeaturesRecursive(gs: GoslingSpec): LinkInfo { if (IsMultipleViews(gs)) { gs.views.forEach(view => { const newLinks = getLinkedFeaturesRecursive(view); - linked.viewLinks.push(...newLinks.viewLinks); + linked.viewLinks = mergeViewLinks(linked.viewLinks, newLinks.viewLinks); linked.trackLinks.push(...newLinks.trackLinks); }); } @@ -191,7 +205,7 @@ function getSingleViewTrackLinks(gs: SingleView): TrackLink[] { trackLinks.push(trackLink); } // Handle x domain - if (hasDiffXDomainThanView(track, assembly, viewXDomain)) { + if (hasDiffXDomainThanView(gs, track, assembly, viewXDomain)) { if (track.mark === 'brush') { console.warn('Track with brush mark should only be used as an overlay'); return; @@ -228,6 +242,7 @@ function getSingleViewTrackLinks(gs: SingleView): TrackLink[] { * Links all of the tracks in a single view together */ function getSingleViewLinks(gs: SingleView): ViewLink[] { + console.warn('got single view', gs); function addLinkY(tracks: Track[], viewYDomain: [number, number]): ViewLink { const viewLinkY: ViewLink = { linkingId: undefined, @@ -260,7 +275,7 @@ function getSingleViewLinks(gs: SingleView): ViewLink[] { // Add each track to the link tracks.forEach(track => { // If the track is already linked to something else, we don't need to add it again - if (hasDiffXDomainThanView(track, assembly, viewXDomain)) return; + if (hasDiffXDomainThanView(gs, track, assembly, viewXDomain)) return; const hasOverlaidTracks = '_overlay' in track; // Add overlaid brush tracks to the link if (hasOverlaidTracks) { @@ -280,14 +295,20 @@ function getSingleViewLinks(gs: SingleView): ViewLink[] { const xLink = addLinkX(tracks, assembly, viewXDomain); const yLink = addLinkY(tracks, viewYDomain); - console.warn('ylink', yLink); return [xLink, yLink].filter(link => link.trackIds.length > 0); } -function hasDiffXDomainThanView(track: Track, assembly: Assembly | undefined, viewXDomain: [number, number]) { - // If the track has a linkingId, it def has a different domain than the view +function hasDiffXDomainThanView( + view: SingleView, + track: Track, + assembly: Assembly | undefined, + viewXDomain: [number, number] +) { + // If the track x has a linkingId which is different from the viewLinkingId, then it has a different domain const hasLinkingId = 'x' in track && track.x && 'linkingId' in track.x && track.x?.linkingId !== undefined; - if (hasLinkingId) return true; + const viewLinkingId = view.linkingId; + const trackLinkingId = track.x?.linkingId; + if (hasLinkingId && viewLinkingId !== trackLinkingId) return true; // If the x encoding as a domain, we need to check whether it is different than the view const hasXEncodingDomain = 'x' in track && track.x && 'domain' in track.x && track.x?.domain !== undefined; From e6a8166bff475593dd3008afc2fc277bfdfdeef4 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 3 Jul 2024 18:21:02 -0400 Subject: [PATCH 114/139] update matrix --- editor/example/json-spec/matrix-hffc6.ts | 251 ++++++++++------------- 1 file changed, 108 insertions(+), 143 deletions(-) diff --git a/editor/example/json-spec/matrix-hffc6.ts b/editor/example/json-spec/matrix-hffc6.ts index f89f3fad1..22a153593 100644 --- a/editor/example/json-spec/matrix-hffc6.ts +++ b/editor/example/json-spec/matrix-hffc6.ts @@ -14,6 +14,7 @@ export const EX_SPEC_MATRIX_HFFC6: GoslingSpec = { yOffset: 75, views: [ { + linkingId: 'matrix-y', tracks: [ { data: { @@ -63,13 +64,17 @@ export const EX_SPEC_MATRIX_HFFC6: GoslingSpec = { mark: 'bar', x: { field: 'start', type: 'genomic' }, xe: { field: 'end', type: 'genomic' }, - y: { field: 'peak', type: 'quantitative', axis: 'none' }, + y: { + field: 'peak', + type: 'quantitative', + axis: 'none' + }, color: { value: '#0072B2' } }, { style: { backgroundOpacity: 0 }, data: { - url: GOSLING_PUBLIC_DATA.geneAnnotation, + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', type: 'beddb', genomicFields: [ { index: 1, name: 'start' }, @@ -86,14 +91,18 @@ export const EX_SPEC_MATRIX_HFFC6: GoslingSpec = { size: { value: 13 }, stroke: { value: 'white' }, strokeWidth: { value: 1 }, - row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, + row: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'] + }, color: { value: '#CB7AA7' } }, { style: { backgroundOpacity: 0 }, title: 'HFFC6_CTCF', data: { - url: GOSLING_PUBLIC_DATA.geneAnnotation, + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', type: 'beddb', genomicFields: [ { index: 1, name: 'start' }, @@ -110,7 +119,11 @@ export const EX_SPEC_MATRIX_HFFC6: GoslingSpec = { size: { value: 13 }, stroke: { value: 'white' }, strokeWidth: { value: 1 }, - row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, + row: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'] + }, color: { value: '#029F73' } } ], @@ -142,7 +155,11 @@ export const EX_SPEC_MATRIX_HFFC6: GoslingSpec = { mark: 'bar', x: { field: 'start', type: 'genomic', axis: 'top' }, xe: { field: 'end', type: 'genomic' }, - y: { field: 'peak', type: 'quantitative', axis: 'none' }, + y: { + field: 'peak', + type: 'quantitative', + axis: 'none' + }, color: { value: 'darkgreen' }, width: 570, height: 40 @@ -159,7 +176,11 @@ export const EX_SPEC_MATRIX_HFFC6: GoslingSpec = { mark: 'bar', x: { field: 'start', type: 'genomic' }, xe: { field: 'end', type: 'genomic' }, - y: { field: 'peak', type: 'quantitative', axis: 'none' }, + y: { + field: 'peak', + type: 'quantitative', + axis: 'none' + }, color: { value: '#E79F00' }, width: 600, height: 40 @@ -178,13 +199,17 @@ export const EX_SPEC_MATRIX_HFFC6: GoslingSpec = { mark: 'bar', x: { field: 'start', type: 'genomic' }, xe: { field: 'end', type: 'genomic' }, - y: { field: 'peak', type: 'quantitative', axis: 'none' }, + y: { + field: 'peak', + type: 'quantitative', + axis: 'none' + }, color: { value: '#0072B2' } }, { style: { backgroundOpacity: 0 }, data: { - url: GOSLING_PUBLIC_DATA.geneAnnotation, + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', type: 'beddb', genomicFields: [ { index: 1, name: 'start' }, @@ -201,14 +226,18 @@ export const EX_SPEC_MATRIX_HFFC6: GoslingSpec = { size: { value: 13 }, stroke: { value: 'white' }, strokeWidth: { value: 1 }, - row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, + row: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'] + }, color: { value: '#CB7AA7' } }, { style: { backgroundOpacity: 0 }, title: 'HFFC6_CTCF', data: { - url: GOSLING_PUBLIC_DATA.geneAnnotation, + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', type: 'beddb', genomicFields: [ { index: 1, name: 'start' }, @@ -225,7 +254,11 @@ export const EX_SPEC_MATRIX_HFFC6: GoslingSpec = { stroke: { value: 'white' }, strokeWidth: { value: 1 }, size: { value: 13 }, - row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, + row: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'] + }, color: { value: '#029F73' } } ], @@ -239,15 +272,24 @@ export const EX_SPEC_MATRIX_HFFC6: GoslingSpec = { { title: 'HFFc6_Micro-C', data: { - url: GOSLING_PUBLIC_DATA.matrixMicroC, + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=hffc6-microc-hg38', type: 'matrix' }, mark: 'bar', x: { field: 'xs', type: 'genomic', axis: 'none' }, xe: { field: 'xe', type: 'genomic', axis: 'none' }, - y: { field: 'ys', type: 'genomic', axis: 'none' }, + y: { + field: 'ys', + type: 'genomic', + axis: 'none', + linkingId: 'matrix-y' + }, ye: { field: 'ye', type: 'genomic', axis: 'none' }, - color: { field: 'value', type: 'quantitative', range: 'warm' }, + color: { + field: 'value', + type: 'quantitative', + range: 'warm' + }, width: 600, height: 600 } @@ -258,7 +300,7 @@ export const EX_SPEC_MATRIX_HFFC6: GoslingSpec = { { title: 'Epilogos (hg38)', data: { - url: GOSLING_PUBLIC_DATA.epilogos, + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=epilogos-hg38', type: 'multivec', row: 'category', column: 'position', @@ -286,7 +328,11 @@ export const EX_SPEC_MATRIX_HFFC6: GoslingSpec = { mark: 'bar', x: { field: 'start', type: 'genomic', axis: 'none' }, xe: { field: 'end', type: 'genomic' }, - y: { field: 'value', type: 'quantitative', axis: 'none' }, + y: { + field: 'value', + type: 'quantitative', + axis: 'none' + }, color: { field: 'category', type: 'nominal', @@ -308,7 +354,6 @@ export const EX_SPEC_MATRIX_HFFC6: GoslingSpec = { 'gray' ] }, - // strokeWidth: {value: 0.5}, width: 600, height: 40 } @@ -334,7 +379,11 @@ export const EX_SPEC_MATRIX_HFFC6: GoslingSpec = { mark: 'bar', x: { field: 'start', type: 'genomic', axis: 'top' }, xe: { field: 'end', type: 'genomic' }, - y: { field: 'peak', type: 'quantitative', axis: 'none' }, + y: { + field: 'peak', + type: 'quantitative', + axis: 'none' + }, color: { value: 'darkgreen' }, width: 600, height: 40 @@ -351,7 +400,11 @@ export const EX_SPEC_MATRIX_HFFC6: GoslingSpec = { mark: 'bar', x: { field: 'start', type: 'genomic' }, xe: { field: 'end', type: 'genomic' }, - y: { field: 'peak', type: 'quantitative', axis: 'none' }, + y: { + field: 'peak', + type: 'quantitative', + axis: 'none' + }, color: { value: '#E79F00' }, width: 600, height: 40 @@ -370,13 +423,17 @@ export const EX_SPEC_MATRIX_HFFC6: GoslingSpec = { mark: 'bar', x: { field: 'start', type: 'genomic' }, xe: { field: 'end', type: 'genomic' }, - y: { field: 'peak', type: 'quantitative', axis: 'none' }, + y: { + field: 'peak', + type: 'quantitative', + axis: 'none' + }, color: { value: '#0072B2' } }, { style: { backgroundOpacity: 0 }, data: { - url: GOSLING_PUBLIC_DATA.geneAnnotation, + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', type: 'beddb', genomicFields: [ { index: 1, name: 'start' }, @@ -393,14 +450,18 @@ export const EX_SPEC_MATRIX_HFFC6: GoslingSpec = { size: { value: 13 }, stroke: { value: 'white' }, strokeWidth: { value: 1 }, - row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, + row: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'] + }, color: { value: '#CB7AA7' } }, { style: { backgroundOpacity: 0 }, title: 'HFFC6_CTCF', data: { - url: GOSLING_PUBLIC_DATA.geneAnnotation, + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=gene-annotation', type: 'beddb', genomicFields: [ { index: 1, name: 'start' }, @@ -417,7 +478,11 @@ export const EX_SPEC_MATRIX_HFFC6: GoslingSpec = { size: { value: 13 }, stroke: { value: 'white' }, strokeWidth: { value: 1 }, - row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, + row: { + field: 'strand', + type: 'nominal', + domain: ['+', '-'] + }, color: { value: '#029F73' } } ], @@ -431,15 +496,24 @@ export const EX_SPEC_MATRIX_HFFC6: GoslingSpec = { { title: 'HFFc6_Hi-C', data: { - url: GOSLING_PUBLIC_DATA.matrixHiC, + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=hffc6-hic-hg38', type: 'matrix' }, mark: 'bar', x: { field: 'xs', type: 'genomic', axis: 'none' }, xe: { field: 'xe', type: 'genomic', axis: 'none' }, - y: { field: 'ys', type: 'genomic', axis: 'none' }, + y: { + field: 'ys', + type: 'genomic', + axis: 'none', + linkingId: 'matrix-y' + }, ye: { field: 'ye', type: 'genomic', axis: 'none' }, - color: { field: 'value', type: 'quantitative', range: 'warm' }, + color: { + field: 'value', + type: 'quantitative', + range: 'warm' + }, width: 600, height: 600 } @@ -450,7 +524,7 @@ export const EX_SPEC_MATRIX_HFFC6: GoslingSpec = { { title: 'Epilogos (hg38)', data: { - url: GOSLING_PUBLIC_DATA.epilogos, + url: 'https://server.gosling-lang.org/api/v1/tileset_info/?d=epilogos-hg38', type: 'multivec', row: 'category', column: 'position', @@ -478,7 +552,11 @@ export const EX_SPEC_MATRIX_HFFC6: GoslingSpec = { mark: 'bar', x: { field: 'start', type: 'genomic', axis: 'none' }, xe: { field: 'end', type: 'genomic' }, - y: { field: 'value', type: 'quantitative', axis: 'none' }, + y: { + field: 'value', + type: 'quantitative', + axis: 'none' + }, color: { field: 'category', type: 'nominal', @@ -500,7 +578,6 @@ export const EX_SPEC_MATRIX_HFFC6: GoslingSpec = { 'gray' ] }, - // strokeWidth: {value: 0.5}, width: 600, height: 40 } @@ -510,118 +587,6 @@ export const EX_SPEC_MATRIX_HFFC6: GoslingSpec = { } ] } - // { - // orientation: 'vertical', - // yOffset: 75, - // views: [ - // { - // tracks: [ - // { - // data: { - // url: 'https://s3.amazonaws.com/gosling-lang.org/data/HFFc6_H3K4me3.bigWig', - // type: 'bigwig', - // column: 'position', - // value: 'peak', - // binSize: 8 - // }, - // title: 'HFFc6_H3K4me3', - // mark: 'bar', - // x: { field: 'start', type: 'genomic', axis: 'none' }, - // xe: { field: 'end', type: 'genomic' }, - // y: { field: 'peak', type: 'quantitative', axis: 'none' }, - // color: { value: 'darkgreen' }, - // height: 600, - // width: 40 - // }, - // { - // data: { - // url: 'https://s3.amazonaws.com/gosling-lang.org/data/HFFc6_Atacseq.mRp.clN.bigWig', - // type: 'bigwig', - // column: 'position', - // value: 'peak', - // binSize: 8 - // }, - // title: 'HFFc6_ATAC', - // mark: 'bar', - // x: { field: 'start', type: 'genomic' }, - // xe: { field: 'end', type: 'genomic' }, - // y: { field: 'peak', type: 'quantitative', axis: 'none' }, - // color: { value: '#E79F00' }, - // height: 600, - // width: 40 - // }, - // { - // alignment: 'overlay', - // tracks: [ - // { - // data: { - // url: 'https://s3.amazonaws.com/gosling-lang.org/data/HFFC6_CTCF.mRp.clN.bigWig', - // type: 'bigwig', - // column: 'position', - // value: 'peak', - // binSize: 8 - // }, - // mark: 'bar', - // x: { field: 'start', type: 'genomic', axis: 'bottom' }, - // xe: { field: 'end', type: 'genomic' }, - // y: { field: 'peak', type: 'quantitative', axis: 'none' }, - // color: { value: '#0072B2' } - // }, - // { - // style: { backgroundOpacity: 0 }, - // data: { - // url: GOSLING_PUBLIC_DATA.geneAnnotation, - // type: 'beddb', - // genomicFields: [ - // { index: 1, name: 'start' }, - // { index: 2, name: 'end' } - // ], - // valueFields: [ - // { index: 5, name: 'strand', type: 'nominal' }, - // { index: 3, name: 'name', type: 'nominal' } - // ] - // }, - // dataTransform: [{ type: 'filter', field: 'strand', oneOf: ['+'] }], - // mark: 'triangleRight', - // x: { field: 'start', type: 'genomic' }, - // size: { value: 13 }, - // stroke: { value: 'white' }, - // strokeWidth: { value: 1 }, - // row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, - // color: { value: '#CB7AA7' } - // }, - // { - // style: { backgroundOpacity: 0 }, - // title: 'HFFC6_CTCF', - // data: { - // url: GOSLING_PUBLIC_DATA.geneAnnotation, - // type: 'beddb', - // genomicFields: [ - // { index: 1, name: 'start' }, - // { index: 2, name: 'end' } - // ], - // valueFields: [ - // { index: 5, name: 'strand', type: 'nominal' }, - // { index: 3, name: 'name', type: 'nominal' } - // ] - // }, - // dataTransform: [{ type: 'filter', field: 'strand', oneOf: ['-'] }], - // mark: 'triangleLeft', - // x: { field: 'start', type: 'genomic' }, - // size: { value: 13 }, - // stroke: { value: 'white' }, - // strokeWidth: { value: 1 }, - // row: { field: 'strand', type: 'nominal', domain: ['+', '-'] }, - // color: { value: '#029F73' } - // } - // ], - // height: 600, - // width: 40 - // } - // ] - // }, - // ] - // }, ], style: { outlineWidth: 0, background: '#F6F6F6' } }; From cd62c05fe8d33a9270af46a6472971206a195960 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Wed, 3 Jul 2024 18:50:42 -0400 Subject: [PATCH 115/139] reorganize files --- demo/App.tsx | 4 +- demo/GoslingComponent.tsx | 5 +- .../linkedEncoding.test.ts | 0 demo/{renderer => linking}/linkedEncoding.ts | 4 +- demo/renderer/main.ts | 110 ++---------------- demo/{renderer => track-def}/axis.ts | 1 - demo/{renderer => track-def}/brushLinear.ts | 0 demo/{renderer => track-def}/gosling.ts | 2 +- demo/{renderer => track-def}/heatmap.ts | 0 demo/track-def/main.ts | 99 ++++++++++++++++ demo/{renderer => track-def}/text.ts | 0 11 files changed, 116 insertions(+), 109 deletions(-) rename demo/{renderer => linking}/linkedEncoding.test.ts (100%) rename demo/{renderer => linking}/linkedEncoding.ts (99%) rename demo/{renderer => track-def}/axis.ts (99%) rename demo/{renderer => track-def}/brushLinear.ts (100%) rename demo/{renderer => track-def}/gosling.ts (97%) rename demo/{renderer => track-def}/heatmap.ts (100%) create mode 100644 demo/track-def/main.ts rename demo/{renderer => track-def}/text.ts (100%) diff --git a/demo/App.tsx b/demo/App.tsx index e91e41dd9..a27fe21af 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -17,10 +17,10 @@ import { getTheme } from '../src/core/utils/theme'; import './App.css'; import type { HiGlassSpec } from '@gosling-lang/higlass-schema'; -import { createTrackDefs, renderTrackDefs, showTrackInfoPositions } from './renderer/main'; +import { createTrackDefs, renderTrackDefs, showTrackInfoPositions } from './track-def/main'; import type { TrackInfo } from 'src/compiler/bounding-box'; import type { GoslingSpec } from 'gosling.js'; -import { getLinkedEncodings } from './renderer/linkedEncoding'; +import { getLinkedEncodings } from './linking/linkedEncoding'; import { GoslingComponent } from './GoslingComponent'; function App() { diff --git a/demo/GoslingComponent.tsx b/demo/GoslingComponent.tsx index 70d8162a8..3befc10a2 100644 --- a/demo/GoslingComponent.tsx +++ b/demo/GoslingComponent.tsx @@ -5,10 +5,11 @@ import { compile } from '../src/compiler/compile'; import { getTheme } from '../src/core/utils/theme'; import type { HiGlassSpec } from '@gosling-lang/higlass-schema'; -import { createTrackDefs, renderTrackDefs, showTrackInfoPositions } from './renderer/main'; +import { createTrackDefs } from './track-def/main'; +import { renderTrackDefs } from './renderer/main'; import type { TrackInfo } from 'src/compiler/bounding-box'; import type { GoslingSpec } from 'gosling.js'; -import { getLinkedEncodings } from './renderer/linkedEncoding'; +import { getLinkedEncodings } from './linking/linkedEncoding'; interface GoslingComponentProps { spec: GoslingSpec | undefined; diff --git a/demo/renderer/linkedEncoding.test.ts b/demo/linking/linkedEncoding.test.ts similarity index 100% rename from demo/renderer/linkedEncoding.test.ts rename to demo/linking/linkedEncoding.test.ts diff --git a/demo/renderer/linkedEncoding.ts b/demo/linking/linkedEncoding.ts similarity index 99% rename from demo/renderer/linkedEncoding.ts rename to demo/linking/linkedEncoding.ts index 26dc02da1..d8a26d1a8 100644 --- a/demo/renderer/linkedEncoding.ts +++ b/demo/linking/linkedEncoding.ts @@ -2,8 +2,8 @@ import { IsMultipleViews, IsSingleView, type Assembly, type SingleView } from '@ import { GenomicPositionHelper, computeChromSizes } from '../../src/core/utils/assembly'; import { signal, type Signal } from '@preact/signals-core'; import type { GoslingSpec } from 'gosling.js'; -import { TrackType } from './main'; -import { isHeatmapTrack } from './heatmap'; +import { TrackType } from '../track-def/main'; +import { isHeatmapTrack } from '../track-def/heatmap'; /** * This is the information needed to link tracks together diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index e0bb676ba..369a94351 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -1,111 +1,19 @@ -import type { PixiManager } from '@pixi-manager'; -import { TextTrack, type TextTrackOptions } from '@gosling-lang/text-track'; -import { type DummyTrackOptions } from '@gosling-lang/dummy-track'; import { GoslingTrack } from '@gosling-lang/gosling-track'; -import { AxisTrack, type AxisTrackOptions } from '@gosling-lang/genomic-axis'; -import { BrushLinearTrack, type BrushLinearTrackOptions } from '@gosling-lang/brush-linear'; +import { AxisTrack } from '@gosling-lang/genomic-axis'; +import { BrushLinearTrack } from '@gosling-lang/brush-linear'; import { Signal } from '@preact/signals-core'; +import { TextTrack } from '@gosling-lang/text-track'; import { panZoom, panZoomHeatmap } from '@gosling-lang/interactors'; -import type { TrackInfo } from '../../src/compiler/bounding-box'; -import type { CompleteThemeDeep } from '../../src/core/utils/theme'; -import type { GoslingTrackOptions } from '../../src/tracks/gosling-track/gosling-track'; - -import { proccessTextHeader } from './text'; -import { processHeatmapTrack, isHeatmapTrack } from './heatmap'; -import { processGoslingTrack } from './gosling'; +import { type TrackDefs, TrackType } from '../track-def/main'; import { getDataFetcher } from './dataFetcher'; -import type { LinkedEncoding } from './linkedEncoding'; -import { BrushCircularTrack, type BrushCircularTrackOptions } from '@gosling-lang/brush-circular'; -import { type HeatmapTrackOptions, HeatmapTrack } from '@gosling-lang/heatmap'; - -/** - * All the different types of tracks that can be rendered - */ -export enum TrackType { - Text, - Dummy, - Gosling, - Axis, - BrushLinear, - BrushCircular, - Heatmap -} - -/** - * Associate options to each track type - */ -interface TrackOptionsMap { - [TrackType.Text]: TextTrackOptions; - [TrackType.Dummy]: DummyTrackOptions; - [TrackType.Gosling]: GoslingTrackOptions; - [TrackType.Axis]: AxisTrackOptions; - [TrackType.BrushLinear]: BrushLinearTrackOptions; - [TrackType.BrushCircular]: BrushCircularTrackOptions; - [TrackType.Heatmap]: HeatmapTrackOptions; -} - -/** - * This interface contains all of the information needed to render each track type. - */ -export interface TrackDef { - type: TrackType; - trackId: string; - boundingBox: { x: number; y: number; width: number; height: number }; - options: T; -} - -/** - * This is a union of all the different TrackDefs - */ -type TrackDefs = { - [K in keyof TrackOptionsMap]: TrackDef; -}[keyof TrackOptionsMap]; - -/** - * This function is for internal testing. It will render a red border around each track - */ -export function showTrackInfoPositions(trackInfos: TrackInfo[], pixiManager: PixiManager) { - trackInfos.forEach(trackInfo => { - const { track, boundingBox } = trackInfo; - const div = pixiManager.makeContainer(boundingBox).overlayDiv; - div.style.border = '3px solid red'; - div.innerHTML = track.mark || 'No mark'; - div.style.textAlign = 'left'; - }); -} - -/** - * Takes a list of TrackInfos and returns a list of TrackDefs - * @param trackInfos - * @param pixiManager - * @param theme - * @returns - */ -export function createTrackDefs(trackInfos: TrackInfo[], theme: Required): TrackDefs[] { - const trackDefs: TrackDefs[] = []; - trackInfos.forEach(trackInfo => { - const { track, boundingBox } = trackInfo; - - if (track.mark === '_header') { - // Header marks contain both the title and subtitle - const textTrackDefs = proccessTextHeader(track, boundingBox, theme); - trackDefs.push(...textTrackDefs); - } else if (isHeatmapTrack(track)) { - // We have a heatmap track - const heatmapTrackDefs = processHeatmapTrack(track, boundingBox, theme); - trackDefs.push(...heatmapTrackDefs); - } else { - // We have a gosling track - const goslingAxisDefs = processGoslingTrack(track, boundingBox, theme); - trackDefs.push(...goslingAxisDefs); - } - }); - return trackDefs; -} +import type { LinkedEncoding } from '../linking/linkedEncoding'; +import { BrushCircularTrack } from '@gosling-lang/brush-circular'; +import { HeatmapTrack } from '@gosling-lang/heatmap'; +import type { PixiManager } from '@pixi-manager'; /** - * Takes a list of track options and renders them on the screen + * Takes a list of track definitions and linkedEncodings and renders them * @param trackOptions * @param pixiManager */ diff --git a/demo/renderer/axis.ts b/demo/track-def/axis.ts similarity index 99% rename from demo/renderer/axis.ts rename to demo/track-def/axis.ts index 6b12f8776..0da8058d1 100644 --- a/demo/renderer/axis.ts +++ b/demo/track-def/axis.ts @@ -3,7 +3,6 @@ import { IsChannelDeep, IsDummyTrack, IsTemplateTrack, - IsXAxis, type AxisPosition, type OverlaidTrack, type SingleTrack, diff --git a/demo/renderer/brushLinear.ts b/demo/track-def/brushLinear.ts similarity index 100% rename from demo/renderer/brushLinear.ts rename to demo/track-def/brushLinear.ts diff --git a/demo/renderer/gosling.ts b/demo/track-def/gosling.ts similarity index 97% rename from demo/renderer/gosling.ts rename to demo/track-def/gosling.ts index 6af1cfb40..6c1ec9841 100644 --- a/demo/renderer/gosling.ts +++ b/demo/track-def/gosling.ts @@ -1,6 +1,6 @@ import { type AxisTrackOptions } from '@gosling-lang/genomic-axis'; -import { type SingleTrack, type Track } from '@gosling-lang/gosling-schema'; +import { type Track } from '@gosling-lang/gosling-schema'; import type { CompleteThemeDeep } from '../../src/core/utils/theme'; import type { GoslingTrackOptions } from '@gosling-lang/gosling-track'; diff --git a/demo/renderer/heatmap.ts b/demo/track-def/heatmap.ts similarity index 100% rename from demo/renderer/heatmap.ts rename to demo/track-def/heatmap.ts diff --git a/demo/track-def/main.ts b/demo/track-def/main.ts new file mode 100644 index 000000000..794556651 --- /dev/null +++ b/demo/track-def/main.ts @@ -0,0 +1,99 @@ +import type { PixiManager } from '@pixi-manager'; +import { type TextTrackOptions } from '@gosling-lang/text-track'; +import { type DummyTrackOptions } from '@gosling-lang/dummy-track'; +import { type AxisTrackOptions } from '@gosling-lang/genomic-axis'; +import { type BrushLinearTrackOptions } from '@gosling-lang/brush-linear'; +import type { TrackInfo } from '../../src/compiler/bounding-box'; +import type { CompleteThemeDeep } from '../../src/core/utils/theme'; +import type { GoslingTrackOptions } from '../../src/tracks/gosling-track/gosling-track'; + +import { proccessTextHeader } from './text'; +import { processHeatmapTrack, isHeatmapTrack } from './heatmap'; +import { processGoslingTrack } from './gosling'; +import { type BrushCircularTrackOptions } from '@gosling-lang/brush-circular'; +import { type HeatmapTrackOptions } from '@gosling-lang/heatmap'; + +/** + * All the different types of tracks that can be rendered + */ +export enum TrackType { + Text, + Dummy, + Gosling, + Axis, + BrushLinear, + BrushCircular, + Heatmap +} + +/** + * Associate options to each track type + */ +interface TrackOptionsMap { + [TrackType.Text]: TextTrackOptions; + [TrackType.Dummy]: DummyTrackOptions; + [TrackType.Gosling]: GoslingTrackOptions; + [TrackType.Axis]: AxisTrackOptions; + [TrackType.BrushLinear]: BrushLinearTrackOptions; + [TrackType.BrushCircular]: BrushCircularTrackOptions; + [TrackType.Heatmap]: HeatmapTrackOptions; +} + +/** + * This interface contains all of the information needed to render each track type. + */ +export interface TrackDef { + type: TrackType; + trackId: string; + boundingBox: { x: number; y: number; width: number; height: number }; + options: T; +} + +/** + * This is a union of all the different TrackDefs + */ +export type TrackDefs = { + [K in keyof TrackOptionsMap]: TrackDef; +}[keyof TrackOptionsMap]; + +/** + * Takes a list of TrackInfos and returns a list of TrackDefs + * @param trackInfos + * @param pixiManager + * @param theme + * @returns + */ +export function createTrackDefs(trackInfos: TrackInfo[], theme: Required): TrackDefs[] { + const trackDefs: TrackDefs[] = []; + trackInfos.forEach(trackInfo => { + const { track, boundingBox } = trackInfo; + + if (track.mark === '_header') { + // Header marks contain both the title and subtitle + const textTrackDefs = proccessTextHeader(track, boundingBox, theme); + trackDefs.push(...textTrackDefs); + } else if (isHeatmapTrack(track)) { + // We have a heatmap track + const heatmapTrackDefs = processHeatmapTrack(track, boundingBox, theme); + trackDefs.push(...heatmapTrackDefs); + } else { + // We have a gosling track + const goslingAxisDefs = processGoslingTrack(track, boundingBox, theme); + trackDefs.push(...goslingAxisDefs); + } + }); + return trackDefs; +} + +/** + * This function is for internal testing usage only. It will render a red border around each track + */ +export function showTrackInfoPositions(trackInfos: TrackInfo[], pixiManager: PixiManager) { + trackInfos.forEach(trackInfo => { + const { track, boundingBox } = trackInfo; + const div = pixiManager.makeContainer(boundingBox).overlayDiv; + div.style.border = '3px solid red'; + div.innerHTML = track.mark || 'No mark'; + div.style.textAlign = 'left'; + }); +} \ No newline at end of file diff --git a/demo/renderer/text.ts b/demo/track-def/text.ts similarity index 100% rename from demo/renderer/text.ts rename to demo/track-def/text.ts From 65a2ab83e33761125981d055eb5e58e455890e36 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Sat, 6 Jul 2024 10:22:17 -0400 Subject: [PATCH 116/139] feat: basic resizing --- demo/GoslingComponent.tsx | 99 ++++++++++++++----- src/pixi-manager/pixi-manager.ts | 24 ++++- .../gosling-track/gosling-track-plot.ts | 2 +- 3 files changed, 94 insertions(+), 31 deletions(-) diff --git a/demo/GoslingComponent.tsx b/demo/GoslingComponent.tsx index 3befc10a2..78ab3fb34 100644 --- a/demo/GoslingComponent.tsx +++ b/demo/GoslingComponent.tsx @@ -20,41 +20,86 @@ export function GoslingComponent({ spec, width, height }: GoslingComponentProps) const [fps, setFps] = useState(120); useEffect(() => { - console.warn('got spec', spec); if (!spec) return; const plotElement = document.getElementById('plot') as HTMLDivElement; plotElement.innerHTML = ''; - // Initialize the PixiManager. This will be used to get containers and overlay divs for the plots - const pixiManager = new PixiManager(width, height, plotElement, setFps); - - const callback = ( - hg: HiGlassSpec, - size, - gs: GoslingSpec, - tracksAndViews, - idTable, - trackInfos: TrackInfo[], - theme: Require - ) => { - console.warn(trackInfos); - console.warn(tracksAndViews); - console.warn(gs); - // showTrackInfoPositions(trackInfos, pixiManager); - const linkedEncodings = getLinkedEncodings(gs); - console.warn('linkedEncodings', linkedEncodings); - const trackDefs = createTrackDefs(trackInfos, theme); - console.warn('trackDefs', trackDefs); - renderTrackDefs(trackDefs, linkedEncodings, pixiManager); - }; - - // Compile the spec - compile(spec, callback, [], getTheme('light'), { containerSize: { width: width, height: height } }); + renderGosling(spec, plotElement, width, height); }, [spec]); return (
-
+
); } + +function renderGosling(gs: GoslingSpec, container: HTMLDivElement, width: number, height: number) { + // Initialize the PixiManager. This will be used to get containers and overlay divs for the plots + const pixiManager = new PixiManager(width, height, container, () => {}); + + const callback = ( + hg: HiGlassSpec, + size, + gs: GoslingSpec, + tracksAndViews, + idTable, + trackInfos: TrackInfo[], + theme: Require + ) => { + console.warn(trackInfos); + console.warn(tracksAndViews); + console.warn(gs); + // showTrackInfoPositions(trackInfos, pixiManager); + const linkedEncodings = getLinkedEncodings(gs); + console.warn('linkedEncodings', linkedEncodings); + + const resizeObserver = new ResizeObserver( + debounce(entries => { + const { width, height } = entries[0].contentRect; + // Remove all of the previously drawn overlay divs and tracks + pixiManager.clearAll(); + const rescaledTracks = rescaleTrackInfos(trackInfos, width, height); + const trackDefs = createTrackDefs(rescaledTracks, theme); + renderTrackDefs(trackDefs, linkedEncodings, pixiManager); + // pixiManager.resize(width, height); + }, 300) + ); + resizeObserver.observe(container); + // const trackDefs = createTrackDefs([...trackInfos], theme); + // console.warn('trackDefs', trackDefs); + // renderTrackDefs(trackDefs, linkedEncodings, pixiManager); + }; + + // Compile the spec + compile(gs, callback, [], getTheme('light'), { containerSize: { width: 0, height: 0 } }); +} + +/** Debounces the resize observer */ +function debounce(f: (arg0: unknown) => unknown, delay: number) { + let timer = 0; + return function (...args: [arg0: unknown]) { + clearTimeout(timer); + timer = setTimeout(() => f.apply(this, args), delay); + }; +} + +/** + * This function rescales the bounding boxes of the trackInfos so that they fit within the width and height + */ +function rescaleTrackInfos(trackInfos: TrackInfo[], width: number, height: number): TrackInfo[] { + const maxWidth = Math.max(...trackInfos.map(ti => ti.boundingBox.x + ti.boundingBox.width)); + const scalingFactor = width / maxWidth; + const scaledTrackInfos = trackInfos.map(ti => { + return { + ...ti, + boundingBox: { + x: ti.boundingBox.x * scalingFactor, + y: ti.boundingBox.y * scalingFactor, + width: ti.boundingBox.width * scalingFactor, + height: ti.boundingBox.height * scalingFactor + } + }; + }); + return scaledTrackInfos; +} diff --git a/src/pixi-manager/pixi-manager.ts b/src/pixi-manager/pixi-manager.ts index 676603878..f8b160437 100644 --- a/src/pixi-manager/pixi-manager.ts +++ b/src/pixi-manager/pixi-manager.ts @@ -12,7 +12,11 @@ interface BoundingBox { } export class PixiManager { app: PIXI.Application; - containerElement: HTMLDivElement; + // This contains both the canvas and the overlay container + rootDiv: HTMLDivElement; + // Div which contains all overlay divs + overlayContainer: HTMLDivElement; + // Mapping between position and overlay div so we can reuse overlay divs createdContainers: Map = new Map(); constructor(width: number, height: number, container: HTMLDivElement, fps: (fps: number) => void) { @@ -33,8 +37,10 @@ export class PixiManager { } }); - this.containerElement = container; + this.rootDiv = container; container.appendChild(this.app.view); + this.overlayContainer = document.createElement('div'); + container.appendChild(this.overlayContainer); // Add FPS counter this.app.ticker.add(() => { fps(this.app.ticker.FPS); @@ -61,12 +67,24 @@ export class PixiManager { } else { plotDiv = createOverlayElement(position); this.createdContainers.set(positionString, plotDiv); - this.containerElement.appendChild(plotDiv); + this.overlayContainer.appendChild(plotDiv); } return { pixiContainer: pContainer, overlayDiv: plotDiv }; } + clearAll(): void { + const children = this.app.stage.removeChildren(); + children.forEach(child => { + child.destroy(); + }); + this.createdContainers.forEach(div => { + div.remove(); + }); + this.createdContainers.clear(); + this.overlayContainer.innerHTML = ''; + } + destroy(): void { this.app.destroy(); } diff --git a/src/tracks/gosling-track/gosling-track-plot.ts b/src/tracks/gosling-track/gosling-track-plot.ts index e68e2aa50..2de4668ad 100644 --- a/src/tracks/gosling-track/gosling-track-plot.ts +++ b/src/tracks/gosling-track/gosling-track-plot.ts @@ -29,6 +29,7 @@ export class GoslingTrack extends GoslingTrackClass implements Plot { orientation: 'horizontal' | 'vertical' = 'horizontal' ) { const { pixiContainer, overlayDiv } = containers; + if (!overlayDiv.clientWidth) throw new Error('Container does not have width'); // If there is already an svg element, use it. Otherwise, create a new one // If we do not reuse the same SVG element, we cannot have multiple brushes on the same track. @@ -77,7 +78,6 @@ export class GoslingTrack extends GoslingTrackClass implements Plot { // We move the scene down because the rotation point is the top left corner this.scene.position.set(position.x, position.y); } - this.xDomain = xDomain; this.yDomain = yDomain ?? signal<[number, number]>(xDomain.value); this.domOverlay = overlayDiv; From db148fe420536a9fa10e03ac16442cca9724fe52 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Sat, 6 Jul 2024 10:36:03 -0400 Subject: [PATCH 117/139] refactor no more callback --- demo/GoslingComponent.tsx | 51 +++++++++------------------ src/compiler/compile.ts | 26 +++++++------- src/compiler/create-higlass-models.ts | 15 +++++--- 3 files changed, 41 insertions(+), 51 deletions(-) diff --git a/demo/GoslingComponent.tsx b/demo/GoslingComponent.tsx index 78ab3fb34..3b6131840 100644 --- a/demo/GoslingComponent.tsx +++ b/demo/GoslingComponent.tsx @@ -38,41 +38,24 @@ function renderGosling(gs: GoslingSpec, container: HTMLDivElement, width: number // Initialize the PixiManager. This will be used to get containers and overlay divs for the plots const pixiManager = new PixiManager(width, height, container, () => {}); - const callback = ( - hg: HiGlassSpec, - size, - gs: GoslingSpec, - tracksAndViews, - idTable, - trackInfos: TrackInfo[], - theme: Require - ) => { - console.warn(trackInfos); - console.warn(tracksAndViews); - console.warn(gs); - // showTrackInfoPositions(trackInfos, pixiManager); - const linkedEncodings = getLinkedEncodings(gs); - console.warn('linkedEncodings', linkedEncodings); - - const resizeObserver = new ResizeObserver( - debounce(entries => { - const { width, height } = entries[0].contentRect; - // Remove all of the previously drawn overlay divs and tracks - pixiManager.clearAll(); - const rescaledTracks = rescaleTrackInfos(trackInfos, width, height); - const trackDefs = createTrackDefs(rescaledTracks, theme); - renderTrackDefs(trackDefs, linkedEncodings, pixiManager); - // pixiManager.resize(width, height); - }, 300) - ); - resizeObserver.observe(container); - // const trackDefs = createTrackDefs([...trackInfos], theme); - // console.warn('trackDefs', trackDefs); - // renderTrackDefs(trackDefs, linkedEncodings, pixiManager); - }; - // Compile the spec - compile(gs, callback, [], getTheme('light'), { containerSize: { width: 0, height: 0 } }); + const compileResult = compile(gs, [], getTheme('light'), { containerSize: { width: 0, height: 0 } }); + const { trackInfos, gs: processedSpec, theme } = compileResult; + + // Extract all of the linking information from the spec + const linkedEncodings = getLinkedEncodings(processedSpec); + const resizeObserver = new ResizeObserver( + debounce(entries => { + const { width, height } = entries[0].contentRect; + // Remove all of the previously drawn overlay divs and tracks + pixiManager.clearAll(); + const rescaledTracks = rescaleTrackInfos(trackInfos, width, height); + const trackDefs = createTrackDefs(rescaledTracks, theme); + renderTrackDefs(trackDefs, linkedEncodings, pixiManager); + // pixiManager.resize(width, height); + }, 300) + ); + resizeObserver.observe(container); } /** Debounces the resize observer */ diff --git a/src/compiler/compile.ts b/src/compiler/compile.ts index 9ada6c8e3..d60e0f0e9 100644 --- a/src/compiler/compile.ts +++ b/src/compiler/compile.ts @@ -9,20 +9,19 @@ import { renderHiGlass as createHiGlassModels } from './create-higlass-models'; import { manageResponsiveSpecs } from './responsive'; import type { IdTable } from '../api/track-and-view-ids'; -/** The callback function called everytime after the spec has been compiled */ -export type CompileCallback = ( - hg: HiGlassSpec, - size: Size, - gs: GoslingSpec, - tracksAndViews: VisUnitApiData[], - idTable: IdTable, - trackInfos: TrackInfo[], - theme: Required -) => void; + +interface CompileResult { + hg: HiGlassSpec; + size: Size; + gs: GoslingSpec; + tracksAndViews: VisUnitApiData[]; + idTable: IdTable; + trackInfos: TrackInfo[]; + theme: Required; +} export function compile( spec: GoslingSpec, - callback: CompileCallback, templates: TemplateTrackDef[], theme: Required, containerStatus: { @@ -30,7 +29,7 @@ export function compile( containerParentSize?: { width: number; height: number }; }, urlToFetchOptions?: UrlToFetchOptions -) { +): CompileResult { // Make sure to keep the original spec as-is const specCopy = JSON.parse(JSON.stringify(spec)); @@ -69,5 +68,6 @@ export function compile( } // Make HiGlass models for individual tracks - createHiGlassModels(specCopy, trackInfos, callback, theme, urlToFetchOptions); + const compileResult = createHiGlassModels(specCopy, trackInfos, theme, urlToFetchOptions); + return compileResult; } diff --git a/src/compiler/create-higlass-models.ts b/src/compiler/create-higlass-models.ts index a37b2e43a..ddd5c5560 100644 --- a/src/compiler/create-higlass-models.ts +++ b/src/compiler/create-higlass-models.ts @@ -11,7 +11,6 @@ import type { ViewApiData } from '@gosling-lang/gosling-schema'; import type { CompleteThemeDeep } from '../core/utils/theme'; -import type { CompileCallback } from './compile'; import type { UrlToFetchOptions } from 'src/core/gosling-component'; import { getViewApiData } from '../api/api-data'; import { GoslingToHiGlassIdMapper } from '../api/track-and-view-ids'; @@ -20,13 +19,12 @@ import { IsDummyTrack } from '@gosling-lang/gosling-schema'; export function renderHiGlass( spec: GoslingSpec, trackInfos: TrackInfo[], - callback: CompileCallback, theme: Required, urlToFetchOptions?: UrlToFetchOptions ) { if (trackInfos.length === 0) { // no tracks to render - return; + throw new Error('No tracks to render'); } // HiGlass model @@ -111,5 +109,14 @@ export function renderHiGlass( ...views.map(d => ({ ...d, type: 'view' } as VisUnitApiData)) ]; - callback(hgModel.spec(), getBoundingBox(trackInfos), spec, tracksAndViews, idMapper.getTable(), trackInfos, theme); + const compileResult = { + hg: hgModel.spec(), + size: getBoundingBox(trackInfos), + gs: spec, + tracksAndViews, + idTable: idMapper.getTable(), + trackInfos, + theme + }; + return compileResult; } From 328988aac2ad136cd6832dd2bf52a97d2befa576 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Sat, 6 Jul 2024 12:33:13 -0400 Subject: [PATCH 118/139] resize pixi canvas after rendering --- demo/GoslingComponent.tsx | 47 ++++++++++++++++++++++---------- src/pixi-manager/pixi-manager.ts | 4 +++ 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/demo/GoslingComponent.tsx b/demo/GoslingComponent.tsx index 3b6131840..bfdde3a53 100644 --- a/demo/GoslingComponent.tsx +++ b/demo/GoslingComponent.tsx @@ -29,11 +29,17 @@ export function GoslingComponent({ spec, width, height }: GoslingComponentProps) return (
-
+
); } - +/** + * This is the main function. It takes a Gosling spec and renders it to the container. + * @param gs + * @param container + * @param width + * @param height + */ function renderGosling(gs: GoslingSpec, container: HTMLDivElement, width: number, height: number) { // Initialize the PixiManager. This will be used to get containers and overlay divs for the plots const pixiManager = new PixiManager(width, height, container, () => {}); @@ -44,18 +50,31 @@ function renderGosling(gs: GoslingSpec, container: HTMLDivElement, width: number // Extract all of the linking information from the spec const linkedEncodings = getLinkedEncodings(processedSpec); - const resizeObserver = new ResizeObserver( - debounce(entries => { - const { width, height } = entries[0].contentRect; - // Remove all of the previously drawn overlay divs and tracks - pixiManager.clearAll(); - const rescaledTracks = rescaleTrackInfos(trackInfos, width, height); - const trackDefs = createTrackDefs(rescaledTracks, theme); - renderTrackDefs(trackDefs, linkedEncodings, pixiManager); - // pixiManager.resize(width, height); - }, 300) - ); - resizeObserver.observe(container); + const isResponsiveWidth = + processedSpec.responsiveSize && + typeof processedSpec.responsiveSize === 'object' && + processedSpec.responsiveSize.width; + + if (isResponsiveWidth) { + const resizeObserver = new ResizeObserver( + debounce(entries => { + const { width, height } = entries[0].contentRect; + // Remove all of the previously drawn overlay divs and tracks + pixiManager.clearAll(); + const rescaledTracks = rescaleTrackInfos(trackInfos, width, height); + const trackDefs = createTrackDefs(rescaledTracks, theme); + renderTrackDefs(trackDefs, linkedEncodings, pixiManager); + // pixiManager.resize(width, height); + }, 300) + ); + resizeObserver.observe(container); + } else { + const trackDefs = createTrackDefs(trackInfos, theme); + renderTrackDefs(trackDefs, linkedEncodings, pixiManager); + const maxWidth = Math.max(...trackInfos.map(ti => ti.boundingBox.x + ti.boundingBox.width)); + const maxHeight = Math.max(...trackInfos.map(ti => ti.boundingBox.y + ti.boundingBox.height)); + pixiManager.resize(maxWidth, maxHeight); + } } /** Debounces the resize observer */ diff --git a/src/pixi-manager/pixi-manager.ts b/src/pixi-manager/pixi-manager.ts index f8b160437..21a340e69 100644 --- a/src/pixi-manager/pixi-manager.ts +++ b/src/pixi-manager/pixi-manager.ts @@ -85,6 +85,10 @@ export class PixiManager { this.overlayContainer.innerHTML = ''; } + resize(width: number, height: number): void { + this.app.renderer.resize(width, height); + } + destroy(): void { this.app.destroy(); } From fc8117f58df386dda3e96ecd0f57c43b814623c9 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Sat, 6 Jul 2024 13:09:39 -0400 Subject: [PATCH 119/139] feat: rescale height --- demo/GoslingComponent.tsx | 86 +++++++++++++++++++++++++++++---------- 1 file changed, 64 insertions(+), 22 deletions(-) diff --git a/demo/GoslingComponent.tsx b/demo/GoslingComponent.tsx index bfdde3a53..594711237 100644 --- a/demo/GoslingComponent.tsx +++ b/demo/GoslingComponent.tsx @@ -50,25 +50,30 @@ function renderGosling(gs: GoslingSpec, container: HTMLDivElement, width: number // Extract all of the linking information from the spec const linkedEncodings = getLinkedEncodings(processedSpec); - const isResponsiveWidth = - processedSpec.responsiveSize && - typeof processedSpec.responsiveSize === 'object' && - processedSpec.responsiveSize.width; - if (isResponsiveWidth) { + // If the spec is responsive, we need to add a resize observer to the container + const { isResponsiveWidth, isResponsiveHeight } = checkResponsiveSpec(processedSpec); + if (isResponsiveWidth || isResponsiveHeight) { const resizeObserver = new ResizeObserver( debounce(entries => { const { width, height } = entries[0].contentRect; + console.warn('Resizing to', width, height); // Remove all of the previously drawn overlay divs and tracks pixiManager.clearAll(); - const rescaledTracks = rescaleTrackInfos(trackInfos, width, height); + const rescaledTracks = rescaleTrackInfos( + trackInfos, + width, + height, + isResponsiveWidth, + isResponsiveHeight + ); const trackDefs = createTrackDefs(rescaledTracks, theme); renderTrackDefs(trackDefs, linkedEncodings, pixiManager); - // pixiManager.resize(width, height); }, 300) ); resizeObserver.observe(container); } else { + // If the spec is not responsive, we can just render the tracks const trackDefs = createTrackDefs(trackInfos, theme); renderTrackDefs(trackDefs, linkedEncodings, pixiManager); const maxWidth = Math.max(...trackInfos.map(ti => ti.boundingBox.x + ti.boundingBox.width)); @@ -86,22 +91,59 @@ function debounce(f: (arg0: unknown) => unknown, delay: number) { }; } +/** Checks whether the input spec has responsive width or height */ +function checkResponsiveSpec(spec: GoslingSpec) { + const isResponsiveWidth = + (spec.responsiveSize && typeof spec.responsiveSize === 'object' && spec.responsiveSize.width) || false; + + const isResponsiveHeight = + (spec.responsiveSize && typeof spec.responsiveSize === 'object' && spec.responsiveSize.height) || false; + + return { + isResponsiveWidth, + isResponsiveHeight + }; +} + /** * This function rescales the bounding boxes of the trackInfos so that they fit within the width and height */ -function rescaleTrackInfos(trackInfos: TrackInfo[], width: number, height: number): TrackInfo[] { - const maxWidth = Math.max(...trackInfos.map(ti => ti.boundingBox.x + ti.boundingBox.width)); - const scalingFactor = width / maxWidth; - const scaledTrackInfos = trackInfos.map(ti => { - return { - ...ti, - boundingBox: { - x: ti.boundingBox.x * scalingFactor, - y: ti.boundingBox.y * scalingFactor, - width: ti.boundingBox.width * scalingFactor, - height: ti.boundingBox.height * scalingFactor - } - }; - }); - return scaledTrackInfos; +function rescaleTrackInfos( + trackInfos: TrackInfo[], + width: number, + height: number, + isResponsiveWidth: boolean, + isResponsiveHeight: boolean +): TrackInfo[] { + if (isResponsiveWidth) { + const maxWidth = Math.max(...trackInfos.map(ti => ti.boundingBox.x + ti.boundingBox.width)); + const scalingFactor = width / maxWidth; + trackInfos = trackInfos.map(ti => { + return { + ...ti, + boundingBox: { + x: ti.boundingBox.x * scalingFactor, + y: ti.boundingBox.y, + width: ti.boundingBox.width * scalingFactor, + height: ti.boundingBox.height + } + }; + }); + } + if (isResponsiveHeight) { + const maxHeight = Math.max(...trackInfos.map(ti => ti.boundingBox.y + ti.boundingBox.height)); + const scalingFactor = height / maxHeight; + trackInfos = trackInfos.map(ti => { + return { + ...ti, + boundingBox: { + x: ti.boundingBox.x, + y: ti.boundingBox.y * scalingFactor, + width: ti.boundingBox.width, + height: ti.boundingBox.height * scalingFactor + } + }; + }); + } + return trackInfos; } From c6bd779d38542941f2b6fd6b6a8b9552ae8012bb Mon Sep 17 00:00:00 2001 From: etowahadams Date: Sat, 6 Jul 2024 14:55:15 -0400 Subject: [PATCH 120/139] fixes for responsive height --- demo/GoslingComponent.tsx | 47 ++++++++++++++++++-------------- demo/linking/linkedEncoding.ts | 1 - src/pixi-manager/pixi-manager.ts | 27 ++++++++++++++---- 3 files changed, 48 insertions(+), 27 deletions(-) diff --git a/demo/GoslingComponent.tsx b/demo/GoslingComponent.tsx index 594711237..dbd145cab 100644 --- a/demo/GoslingComponent.tsx +++ b/demo/GoslingComponent.tsx @@ -4,7 +4,6 @@ import { PixiManager } from '@pixi-manager'; import { compile } from '../src/compiler/compile'; import { getTheme } from '../src/core/utils/theme'; -import type { HiGlassSpec } from '@gosling-lang/higlass-schema'; import { createTrackDefs } from './track-def/main'; import { renderTrackDefs } from './renderer/main'; import type { TrackInfo } from 'src/compiler/bounding-box'; @@ -18,20 +17,17 @@ interface GoslingComponentProps { } export function GoslingComponent({ spec, width, height }: GoslingComponentProps) { const [fps, setFps] = useState(120); + const prevSpec = useMemo(() => spec, [spec]); useEffect(() => { if (!spec) return; const plotElement = document.getElementById('plot') as HTMLDivElement; plotElement.innerHTML = ''; - renderGosling(spec, plotElement, width, height); + renderGosling(spec, plotElement); }, [spec]); - return ( -
-
-
- ); + return
; } /** * This is the main function. It takes a Gosling spec and renders it to the container. @@ -40,9 +36,11 @@ export function GoslingComponent({ spec, width, height }: GoslingComponentProps) * @param width * @param height */ -function renderGosling(gs: GoslingSpec, container: HTMLDivElement, width: number, height: number) { +function renderGosling(gs: GoslingSpec, container: HTMLDivElement) { // Initialize the PixiManager. This will be used to get containers and overlay divs for the plots - const pixiManager = new PixiManager(width, height, container, () => {}); + const canvasWidth = 1000, + canvasHeight = 1000; // These initial sizes don't matter because the size will be updated + const pixiManager = new PixiManager(canvasWidth, canvasHeight, container, () => {}); // Compile the spec const compileResult = compile(gs, [], getTheme('light'), { containerSize: { width: 0, height: 0 } }); @@ -56,19 +54,22 @@ function renderGosling(gs: GoslingSpec, container: HTMLDivElement, width: number if (isResponsiveWidth || isResponsiveHeight) { const resizeObserver = new ResizeObserver( debounce(entries => { - const { width, height } = entries[0].contentRect; - console.warn('Resizing to', width, height); + const { width: containerWidth, height: containerHeight } = entries[0].contentRect; + console.warn('Resizing to', containerWidth, containerHeight); // Remove all of the previously drawn overlay divs and tracks pixiManager.clearAll(); const rescaledTracks = rescaleTrackInfos( trackInfos, - width, - height, + containerWidth - 100, // minus 100 to account for the padding + containerHeight - 100, isResponsiveWidth, isResponsiveHeight ); const trackDefs = createTrackDefs(rescaledTracks, theme); renderTrackDefs(trackDefs, linkedEncodings, pixiManager); + // Resize the canvas to make sure it fits the tracks + const { width, height } = calculateWidthHeight(rescaledTracks); + pixiManager.resize(width, height); }, 300) ); resizeObserver.observe(container); @@ -76,9 +77,9 @@ function renderGosling(gs: GoslingSpec, container: HTMLDivElement, width: number // If the spec is not responsive, we can just render the tracks const trackDefs = createTrackDefs(trackInfos, theme); renderTrackDefs(trackDefs, linkedEncodings, pixiManager); - const maxWidth = Math.max(...trackInfos.map(ti => ti.boundingBox.x + ti.boundingBox.width)); - const maxHeight = Math.max(...trackInfos.map(ti => ti.boundingBox.y + ti.boundingBox.height)); - pixiManager.resize(maxWidth, maxHeight); + // Resize the canvas to make sure it fits the tracks + const { width, height } = calculateWidthHeight(trackInfos); + pixiManager.resize(width, height); } } @@ -105,6 +106,13 @@ function checkResponsiveSpec(spec: GoslingSpec) { }; } +/** Helper function which calculates the maximum width and height of the bounding boxes of the trackInfos */ +function calculateWidthHeight(trackInfos: TrackInfo[]) { + const width = Math.max(...trackInfos.map(ti => ti.boundingBox.x + ti.boundingBox.width)); + const height = Math.max(...trackInfos.map(ti => ti.boundingBox.y + ti.boundingBox.height)); + return { width, height }; +} + /** * This function rescales the bounding boxes of the trackInfos so that they fit within the width and height */ @@ -115,9 +123,9 @@ function rescaleTrackInfos( isResponsiveWidth: boolean, isResponsiveHeight: boolean ): TrackInfo[] { + const { width: origWidth, height: origHeight } = calculateWidthHeight(trackInfos); if (isResponsiveWidth) { - const maxWidth = Math.max(...trackInfos.map(ti => ti.boundingBox.x + ti.boundingBox.width)); - const scalingFactor = width / maxWidth; + const scalingFactor = width / origWidth; trackInfos = trackInfos.map(ti => { return { ...ti, @@ -131,8 +139,7 @@ function rescaleTrackInfos( }); } if (isResponsiveHeight) { - const maxHeight = Math.max(...trackInfos.map(ti => ti.boundingBox.y + ti.boundingBox.height)); - const scalingFactor = height / maxHeight; + const scalingFactor = height / origHeight; trackInfos = trackInfos.map(ti => { return { ...ti, diff --git a/demo/linking/linkedEncoding.ts b/demo/linking/linkedEncoding.ts index d8a26d1a8..a4005b681 100644 --- a/demo/linking/linkedEncoding.ts +++ b/demo/linking/linkedEncoding.ts @@ -242,7 +242,6 @@ function getSingleViewTrackLinks(gs: SingleView): TrackLink[] { * Links all of the tracks in a single view together */ function getSingleViewLinks(gs: SingleView): ViewLink[] { - console.warn('got single view', gs); function addLinkY(tracks: Track[], viewYDomain: [number, number]): ViewLink { const viewLinkY: ViewLink = { linkingId: undefined, diff --git a/src/pixi-manager/pixi-manager.ts b/src/pixi-manager/pixi-manager.ts index 21a340e69..b0f01d4e4 100644 --- a/src/pixi-manager/pixi-manager.ts +++ b/src/pixi-manager/pixi-manager.ts @@ -12,11 +12,11 @@ interface BoundingBox { } export class PixiManager { app: PIXI.Application; - // This contains both the canvas and the overlay container + /** Contains the canvas and the overlayContainer */ rootDiv: HTMLDivElement; - // Div which contains all overlay divs + /** This contains all of the overlay divs */ overlayContainer: HTMLDivElement; - // Mapping between position and overlay div so we can reuse overlay divs + /** Mapping between the position and the overlay div */ createdContainers: Map = new Map(); constructor(width: number, height: number, container: HTMLDivElement, fps: (fps: number) => void) { @@ -36,15 +36,28 @@ export class PixiManager { wheel: false } }); + // The wrapper div is used to add padding around the canvas + const wrapper = document.createElement('div'); + wrapper.style.padding = '50px'; + wrapper.style.backgroundColor = 'white'; + container.appendChild(wrapper); - this.rootDiv = container; - container.appendChild(this.app.view); + // Canvas and overlay container will be added to the root div + const rootDiv = document.createElement('div'); + rootDiv.style.position = 'relative'; + wrapper.appendChild(rootDiv); + this.rootDiv = rootDiv; + this.rootDiv.appendChild(this.app.view); + + // Overlays will be added to the overlay container this.overlayContainer = document.createElement('div'); - container.appendChild(this.overlayContainer); + this.rootDiv.appendChild(this.overlayContainer); // Add FPS counter this.app.ticker.add(() => { fps(this.app.ticker.FPS); }); + + console.warn('created new pixi manager'); } /** @@ -87,6 +100,8 @@ export class PixiManager { resize(width: number, height: number): void { this.app.renderer.resize(width, height); + this.rootDiv.style.width = `${width}px`; + this.rootDiv.style.height = `${height}px`; } destroy(): void { From d591b16e9bc08e9087d482cfac9478374cdc9625 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Sat, 6 Jul 2024 15:26:24 -0400 Subject: [PATCH 121/139] pixi manager property comment --- src/pixi-manager/pixi-manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pixi-manager/pixi-manager.ts b/src/pixi-manager/pixi-manager.ts index b0f01d4e4..c2b02e637 100644 --- a/src/pixi-manager/pixi-manager.ts +++ b/src/pixi-manager/pixi-manager.ts @@ -14,7 +14,7 @@ export class PixiManager { app: PIXI.Application; /** Contains the canvas and the overlayContainer */ rootDiv: HTMLDivElement; - /** This contains all of the overlay divs */ + /** Element which contains all of the overlay divs */ overlayContainer: HTMLDivElement; /** Mapping between the position and the overlay div */ createdContainers: Map = new Map(); From 664bd4451a4834c86b314d6413e7a965da7a7c2e Mon Sep 17 00:00:00 2001 From: etowahadams Date: Sat, 6 Jul 2024 15:53:27 -0400 Subject: [PATCH 122/139] feat: basic pixi manager persistance --- demo/GoslingComponent.tsx | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/demo/GoslingComponent.tsx b/demo/GoslingComponent.tsx index dbd145cab..7bb61221c 100644 --- a/demo/GoslingComponent.tsx +++ b/demo/GoslingComponent.tsx @@ -17,14 +17,23 @@ interface GoslingComponentProps { } export function GoslingComponent({ spec, width, height }: GoslingComponentProps) { const [fps, setFps] = useState(120); - const prevSpec = useMemo(() => spec, [spec]); + const [pixiManager, setPixiManager] = useState(null); useEffect(() => { if (!spec) return; - const plotElement = document.getElementById('plot') as HTMLDivElement; - plotElement.innerHTML = ''; - renderGosling(spec, plotElement); + // Initialize the PixiManager. This will be used to get containers and overlay divs for the plots + const canvasWidth = 1000, + canvasHeight = 1000; // These initial sizes don't matter because the size will be updated + if (!pixiManager) { + const pixiManager = new PixiManager(canvasWidth, canvasHeight, plotElement, () => {}); + renderGosling(spec, plotElement, pixiManager); + setPixiManager(pixiManager); + } else { + console.warn('pixi manager found'); + pixiManager.clearAll(); + renderGosling(spec, plotElement, pixiManager); + } }, [spec]); return
; @@ -36,12 +45,7 @@ export function GoslingComponent({ spec, width, height }: GoslingComponentProps) * @param width * @param height */ -function renderGosling(gs: GoslingSpec, container: HTMLDivElement) { - // Initialize the PixiManager. This will be used to get containers and overlay divs for the plots - const canvasWidth = 1000, - canvasHeight = 1000; // These initial sizes don't matter because the size will be updated - const pixiManager = new PixiManager(canvasWidth, canvasHeight, container, () => {}); - +function renderGosling(gs: GoslingSpec, container: HTMLDivElement, pixiManager: PixiManager) { // Compile the spec const compileResult = compile(gs, [], getTheme('light'), { containerSize: { width: 0, height: 0 } }); const { trackInfos, gs: processedSpec, theme } = compileResult; @@ -76,6 +80,7 @@ function renderGosling(gs: GoslingSpec, container: HTMLDivElement) { } else { // If the spec is not responsive, we can just render the tracks const trackDefs = createTrackDefs(trackInfos, theme); + console.warn('Rendering tracks'); renderTrackDefs(trackDefs, linkedEncodings, pixiManager); // Resize the canvas to make sure it fits the tracks const { width, height } = calculateWidthHeight(trackInfos); From cde2d9993f9c83dac98fa9688fcfc9d6aea84b3b Mon Sep 17 00:00:00 2001 From: etowahadams Date: Sat, 6 Jul 2024 16:12:46 -0400 Subject: [PATCH 123/139] remove console warns --- demo/GoslingComponent.tsx | 9 +++++---- demo/linking/linkedEncoding.ts | 4 ---- src/compiler/bounding-box.ts | 19 ------------------- 3 files changed, 5 insertions(+), 27 deletions(-) diff --git a/demo/GoslingComponent.tsx b/demo/GoslingComponent.tsx index 7bb61221c..e89b65d8b 100644 --- a/demo/GoslingComponent.tsx +++ b/demo/GoslingComponent.tsx @@ -17,20 +17,20 @@ interface GoslingComponentProps { } export function GoslingComponent({ spec, width, height }: GoslingComponentProps) { const [fps, setFps] = useState(120); + // Pixi manager should persist between render calls. Otherwise performance degrades greatly. const [pixiManager, setPixiManager] = useState(null); useEffect(() => { if (!spec) return; const plotElement = document.getElementById('plot') as HTMLDivElement; - // Initialize the PixiManager. This will be used to get containers and overlay divs for the plots - const canvasWidth = 1000, - canvasHeight = 1000; // These initial sizes don't matter because the size will be updated + // If the pixiManager doesn't exist, create a new one if (!pixiManager) { + const canvasWidth = 1000, + canvasHeight = 1000; // These initial sizes don't matter because the size will be updated const pixiManager = new PixiManager(canvasWidth, canvasHeight, plotElement, () => {}); renderGosling(spec, plotElement, pixiManager); setPixiManager(pixiManager); } else { - console.warn('pixi manager found'); pixiManager.clearAll(); renderGosling(spec, plotElement, pixiManager); } @@ -52,6 +52,7 @@ function renderGosling(gs: GoslingSpec, container: HTMLDivElement, pixiManager: // Extract all of the linking information from the spec const linkedEncodings = getLinkedEncodings(processedSpec); + console.warn('Linked encodings', linkedEncodings); // If the spec is responsive, we need to add a resize observer to the container const { isResponsiveWidth, isResponsiveHeight } = checkResponsiveSpec(processedSpec); diff --git a/demo/linking/linkedEncoding.ts b/demo/linking/linkedEncoding.ts index a4005b681..d8ff6ad00 100644 --- a/demo/linking/linkedEncoding.ts +++ b/demo/linking/linkedEncoding.ts @@ -55,7 +55,6 @@ interface LinkInfo { export function getLinkedEncodings(gs: GoslingSpec) { // First, we traverse the gosling spec to find all the linked tracks and brushes const { trackLinks, viewLinks } = getLinkedFeaturesRecursive(gs); - console.warn('trackLinks', trackLinks); // We associate tracks the other tracks they are linked with const linkedEncodings = viewLinks.map(viewLink => { const linkedTracks = getLinkedTracks(viewLink.linkingId, trackLinks).map(track => ({ @@ -69,7 +68,6 @@ export function getLinkedEncodings(gs: GoslingSpec) { tracks: [...linkedTracks, ...viewTracks] } as LinkedEncoding; }); - console.warn('linked Encodings from view', [...linkedEncodings]); // Combine trackLinks that do not belong to any viewLink const unlinkedTracks = trackLinks.filter( trackLink => @@ -85,7 +83,6 @@ export function getLinkedEncodings(gs: GoslingSpec) { * This can happen when a track uses the "domain" property */ function combineUnlinkedTracks(unlinkedTracks: TrackLink[]): LinkedEncoding[] { - console.warn('unlinkedTracks', unlinkedTracks); const linkedEncodings: LinkedEncoding[] = []; unlinkedTracks.forEach(trackLink => { const existingLink = linkedEncodings.find(link => link.linkingId && link.linkingId === trackLink.linkingId); @@ -110,7 +107,6 @@ function combineUnlinkedTracks(unlinkedTracks: TrackLink[]): LinkedEncoding[] { linkedEncodings.push(newLink); } }); - console.warn('linkedEncodings from unlinked', linkedEncodings); return linkedEncodings; } diff --git a/src/compiler/bounding-box.ts b/src/compiler/bounding-box.ts index 610935b0c..c1d4c2734 100644 --- a/src/compiler/bounding-box.ts +++ b/src/compiler/bounding-box.ts @@ -248,7 +248,6 @@ function traverseAndCollectTrackInfo( } } }); - adjustOverlaidTrackPosition(output); } } else { // We did not reach a track definition, so continue traversing. @@ -374,24 +373,6 @@ function traverseAndCollectTrackInfo( return { x: dx, y: dy, width: cumWidth, height: cumHeight }; } -/** - * Adjusts the x and y position of the overlaid tracks - * Problem: Some overlaid tracks have an axis. Some do not. If an overlaid track does not have an axis - * then the (x, y) position of the bounding box is possibly incorrect. - */ -function adjustOverlaidTrackPosition(output) { - const overlaidTracks = output.filter(t => t.track.overlayOnPreviousTrack); - const hasOverlaidTracks = overlaidTracks.length > 0; - if (!hasOverlaidTracks) return output; - console.warn('overlaidTracks', overlaidTracks); - const baseTrack = output.filter(t => !t.track.overlayOnPreviousTrack)[0]; - // overlaidTracks[0].boundingBox.x += 30; - // overlaidTracks[0].boundingBox.y += 30; - // if (baseTrack.boundingBox.width > overlaidTracks[0].boundingBox.width) { - // overlaidTracks[0].boundingBox.x += 30; - // } -} - export function getNumOfXAxes(tracks: Track[]): number { return tracks.filter(t => IsXAxis(t)).length; } From 7b07dc8f013fad68dfddee4e518d727fbbdd621a Mon Sep 17 00:00:00 2001 From: etowahadams Date: Sat, 6 Jul 2024 17:23:09 -0400 Subject: [PATCH 124/139] fix: brushes and types --- demo/GoslingComponent.tsx | 19 ++++----- demo/track-def/axis.ts | 25 +++++------- demo/track-def/{brushLinear.ts => brush.ts} | 27 +++++++------ demo/track-def/gosling.ts | 20 +++++----- demo/track-def/heatmap.ts | 10 ++--- demo/track-def/main.ts | 2 +- demo/track-def/text.ts | 7 ++-- demo/track-def/types.ts | 44 +++++++++++++++++++++ 8 files changed, 96 insertions(+), 58 deletions(-) rename demo/track-def/{brushLinear.ts => brush.ts} (65%) create mode 100644 demo/track-def/types.ts diff --git a/demo/GoslingComponent.tsx b/demo/GoslingComponent.tsx index e89b65d8b..97b7dd71d 100644 --- a/demo/GoslingComponent.tsx +++ b/demo/GoslingComponent.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect } from 'react'; import { PixiManager } from '@pixi-manager'; import { compile } from '../src/compiler/compile'; @@ -39,22 +39,18 @@ export function GoslingComponent({ spec, width, height }: GoslingComponentProps) return
; } /** - * This is the main function. It takes a Gosling spec and renders it to the container. - * @param gs - * @param container - * @param width - * @param height + * This is the main function. It takes a Gosling spec and renders it using the PixiManager */ function renderGosling(gs: GoslingSpec, container: HTMLDivElement, pixiManager: PixiManager) { - // Compile the spec + // 1. Compile the spec const compileResult = compile(gs, [], getTheme('light'), { containerSize: { width: 0, height: 0 } }); const { trackInfos, gs: processedSpec, theme } = compileResult; - - // Extract all of the linking information from the spec + console.warn('Spec', processedSpec); + // 2. Extract all of the linking information from the spec const linkedEncodings = getLinkedEncodings(processedSpec); console.warn('Linked encodings', linkedEncodings); - // If the spec is responsive, we need to add a resize observer to the container + // 3. If the spec is responsive, we need to add a resize observer to the container const { isResponsiveWidth, isResponsiveHeight } = checkResponsiveSpec(processedSpec); if (isResponsiveWidth || isResponsiveHeight) { const resizeObserver = new ResizeObserver( @@ -70,6 +66,7 @@ function renderGosling(gs: GoslingSpec, container: HTMLDivElement, pixiManager: isResponsiveWidth, isResponsiveHeight ); + // 4. Render the tracks const trackDefs = createTrackDefs(rescaledTracks, theme); renderTrackDefs(trackDefs, linkedEncodings, pixiManager); // Resize the canvas to make sure it fits the tracks @@ -79,7 +76,7 @@ function renderGosling(gs: GoslingSpec, container: HTMLDivElement, pixiManager: ); resizeObserver.observe(container); } else { - // If the spec is not responsive, we can just render the tracks + // 4. If the spec is not responsive, we can just render the tracks const trackDefs = createTrackDefs(trackInfos, theme); console.warn('Rendering tracks'); renderTrackDefs(trackDefs, linkedEncodings, pixiManager); diff --git a/demo/track-def/axis.ts b/demo/track-def/axis.ts index 0da8058d1..6ad573605 100644 --- a/demo/track-def/axis.ts +++ b/demo/track-def/axis.ts @@ -1,18 +1,10 @@ import { type AxisTrackOptions } from '@gosling-lang/genomic-axis'; -import { - IsChannelDeep, - IsDummyTrack, - IsTemplateTrack, - type AxisPosition, - type OverlaidTrack, - type SingleTrack, - type TemplateTrack, - type Track -} from '@gosling-lang/gosling-schema'; +import { IsChannelDeep, IsDummyTrack, IsTemplateTrack, type AxisPosition } from '@gosling-lang/gosling-schema'; import type { CompleteThemeDeep } from '../../src/core/utils/theme'; import { resolveSuperposedTracks } from '../../src/core/utils/overlay'; import { HIGLASS_AXIS_SIZE } from '../../src/compiler/higlass-model'; import { TrackType, type TrackDef } from './main'; +import type { ProcessedCircularTrack, ProcessedTrack } from './types'; /** * Generates the track definition for the axis track @@ -21,7 +13,7 @@ import { TrackType, type TrackDef } from './main'; * @param theme */ export function getAxisTrackDef( - track: SingleTrack | OverlaidTrack | TemplateTrack, + track: ProcessedTrack, boundingBox: { x: number; y: number; width: number; height: number }, theme: Required ): [trackBbox: { x: number; y: number; width: number; height: number }, TrackDef[] | undefined] { @@ -95,7 +87,7 @@ export function getAxisTrackDef( */ function getAxisTrackLinearOptions( encoding: 'x' | 'y', - track: SingleTrack | OverlaidTrack | TemplateTrack, + track: ProcessedTrack, boundingBox: { x: number; y: number; width: number; height: number }, position: AxisPosition, theme: Required @@ -131,7 +123,10 @@ function getAxisTrackLinearOptions( /** * Determines the orientation of the axis */ -function getAxisOrientation(encoding: 'x' | 'y', trackOrientation: 'horizontal' | 'vertical') { +function getAxisOrientation( + encoding: 'x' | 'y', + trackOrientation: 'horizontal' | 'vertical' +): 'horizontal' | 'vertical' { if (encoding === 'x') { return trackOrientation === 'horizontal' ? 'horizontal' : 'vertical'; } @@ -146,7 +141,7 @@ function getAxisOrientation(encoding: 'x' | 'y', trackOrientation: 'horizontal' * Generates options for the circular axis track */ function getAxisTrackCircularOptions( - track: SingleTrack | OverlaidTrack | TemplateTrack, + track: ProcessedCircularTrack, boundingBox: { x: number; y: number; width: number; height: number }, position: AxisPosition, theme: Required @@ -191,7 +186,7 @@ function getAxisTrackCircularOptions( * @param track * @returns */ -function getAxisPositions(track: Track): { +function getAxisPositions(track: ProcessedTrack): { xAxisPosition: AxisPosition | undefined; yAxisPosition: AxisPosition | undefined; } { diff --git a/demo/track-def/brushLinear.ts b/demo/track-def/brush.ts similarity index 65% rename from demo/track-def/brushLinear.ts rename to demo/track-def/brush.ts index aeb0af866..cb346cf8c 100644 --- a/demo/track-def/brushLinear.ts +++ b/demo/track-def/brush.ts @@ -1,21 +1,22 @@ -import { type SingleTrack, type Track } from '@gosling-lang/gosling-schema'; import type { BrushLinearTrackOptions } from '@gosling-lang/brush-linear'; import type { BrushCircularTrackOptions } from '@gosling-lang/brush-circular'; import { type TrackDef, TrackType } from './main'; +import { type ProcessedTrack, type OverlayTrack, type ProcessedCircularTrack } from './types'; export function getBrushTrackDefs( - spec: Track, + spec: ProcessedTrack, boundingBox: { x: number; y: number; width: number; height: number } ): TrackDef[] | TrackDef[] { const trackDefs: TrackDef[] = []; - // If we have a linear layout, we use the BrushLinearTrack + // We always expect brushes to be overlayed on top of another track if (!spec._overlay) return []; - spec._overlay.forEach((overlay: SingleTrack) => { + spec._overlay.forEach((overlay: OverlayTrack) => { + // Skip if the overlay is not a brush if (overlay.mark !== 'brush') return; if (spec.layout === 'linear') { - const options = getBrushLinearOptions(spec); + const options = getBrushLinearOptions(spec, overlay); trackDefs.push({ type: TrackType.BrushLinear, trackId: overlay.id, @@ -24,7 +25,7 @@ export function getBrushTrackDefs( }); } else if (spec.layout === 'circular') { // If we have a circular layout, we use the BrushCircularTrack - const options = getBrushCircularOptions(spec); + const options = getBrushCircularOptions(spec, overlay); trackDefs.push({ type: TrackType.BrushCircular, trackId: overlay.id, @@ -39,10 +40,10 @@ export function getBrushTrackDefs( /** * Get the options for a BrushLinearTrack */ -function getBrushLinearOptions(spec: Track): BrushLinearTrackOptions { +function getBrushLinearOptions(spec: ProcessedTrack, overlay: OverlayTrack): BrushLinearTrackOptions { const options = { - projectionFillColor: spec.color?.value ?? 'red', - projectionStrokeColor: spec.stroke?.value ?? 'red', + projectionFillColor: overlay.color?.value ?? 'gray', + projectionStrokeColor: spec.stroke?.value ?? 'black', projectionFillOpacity: spec.opacity?.value ?? 0.3, projectionStrokeOpacity: spec.opacity?.value ?? 0.3, strokeWidth: spec.strokeWidth?.value ?? 1 @@ -53,13 +54,13 @@ function getBrushLinearOptions(spec: Track): BrushLinearTrackOptions { /** * Get the options for a BrushCircularTrack */ -function getBrushCircularOptions(spec: Track): BrushCircularTrackOptions { +function getBrushCircularOptions(spec: ProcessedCircularTrack, overlay: OverlayTrack): BrushCircularTrackOptions { const options = { - projectionFillColor: spec.color?.value ?? 'red', - projectionStrokeColor: 'black', + projectionFillColor: overlay.color?.value ?? 'gray', + projectionStrokeColor: overlay.stroke?.value ?? 'black', projectionFillOpacity: 0.3, projectionStrokeOpacity: 0.3, - strokeWidth: 0.3, + strokeWidth: spec.strokeWidth?.value ?? 0.3, startAngle: spec.startAngle ?? 7.2, endAngle: spec.endAngle ?? 352.8, innerRadius: spec.innerRadius ?? 151.08695652173913, diff --git a/demo/track-def/gosling.ts b/demo/track-def/gosling.ts index 6c1ec9841..c62fc46a4 100644 --- a/demo/track-def/gosling.ts +++ b/demo/track-def/gosling.ts @@ -1,17 +1,19 @@ import { type AxisTrackOptions } from '@gosling-lang/genomic-axis'; - -import { type Track } from '@gosling-lang/gosling-schema'; import type { CompleteThemeDeep } from '../../src/core/utils/theme'; - import type { GoslingTrackOptions } from '@gosling-lang/gosling-track'; import type { BrushLinearTrackOptions } from '@gosling-lang/brush-linear'; - import { getAxisTrackDef } from './axis'; import { type TrackDef, TrackType } from './main'; -import { getBrushTrackDefs } from './brushLinear'; +import { getBrushTrackDefs } from './brush'; +import type { ProcessedTrack } from './types'; +/** + * A Gosling track, as defined in the schema, can be composed of multiple tracks: + * A GoslingTrack, an AxisTrack, and a BrushTrack. This function processes the spec of a single Gosling track + * and returns the corresponding track definitions. + */ export function processGoslingTrack( - track: Track, + track: ProcessedTrack, boundingBox: { x: number; y: number; width: number; height: number }, theme: Required ): (TrackDef | TrackDef | TrackDef)[] { @@ -49,11 +51,11 @@ export function processGoslingTrack( return trackDefs; } -function getGoslingTrackOptions(spec: Track, theme: Required): GoslingTrackOptions { +function getGoslingTrackOptions(spec: ProcessedTrack, theme: Required): GoslingTrackOptions { return { spec: spec, - id: '9f4abc56-cb8d-4494-a9ca-56086ab28de2', - siblingIds: ['9f4abc56-cb8d-4494-a9ca-56086ab28de2'], + id: spec.id, + siblingIds: [], showMousePosition: true, mousePositionColor: '#000000', name: spec.title, diff --git a/demo/track-def/heatmap.ts b/demo/track-def/heatmap.ts index b60522e8f..28b6e2b82 100644 --- a/demo/track-def/heatmap.ts +++ b/demo/track-def/heatmap.ts @@ -1,13 +1,13 @@ -import { type Track } from '@gosling-lang/gosling-schema'; import { type TrackDef, TrackType } from './main'; import { type HeatmapTrackOptions } from '@gosling-lang/heatmap'; import type { CompleteThemeDeep } from '../../src/core/utils/theme'; import { computeChromSizes } from '../../src/core/utils/assembly'; import { getAxisTrackDef } from './axis'; import { type AxisTrackOptions } from '@gosling-lang/genomic-axis'; +import type { ProcessedTrack } from './types'; export function processHeatmapTrack( - track: Track, + track: ProcessedTrack, boundingBox: { x: number; y: number; width: number; height: number }, theme: Required ): (TrackDef | TrackDef)[] { @@ -31,11 +31,11 @@ export function processHeatmapTrack( return trackDefs; } -export function isHeatmapTrack(track: Track): boolean { - return track.data && track.data.type === 'matrix'; +export function isHeatmapTrack(track: ProcessedTrack): boolean { + return (track.data && track.data.type === 'matrix') || false; } -function getHeatmapOptions(spec: Track, theme: Required): HeatmapTrackOptions { +function getHeatmapOptions(spec: ProcessedTrack, theme: Required): HeatmapTrackOptions { const { assembly } = spec; return { spec: spec, diff --git a/demo/track-def/main.ts b/demo/track-def/main.ts index 794556651..70834f48b 100644 --- a/demo/track-def/main.ts +++ b/demo/track-def/main.ts @@ -96,4 +96,4 @@ export function showTrackInfoPositions(trackInfos: TrackInfo[], pixiManager: Pix div.innerHTML = track.mark || 'No mark'; div.style.textAlign = 'left'; }); -} \ No newline at end of file +} diff --git a/demo/track-def/text.ts b/demo/track-def/text.ts index 30e2781c2..a19a61c0c 100644 --- a/demo/track-def/text.ts +++ b/demo/track-def/text.ts @@ -1,8 +1,7 @@ import { type TextTrackOptions } from '@gosling-lang/text-track'; - -import { type Track } from '@gosling-lang/gosling-schema'; import type { CompleteThemeDeep } from '../../src/core/utils/theme'; import { TrackType, type TrackDef } from './main'; +import type { ProcessedTrack } from './types'; /** * Separate the the track with mark "_header" into title and subtitle text tracks @@ -11,7 +10,7 @@ import { TrackType, type TrackDef } from './main'; * @returns */ export function proccessTextHeader( - track: Track, + track: ProcessedTrack, boundingBox: { x: number; y: number; width: number; height: number }, theme: Required ): TrackDef[] { @@ -42,7 +41,7 @@ export function proccessTextHeader( } function getTextTrackOptions( - spec: Track, + spec: ProcessedTrack, type: 'title' | 'subtitle', theme: Required ): TextTrackOptions { diff --git a/demo/track-def/types.ts b/demo/track-def/types.ts new file mode 100644 index 000000000..5e41ae3d6 --- /dev/null +++ b/demo/track-def/types.ts @@ -0,0 +1,44 @@ +import type { DataDeep, Assembly } from '@gosling-lang/gosling-schema'; +/** A Track after it has been compiled */ +export type ProcessedTrack = ProcessedLinearTrack | ProcessedCircularTrack; +/** All tracks potentially have these properties */ +export interface ProcessedTrackBase { + id: string; + height: number; + width: number; + static: boolean; + orientation: 'horizontal' | 'vertical'; + title?: string; + subtitle?: string; + data?: DataDeep; + assembly?: Assembly; + overlayOnPreviousTrack?: boolean; + _overlay?: OverlayTrack[]; + color?: { value: string }; + stroke?: { value: string }; + opacity?: { value: number }; + strokeWidth?: { value: number }; +} + +export type ProcessedLinearTrack = ProcessedTrackBase & { + layout: 'linear'; +}; + +export type ProcessedCircularTrack = ProcessedTrackBase & { + id: string; + layout: 'circular'; + startAngle: number; + endAngle: number; + outerRadius: number; + innerRadius: number; +}; + +/** Tracks in the _overlay */ +export interface OverlayTrack { + id: string; + mark: string; + x?: unknown; + y?: unknown; + color?: { value: string }; + stroke?: { value: string }; +} From 789f7cff406ab3f148829cc92f23014faefd5c85 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Sat, 6 Jul 2024 17:25:18 -0400 Subject: [PATCH 125/139] comment about types --- demo/track-def/types.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/demo/track-def/types.ts b/demo/track-def/types.ts index 5e41ae3d6..6fc0ae983 100644 --- a/demo/track-def/types.ts +++ b/demo/track-def/types.ts @@ -1,4 +1,17 @@ import type { DataDeep, Assembly } from '@gosling-lang/gosling-schema'; + +/** + * After the Gosling spec is compiled, it is a "processed spec". + * A processed spec has most of the same properties as the original spec, but some properties are + * added or modified during the compilation process. + * + * For example, a valid Gosling spec may have no 'id' property, but a processed spec will always have an 'id' property. + * + * This file contains the types for the processed spec. + * + * TODO: this file is incomplete. It should be updated to include all the properties that a processed spec can have. + */ + /** A Track after it has been compiled */ export type ProcessedTrack = ProcessedLinearTrack | ProcessedCircularTrack; /** All tracks potentially have these properties */ From 246ddda25df58cb1bee28164eb19089c57119551 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Sat, 6 Jul 2024 17:41:36 -0400 Subject: [PATCH 126/139] add documentation --- src/pixi-manager/pixi-manager.ts | 4 ++++ .../brush-circular/brush-circular-plot.ts | 12 +++++++++-- src/tracks/brush-linear/brush-linear-plot.ts | 6 ++++++ src/tracks/genomic-axis/axis-track-plot.ts | 21 +++++-------------- .../gosling-track/gosling-track-plot.ts | 13 ++++++++---- src/tracks/heatmap/heatmap-plot.ts | 15 +++++++------ 6 files changed, 41 insertions(+), 30 deletions(-) diff --git a/src/pixi-manager/pixi-manager.ts b/src/pixi-manager/pixi-manager.ts index c2b02e637..474213699 100644 --- a/src/pixi-manager/pixi-manager.ts +++ b/src/pixi-manager/pixi-manager.ts @@ -10,6 +10,10 @@ interface BoundingBox { width: number; height: number; } +/** + * A wrapper class for PIXI.Application. + * It manages the creation of PIXI containers and overlay divs. + */ export class PixiManager { app: PIXI.Application; /** Contains the canvas and the overlayContainer */ diff --git a/src/tracks/brush-circular/brush-circular-plot.ts b/src/tracks/brush-circular/brush-circular-plot.ts index ff7e176ed..5dca634e3 100644 --- a/src/tracks/brush-circular/brush-circular-plot.ts +++ b/src/tracks/brush-circular/brush-circular-plot.ts @@ -6,13 +6,21 @@ import { import { scaleLinear } from 'd3-scale'; import { type Signal, effect, signal } from '@preact/signals-core'; +/** + * A wrapper around the BrushCircularTrackClass that allows for use with signals + */ export class BrushCircularTrack extends CircularBrushTrackClass { + /** A signal containing the genomic x-domain [start, end] */ xDomain: Signal; + /** A signal containing the brush x-domain [start, end] */ xBrushDomain: Signal; - zoomStartScale = scaleLinear(); // This is the scale that we use to store the domain when the user starts zooming - domOverlay: HTMLElement; // This is the div that we're going to apply the zoom behavior to + /** The div element the zoom behavior will get attached to */ + domOverlay: HTMLElement; + /** Width of the track */ width: number; + /** Height of the track */ height: number; + /** Circular brush tracks cannot be vertical for now */ orientation: 'horizontal'; constructor( diff --git a/src/tracks/brush-linear/brush-linear-plot.ts b/src/tracks/brush-linear/brush-linear-plot.ts index 3781349ae..401898a41 100644 --- a/src/tracks/brush-linear/brush-linear-plot.ts +++ b/src/tracks/brush-linear/brush-linear-plot.ts @@ -6,9 +6,15 @@ import { import { scaleLinear } from 'd3-scale'; import { type Signal, effect, signal } from '@preact/signals-core'; +/** + * A wrapper around the BrushLinearTrackClass that allows for use with signals + */ export class BrushLinearTrack extends BrushLinearTrackClass { + /** A signal containing the genomic x-domain [start, end] */ xDomain: Signal<[number, number]>; + /** A signal containing the brush x-domain [start, end] */ xBrushDomain: Signal<[number, number]>; + /** The div element the zoom behavior will get attached to */ domOverlay: HTMLElement; width: number; height: number; diff --git a/src/tracks/genomic-axis/axis-track-plot.ts b/src/tracks/genomic-axis/axis-track-plot.ts index 4e3764906..6800ae49a 100644 --- a/src/tracks/genomic-axis/axis-track-plot.ts +++ b/src/tracks/genomic-axis/axis-track-plot.ts @@ -2,26 +2,15 @@ import { AxisTrackClass, type AxisTrackContext, type AxisTrackOptions } from './ import * as PIXI from 'pixi.js'; import { fakePubSub } from '@higlass/utils'; import { scaleLinear } from 'd3-scale'; -import { ZoomTransform } from 'd3-zoom'; - -import { type D3ZoomEvent, zoom } from 'd3-zoom'; -import { select } from 'd3-selection'; import { type Signal, effect } from '@preact/signals-core'; -// Default d3 zoom feels slow so we use this instead -// https://d3js.org/d3-zoom#zoom_wheelDelta -function wheelDelta(event: WheelEvent) { - const defaultMultiplier = 5; - return ( - -event.deltaY * - (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002) * - (event.ctrlKey ? 10 : defaultMultiplier) - ); -} - +/** + * A wrapper around the AxisTrackClass that allows for use with signals + */ export class AxisTrack extends AxisTrackClass { + /** A signal containing the genomic x-domain [start, end] */ xDomain: Signal<[number, number]>; - zoomStartScale = scaleLinear(); + /** The div element the zoom behavior will get attached to */ domOverlay: HTMLElement; width: number; height: number; diff --git a/src/tracks/gosling-track/gosling-track-plot.ts b/src/tracks/gosling-track/gosling-track-plot.ts index 2de4668ad..88da264bc 100644 --- a/src/tracks/gosling-track/gosling-track-plot.ts +++ b/src/tracks/gosling-track/gosling-track-plot.ts @@ -8,11 +8,16 @@ import { DataFetcher } from '@higlass/datafetcher'; import { type Plot } from '../utils'; import { signal, effect } from '@preact/signals-core'; +/** + * A wrapper around the GoslingTrackClass that allows for use with signals + */ export class GoslingTrack extends GoslingTrackClass implements Plot { - xDomain: Signal<[number, number]>; // Stores the genomic x-domain - yDomain: Signal<[number, number]>; // Stores the genomic y-domain - zoomStartScale = scaleLinear(); - domOverlay: HTMLElement; // This is the HTML element that covers the plot. Zoom behavior gets attached to this + /** A signal containing the genomic x-domain [start, end] */ + xDomain: Signal<[number, number]>; + /** A signal containing the genomic y-domain [start, end]. Note that this is only used when the y encoding has type "genomic" */ + yDomain: Signal<[number, number]>; + /** The div element the zoom behavior will get attached to */ + domOverlay: HTMLElement; width: number; height: number; orientation: 'horizontal' | 'vertical'; diff --git a/src/tracks/heatmap/heatmap-plot.ts b/src/tracks/heatmap/heatmap-plot.ts index 6dee5712c..3c4fa0e7a 100644 --- a/src/tracks/heatmap/heatmap-plot.ts +++ b/src/tracks/heatmap/heatmap-plot.ts @@ -3,12 +3,8 @@ import type { TiledPixiTrackContext, TiledPixiTrackOptions } from '@higlass/trac import * as PIXI from 'pixi.js'; import { fakePubSub } from '@higlass/utils'; import { scaleLinear } from 'd3-scale'; - -import { type D3ZoomEvent, zoom, ZoomTransform } from 'd3-zoom'; -import { select } from 'd3-selection'; -import { zoomWheelBehavior } from '../utils'; import { DataFetcher } from '@higlass/datafetcher'; -import { signal, type Signal, effect } from '@preact/signals-core'; +import { signal, type Signal } from '@preact/signals-core'; export type HeatmapTrackContext = TiledPixiTrackContext & { svgElement: HTMLElement; @@ -40,11 +36,14 @@ export type HeatmapTrackOptions = TiledPixiTrackOptions & { }; export class HeatmapTrack extends HeatmapTiledPixiTrack { - xDomain: Signal<[number, number]>; // This has to be a signal because it will potentially be updated by interactors + /** A signal containing the genomic x-domain [start, end] */ + xDomain: Signal<[number, number]>; + /** A signal containing the genomic y-domain [start, end] */ yDomain: Signal<[number, number]>; - maxDomain: number; // the maximum domain of the data. This is needed for zoomPanHeatmap to work properly + /** The maximum domain of the data. This is needed for zoomPanHeatmap to work properly */ + maxDomain: number; + /** The div element the zoom behavior will get attached to */ domOverlay: HTMLElement; - d3ZoomTransform: ZoomTransform; constructor( options: HeatmapTrackOptions, From 280b8d1136b50366a7df2f6b943e63f298c78e04 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Sat, 6 Jul 2024 18:09:35 -0400 Subject: [PATCH 127/139] feat: use color --- demo/track-def/heatmap.ts | 124 ++++++-------------------------------- 1 file changed, 18 insertions(+), 106 deletions(-) diff --git a/demo/track-def/heatmap.ts b/demo/track-def/heatmap.ts index 28b6e2b82..9136e9b8d 100644 --- a/demo/track-def/heatmap.ts +++ b/demo/track-def/heatmap.ts @@ -5,6 +5,7 @@ import { computeChromSizes } from '../../src/core/utils/assembly'; import { getAxisTrackDef } from './axis'; import { type AxisTrackOptions } from '@gosling-lang/genomic-axis'; import type { ProcessedTrack } from './types'; +import { IsChannelDeep, getHiGlassColorRange } from '@gosling-lang/gosling-schema'; export function processHeatmapTrack( track: ProcessedTrack, @@ -35,14 +36,26 @@ export function isHeatmapTrack(track: ProcessedTrack): boolean { return (track.data && track.data.type === 'matrix') || false; } -function getHeatmapOptions(spec: ProcessedTrack, theme: Required): HeatmapTrackOptions { - const { assembly } = spec; +function getHeatmapOptions(track: ProcessedTrack): HeatmapTrackOptions { + const { assembly } = track; + // Edge case: The first track in a view with "alignment": "overlay" can + // sometimes not have a y encoding but it has a single overlay track which contains the y encoding + // TODO: Should be possible to fix this during when the spec is compiled + const missingX = !('x' in track) || track.x === undefined; + const missingY = !('y' in track) || track.y === undefined; + const hasOverlay = '_overlay' in track && track._overlay && track._overlay.length == 1; + if (missingX && missingY && hasOverlay) track = { ...track, ...track._overlay[0] }; + + // Get color range + const colorStr = + IsChannelDeep(track.color) && typeof track.color.range === 'string' ? track.color.range : 'viridis'; + const colorRange = getHiGlassColorRange(colorStr); return { - spec: spec, + spec: track, maxDomain: computeChromSizes(assembly).total, showMousePosition: false, mousePositionColor: '#000000', - name: spec.title, + name: track.title, labelPosition: 'none', labelShowResolution: false, labelColor: 'black', @@ -65,107 +78,6 @@ function getHeatmapOptions(spec: ProcessedTrack, theme: Required Date: Sat, 6 Jul 2024 18:54:03 -0400 Subject: [PATCH 128/139] feat: dummy track --- demo/linking/linkedEncoding.ts | 5 +++-- demo/renderer/main.ts | 4 ++++ demo/track-def/dummy.ts | 21 +++++++++++++++++++++ demo/track-def/main.ts | 9 ++++++++- demo/track-def/types.ts | 10 ++++++++-- src/compiler/bounding-box.ts | 4 ++-- src/tracks/dummy-track/dummy-track-plot.ts | 15 +++++++++------ 7 files changed, 55 insertions(+), 13 deletions(-) create mode 100644 demo/track-def/dummy.ts diff --git a/demo/linking/linkedEncoding.ts b/demo/linking/linkedEncoding.ts index d8ff6ad00..01fe465a4 100644 --- a/demo/linking/linkedEncoding.ts +++ b/demo/linking/linkedEncoding.ts @@ -1,4 +1,4 @@ -import { IsMultipleViews, IsSingleView, type Assembly, type SingleView } from '@gosling-lang/gosling-schema'; +import { IsDummyTrack, IsMultipleViews, IsSingleView, type Assembly, type SingleView } from '@gosling-lang/gosling-schema'; import { GenomicPositionHelper, computeChromSizes } from '../../src/core/utils/assembly'; import { signal, type Signal } from '@preact/signals-core'; import type { GoslingSpec } from 'gosling.js'; @@ -270,7 +270,8 @@ function getSingleViewLinks(gs: SingleView): ViewLink[] { // Add each track to the link tracks.forEach(track => { // If the track is already linked to something else, we don't need to add it again - if (hasDiffXDomainThanView(gs, track, assembly, viewXDomain)) return; + // Or if the track is a dummy track, we don't need to add it + if (hasDiffXDomainThanView(gs, track, assembly, viewXDomain) || IsDummyTrack(track)) return; const hasOverlaidTracks = '_overlay' in track; // Add overlaid brush tracks to the link if (hasOverlaidTracks) { diff --git a/demo/renderer/main.ts b/demo/renderer/main.ts index 369a94351..2c008fcf2 100644 --- a/demo/renderer/main.ts +++ b/demo/renderer/main.ts @@ -11,6 +11,7 @@ import type { LinkedEncoding } from '../linking/linkedEncoding'; import { BrushCircularTrack } from '@gosling-lang/brush-circular'; import { HeatmapTrack } from '@gosling-lang/heatmap'; import type { PixiManager } from '@pixi-manager'; +import { DummyTrack } from '@gosling-lang/dummy-track'; /** * Takes a list of track definitions and linkedEncodings and renders them @@ -99,6 +100,9 @@ export function renderTrackDefs(trackDefs: TrackDefs[], linkedEncodings: LinkedE brush.addInteractor(plot => panZoom(plot, domain)); } } + if (type === TrackType.Dummy) { + new DummyTrack(options, pixiManager.makeContainer(boundingBox).overlayDiv); + } }); } diff --git a/demo/track-def/dummy.ts b/demo/track-def/dummy.ts new file mode 100644 index 000000000..3355ea487 --- /dev/null +++ b/demo/track-def/dummy.ts @@ -0,0 +1,21 @@ +import type { DummyTrackOptions } from '@gosling-lang/dummy-track'; +import { type TrackDef, TrackType } from './main'; +import { type ProcessedDummyTrack } from './types'; + +export function processDummyTrack( + track: ProcessedDummyTrack, + boundingBox: { x: number; y: number; width: number; height: number } +): TrackDef[] { + const trackDef: TrackDef = { + type: TrackType.Dummy, + trackId: track.id, + boundingBox, + options: { + width: boundingBox.width, + height: boundingBox.height, + ...track.style, + title: track.title ?? '' + } + }; + return [trackDef]; +} diff --git a/demo/track-def/main.ts b/demo/track-def/main.ts index 70834f48b..90a76f8aa 100644 --- a/demo/track-def/main.ts +++ b/demo/track-def/main.ts @@ -12,6 +12,8 @@ import { processHeatmapTrack, isHeatmapTrack } from './heatmap'; import { processGoslingTrack } from './gosling'; import { type BrushCircularTrackOptions } from '@gosling-lang/brush-circular'; import { type HeatmapTrackOptions } from '@gosling-lang/heatmap'; +import { processDummyTrack } from './dummy'; +import { IsDummyTrack } from '@gosling-lang/gosling-schema'; /** * All the different types of tracks that can be rendered @@ -30,9 +32,9 @@ export enum TrackType { * Associate options to each track type */ interface TrackOptionsMap { + [TrackType.Gosling]: GoslingTrackOptions; [TrackType.Text]: TextTrackOptions; [TrackType.Dummy]: DummyTrackOptions; - [TrackType.Gosling]: GoslingTrackOptions; [TrackType.Axis]: AxisTrackOptions; [TrackType.BrushLinear]: BrushLinearTrackOptions; [TrackType.BrushCircular]: BrushCircularTrackOptions; @@ -65,6 +67,7 @@ export type TrackDefs = { */ export function createTrackDefs(trackInfos: TrackInfo[], theme: Required): TrackDefs[] { const trackDefs: TrackDefs[] = []; + console.warn('trackinfos', trackInfos); trackInfos.forEach(trackInfo => { const { track, boundingBox } = trackInfo; @@ -76,6 +79,10 @@ export function createTrackDefs(trackInfos: TrackInfo[], theme: Required Date: Sat, 6 Jul 2024 19:10:37 -0400 Subject: [PATCH 129/139] make csv data fetcher take assembly --- demo/renderer/dataFetcher.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/demo/renderer/dataFetcher.ts b/demo/renderer/dataFetcher.ts index fd6281a0c..0f63c05be 100644 --- a/demo/renderer/dataFetcher.ts +++ b/demo/renderer/dataFetcher.ts @@ -1,8 +1,9 @@ import { DataFetcher } from '@higlass/datafetcher'; import { fakePubSub } from '@higlass/utils'; import { BigWigDataFetcher, CsvDataFetcher, JsonDataFetcher } from '@data-fetchers'; +import type { ProcessedTrack } from 'demo/track-def/types'; -export function getDataFetcher(spec: Track) { +export function getDataFetcher(spec: ProcessedTrack) { if (!('data' in spec)) { console.warn('No data in the track spec', spec); } @@ -13,11 +14,11 @@ export function getDataFetcher(spec: Track) { return new DataFetcher({ server, tilesetUid }, fakePubSub); } if (spec.data.type == 'bigwig') { - return new BigWigDataFetcher(spec.data); + return new BigWigDataFetcher({ ...spec.data, assembly: spec.assembly }); } if (spec.data.type == 'csv') { const fields = getFields(spec); - return new CsvDataFetcher({ ...spec.data, ...fields }); + return new CsvDataFetcher({ ...spec.data, ...fields, assembly: spec.assembly }); } if (spec.data.type == 'json') { const fields = getFields(spec); From 943b5be40608361c81960d1b9156cc6affe6b508 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Sat, 6 Jul 2024 19:16:46 -0400 Subject: [PATCH 130/139] feat: gff data fetcher --- demo/renderer/dataFetcher.ts | 5 ++++- src/data-fetchers/gff/gff-data-fetcher.ts | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/demo/renderer/dataFetcher.ts b/demo/renderer/dataFetcher.ts index 0f63c05be..8c5266839 100644 --- a/demo/renderer/dataFetcher.ts +++ b/demo/renderer/dataFetcher.ts @@ -1,6 +1,6 @@ import { DataFetcher } from '@higlass/datafetcher'; import { fakePubSub } from '@higlass/utils'; -import { BigWigDataFetcher, CsvDataFetcher, JsonDataFetcher } from '@data-fetchers'; +import { BigWigDataFetcher, CsvDataFetcher, GffDataFetcher, JsonDataFetcher } from '@data-fetchers'; import type { ProcessedTrack } from 'demo/track-def/types'; export function getDataFetcher(spec: ProcessedTrack) { @@ -24,6 +24,9 @@ export function getDataFetcher(spec: ProcessedTrack) { const fields = getFields(spec); return new JsonDataFetcher({ ...spec.data, ...fields, assembly: spec.assembly }); } + if (spec.data.type == 'gff') { + return new GffDataFetcher({ ...spec.data, assembly: spec.assembly }); + } } /** diff --git a/src/data-fetchers/gff/gff-data-fetcher.ts b/src/data-fetchers/gff/gff-data-fetcher.ts index 89a01c9f9..d16786c88 100644 --- a/src/data-fetchers/gff/gff-data-fetcher.ts +++ b/src/data-fetchers/gff/gff-data-fetcher.ts @@ -10,6 +10,7 @@ import type { ModuleThread } from 'threads'; import type { Assembly, GffData } from '@gosling-lang/gosling-schema'; import type { WorkerApi, TilesetInfo, GffTile, EmptyTile } from './gff-worker'; import type { TabularDataFetcher } from '../utils'; +import { uuid } from '../../core/utils/uuid'; const DEBOUNCE_TIME = 200; @@ -26,8 +27,8 @@ class GffDataFetcher implements TabularDataFetcher { private fetchTimeout?: ReturnType; private worker: Promise>; - constructor(HGC: import('@higlass/types').HGC, config: GFFDataConfig) { - this.uid = HGC.libraries.slugid.nice(); + constructor(config: GFFDataConfig) { + this.uid = uuid(); this.prevRequestTime = 0; this.toFetch = new Set(); const { url, indexUrl, assembly, ...options } = config; From 7f217a566bc7c141484f88e820dd9c5502b2b007 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Sat, 6 Jul 2024 19:19:06 -0400 Subject: [PATCH 131/139] feat: bam --- demo/renderer/dataFetcher.ts | 5 ++++- src/data-fetchers/bam/bam-data-fetcher.ts | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/demo/renderer/dataFetcher.ts b/demo/renderer/dataFetcher.ts index 8c5266839..c9cc96730 100644 --- a/demo/renderer/dataFetcher.ts +++ b/demo/renderer/dataFetcher.ts @@ -1,6 +1,6 @@ import { DataFetcher } from '@higlass/datafetcher'; import { fakePubSub } from '@higlass/utils'; -import { BigWigDataFetcher, CsvDataFetcher, GffDataFetcher, JsonDataFetcher } from '@data-fetchers'; +import { BigWigDataFetcher, CsvDataFetcher, GffDataFetcher, JsonDataFetcher, BamDataFetcher } from '@data-fetchers'; import type { ProcessedTrack } from 'demo/track-def/types'; export function getDataFetcher(spec: ProcessedTrack) { @@ -27,6 +27,9 @@ export function getDataFetcher(spec: ProcessedTrack) { if (spec.data.type == 'gff') { return new GffDataFetcher({ ...spec.data, assembly: spec.assembly }); } + if (spec.data.type == 'bam') { + return new BamDataFetcher({ ...spec.data, assembly: spec.assembly }); + } } /** diff --git a/src/data-fetchers/bam/bam-data-fetcher.ts b/src/data-fetchers/bam/bam-data-fetcher.ts index 16cc608d1..59e4da7e8 100644 --- a/src/data-fetchers/bam/bam-data-fetcher.ts +++ b/src/data-fetchers/bam/bam-data-fetcher.ts @@ -10,6 +10,7 @@ import type { ModuleThread } from 'threads'; import type { WorkerApi, TilesetInfo, Tiles, Segment, Junction, SegmentWithMate } from './bam-worker'; import { computeChromSizes } from '../../core/utils/assembly'; import type { TabularDataFetcher } from '../utils'; +import { uuid } from '../../core/utils/uuid'; const DEBOUNCE_TIME = 200; @@ -35,8 +36,8 @@ class BamDataFetcher implements TabularDataFetcher(new Worker()).then(async worker => { From f21743244fe46697e0e71598c68f000ddbdfd598 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Sat, 6 Jul 2024 19:20:14 -0400 Subject: [PATCH 132/139] feat: bed data fetcher --- demo/renderer/dataFetcher.ts | 5 ++++- src/data-fetchers/bed/bed-data-fetcher.ts | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/demo/renderer/dataFetcher.ts b/demo/renderer/dataFetcher.ts index c9cc96730..5b8cd3e87 100644 --- a/demo/renderer/dataFetcher.ts +++ b/demo/renderer/dataFetcher.ts @@ -1,6 +1,6 @@ import { DataFetcher } from '@higlass/datafetcher'; import { fakePubSub } from '@higlass/utils'; -import { BigWigDataFetcher, CsvDataFetcher, GffDataFetcher, JsonDataFetcher, BamDataFetcher } from '@data-fetchers'; +import { BigWigDataFetcher, CsvDataFetcher, GffDataFetcher, JsonDataFetcher, BamDataFetcher, BedDataFetcher } from '@data-fetchers'; import type { ProcessedTrack } from 'demo/track-def/types'; export function getDataFetcher(spec: ProcessedTrack) { @@ -30,6 +30,9 @@ export function getDataFetcher(spec: ProcessedTrack) { if (spec.data.type == 'bam') { return new BamDataFetcher({ ...spec.data, assembly: spec.assembly }); } + if (spec.data.type == 'bed') { + return new BedDataFetcher({ ...spec.data, assembly: spec.assembly }); + } } /** diff --git a/src/data-fetchers/bed/bed-data-fetcher.ts b/src/data-fetchers/bed/bed-data-fetcher.ts index 2bcd74d1f..31bc23aa1 100644 --- a/src/data-fetchers/bed/bed-data-fetcher.ts +++ b/src/data-fetchers/bed/bed-data-fetcher.ts @@ -12,6 +12,7 @@ import type { Assembly, BedData } from '@gosling-lang/gosling-schema'; import type { WorkerApi, TilesetInfo } from './bed-worker'; import type { BedTile, EmptyTile } from './bed-worker'; import type { TabularDataFetcher } from '../utils'; +import { uuid } from '../../core/utils/uuid'; const DEBOUNCE_TIME = 200; @@ -28,8 +29,8 @@ class BedDataFetcher implements TabularDataFetcher { private fetchTimeout?: ReturnType; private worker: Promise>; - constructor(HGC: import('@higlass/types').HGC, config: BedDataConfig) { - this.uid = HGC.libraries.slugid.nice(); + constructor(config: BedDataConfig) { + this.uid = uuid(); this.prevRequestTime = 0; this.toFetch = new Set(); const { url, indexUrl, assembly, ...options } = config; From fd2b1ee0278908a001444c905e0b1d6647524b43 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Sat, 6 Jul 2024 19:23:05 -0400 Subject: [PATCH 133/139] feat: vcf data fetcher --- demo/renderer/dataFetcher.ts | 13 ++++++++++++- src/data-fetchers/vcf/vcf-data-fetcher.ts | 5 +++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/demo/renderer/dataFetcher.ts b/demo/renderer/dataFetcher.ts index 5b8cd3e87..af4e40e02 100644 --- a/demo/renderer/dataFetcher.ts +++ b/demo/renderer/dataFetcher.ts @@ -1,6 +1,14 @@ import { DataFetcher } from '@higlass/datafetcher'; import { fakePubSub } from '@higlass/utils'; -import { BigWigDataFetcher, CsvDataFetcher, GffDataFetcher, JsonDataFetcher, BamDataFetcher, BedDataFetcher } from '@data-fetchers'; +import { + BigWigDataFetcher, + CsvDataFetcher, + GffDataFetcher, + JsonDataFetcher, + BamDataFetcher, + BedDataFetcher, + VcfDataFetcher +} from '@data-fetchers'; import type { ProcessedTrack } from 'demo/track-def/types'; export function getDataFetcher(spec: ProcessedTrack) { @@ -33,6 +41,9 @@ export function getDataFetcher(spec: ProcessedTrack) { if (spec.data.type == 'bed') { return new BedDataFetcher({ ...spec.data, assembly: spec.assembly }); } + if (spec.data.type == 'vcf') { + return new VcfDataFetcher({ ...spec.data, assembly: spec.assembly }); + } } /** diff --git a/src/data-fetchers/vcf/vcf-data-fetcher.ts b/src/data-fetchers/vcf/vcf-data-fetcher.ts index 8e8334114..fa5acd2f7 100644 --- a/src/data-fetchers/vcf/vcf-data-fetcher.ts +++ b/src/data-fetchers/vcf/vcf-data-fetcher.ts @@ -12,6 +12,7 @@ import type { Assembly, VcfData } from '@gosling-lang/gosling-schema'; import type { WorkerApi, TilesetInfo } from './vcf-worker'; import type { TabularDataFetcher } from '../utils'; import { getSubstitutionType, getMutationType } from './utils'; +import { uuid } from '../../core/utils/uuid'; const DEBOUNCE_TIME = 200; @@ -51,8 +52,8 @@ class VcfDataFetcher implements TabularDataFetcher { private fetchTimeout?: ReturnType; private worker: Promise>; - constructor(HGC: import('@higlass/types').HGC, config: VcfData & { assembly: Assembly }) { - this.uid = HGC.libraries.slugid.nice(); + constructor(config: VcfData & { assembly: Assembly }) { + this.uid = uuid(); this.prevRequestTime = 0; this.toFetch = new Set(); const { url, indexUrl, assembly, ...options } = config; From c2d0548ce9690dc98872564c8672a58166be2451 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Sat, 6 Jul 2024 23:36:32 -0400 Subject: [PATCH 134/139] feat: basic tooltip --- src/pixi-manager/pixi-manager.ts | 4 +-- .../gosling-track/gosling-track-plot.ts | 36 +++++++++++++++++++ src/tracks/gosling-track/gosling-track.ts | 25 ++++++------- 3 files changed, 51 insertions(+), 14 deletions(-) diff --git a/src/pixi-manager/pixi-manager.ts b/src/pixi-manager/pixi-manager.ts index 474213699..0f7c27f8a 100644 --- a/src/pixi-manager/pixi-manager.ts +++ b/src/pixi-manager/pixi-manager.ts @@ -34,9 +34,9 @@ export class PixiManager { backgroundColor: 0xffffff, eventMode: 'static', eventFeatures: { - move: false, + move: true, globalMove: false, - click: false, + click: true, wheel: false } }); diff --git a/src/tracks/gosling-track/gosling-track-plot.ts b/src/tracks/gosling-track/gosling-track-plot.ts index 88da264bc..48bb1300b 100644 --- a/src/tracks/gosling-track/gosling-track-plot.ts +++ b/src/tracks/gosling-track/gosling-track-plot.ts @@ -102,6 +102,42 @@ export class GoslingTrack extends GoslingTrackClass implements Plot { const newScaleY = this._refYScale.domain(this.yDomain.value); this.zoomed(newScaleX, newScaleY); }); + this.addTooltip(); + } + + /** When the tooltip option is used, the tooltip div will be populated sample information */ + addTooltip() { + const div = document.createElement('tooltip'); + div.style.position = 'absolute'; + div.style.pointerEvents = 'none'; + div.style.backgroundColor = 'white'; + div.style.borderRadius = '5px'; + div.style.border = '1px solid #dddddd'; + div.style.boxSizing = 'border-box'; + div.style.fontSize = '10px'; + this.domOverlay.appendChild(div); + + this.domOverlay.addEventListener('mousemove', (e: MouseEvent) => { + const rect = this.domOverlay.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + div.style.left = `${x}px`; + div.style.top = `${y}px`; + const tooltip = this.getMouseOverHtml(x, y); + if (tooltip === '') { + div.innerHTML = ''; + div.style.display = 'none'; + } else { + div.innerHTML = tooltip; + div.style.display = 'block'; + } + }); + this.domOverlay.addEventListener('mouseleave', () => { + div.innerHTML = ''; + }); + this.domOverlay.addEventListener('mousedown', () => { + div.style.display = 'none'; + }); } addInteractor(interactor: (plot: GoslingTrack) => void) { diff --git a/src/tracks/gosling-track/gosling-track.ts b/src/tracks/gosling-track/gosling-track.ts index d9e5cc3b2..978731acf 100644 --- a/src/tracks/gosling-track/gosling-track.ts +++ b/src/tracks/gosling-track/gosling-track.ts @@ -203,23 +203,25 @@ export class GoslingTrackClass extends TiledPixiTrack this.pMain.addChild(this.pMouseSelection); // Enable click event - this.pMask.interactive = true; this.mRangeBrush = new LinearBrushModel(this.#gBrush, this.options.spec.style?.brush); this.mRangeBrush.on('brush', this.#onRangeBrush.bind(this)); - this.pMask.on('mousedown', (e: PIXI.InteractionEvent) => { - const { x, y } = e.data.getLocalPosition(this.pMain); - this.#onMouseDown(x, y, e.data.originalEvent.altKey); - }); - this.pMask.on('mouseup', (e: PIXI.InteractionEvent) => { + this.pMain.onmousedown = e => { + const { x, y } = e.getLocalPosition(this.pMain); + this.#onMouseDown(x, y, e.originalEvent.altKey); + }; + this.pMain.onmouseup = e => { const { x, y } = e.data.getLocalPosition(this.pMain); this.#onMouseUp(x, y); - }); - this.pMask.on('mousemove', (e: PIXI.InteractionEvent) => { - const { x } = e.data.getLocalPosition(this.pMain); + }; + this.pMain.onmousemove = e => { + const { x } = e.getLocalPosition(this.pMain); + // console.warn(x); + // const html = this.getMouseOverHtml(x, y); + // console.warn(html); this.#onMouseMove(x); - }); - this.pMask.on('mouseout', this.#onMouseOut.bind(this)); + }; + this.pMain.onmouseout = () => this.#onMouseOut(); this.flipText = this.options.spec.orientation === 'vertical'; // We do not use HiGlass' trackNotFoundText @@ -1110,7 +1112,6 @@ export class GoslingTrackClass extends TiledPixiTrack // TODO: We do not yet support range selection on circular tracks return; } - if (this.#isRangeBrushActivated) { this.mRangeBrush.updateRange([mouseX, this.#mouseDownX]).drawBrush().visible().disable(); } From beebbbfbc07cc8b3acdf6edcfaba96f8834c5d3b Mon Sep 17 00:00:00 2001 From: etowahadams Date: Sun, 7 Jul 2024 00:43:15 -0400 Subject: [PATCH 135/139] feat: working tooltip on static --- .../gosling-track/gosling-track-plot.ts | 65 ++++++++++++------- src/tracks/gosling-track/gosling-track.ts | 47 ++++++-------- 2 files changed, 62 insertions(+), 50 deletions(-) diff --git a/src/tracks/gosling-track/gosling-track-plot.ts b/src/tracks/gosling-track/gosling-track-plot.ts index 48bb1300b..bed446049 100644 --- a/src/tracks/gosling-track/gosling-track-plot.ts +++ b/src/tracks/gosling-track/gosling-track-plot.ts @@ -107,36 +107,57 @@ export class GoslingTrack extends GoslingTrackClass implements Plot { /** When the tooltip option is used, the tooltip div will be populated sample information */ addTooltip() { - const div = document.createElement('tooltip'); - div.style.position = 'absolute'; - div.style.pointerEvents = 'none'; - div.style.backgroundColor = 'white'; - div.style.borderRadius = '5px'; - div.style.border = '1px solid #dddddd'; - div.style.boxSizing = 'border-box'; - div.style.fontSize = '10px'; - this.domOverlay.appendChild(div); + /** Helper function to get the position relative to the overlay div */ + function getRelativePosition(element: HTMLElement, e: MouseEvent) { + const rect = element.getBoundingClientRect(); + return { + x: e.clientX - rect.left, + y: e.clientY - rect.top + }; + } + const tooltipDiv = document.createElement('tooltip'); + const tooltipStyles = { + position: 'absolute', + pointerEvents: 'none', + backgroundColor: 'white', + borderRadius: '5px', + border: '1px solid #dddddd', + boxSizing: 'border-box', + fontSize: '10px' + }; + Object.assign(tooltipDiv.style, tooltipStyles); + this.domOverlay.appendChild(tooltipDiv); + // When the mouse moves over the overlay div, update the tooltip position this.domOverlay.addEventListener('mousemove', (e: MouseEvent) => { - const rect = this.domOverlay.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - div.style.left = `${x}px`; - div.style.top = `${y}px`; + const { x, y } = getRelativePosition(this.domOverlay, e); + this.onMouseMove(x); + // Update the tooltip position + tooltipDiv.style.left = `${x}px`; + tooltipDiv.style.top = `${y}px`; const tooltip = this.getMouseOverHtml(x, y); - if (tooltip === '') { - div.innerHTML = ''; - div.style.display = 'none'; + if (tooltip === '' || this.isRangeBrushActivated) { + tooltipDiv.innerHTML = ''; + tooltipDiv.style.display = 'none'; } else { - div.innerHTML = tooltip; - div.style.display = 'block'; + tooltipDiv.innerHTML = tooltip; + tooltipDiv.style.display = 'block'; } }); + // When the mouse leaves the overlay div, clear the tooltip this.domOverlay.addEventListener('mouseleave', () => { - div.innerHTML = ''; + this.onMouseOut(); + tooltipDiv.innerHTML = ''; + }); + // When the mouse is clicked, hide the tooltip. Likely dragging a brush + this.domOverlay.addEventListener('mousedown', e => { + tooltipDiv.style.display = 'none'; + const { x, y } = getRelativePosition(this.domOverlay, e); + this.onMouseDown(x, y, e.altKey); }); - this.domOverlay.addEventListener('mousedown', () => { - div.style.display = 'none'; + this.domOverlay.addEventListener('mouseup', e => { + const { x, y } = getRelativePosition(this.domOverlay, e); + this.onMouseUp(x, y); }); } diff --git a/src/tracks/gosling-track/gosling-track.ts b/src/tracks/gosling-track/gosling-track.ts index 978731acf..c49dfe0a2 100644 --- a/src/tracks/gosling-track/gosling-track.ts +++ b/src/tracks/gosling-track/gosling-track.ts @@ -151,7 +151,7 @@ export class GoslingTrackClass extends TiledPixiTrack pMouseSelection = new PIXI.Graphics(); #mouseDownX = 0; #mouseDownY = 0; - #isRangeBrushActivated = false; + isRangeBrushActivated = false; #gBrush: Selection; #loadingTextStyleObj = new PIXI.TextStyle(loadingTextStyle); #loadingTextBg = new PIXI.Graphics(); @@ -205,23 +205,13 @@ export class GoslingTrackClass extends TiledPixiTrack // Enable click event this.mRangeBrush = new LinearBrushModel(this.#gBrush, this.options.spec.style?.brush); this.mRangeBrush.on('brush', this.#onRangeBrush.bind(this)); - - this.pMain.onmousedown = e => { - const { x, y } = e.getLocalPosition(this.pMain); - this.#onMouseDown(x, y, e.originalEvent.altKey); - }; - this.pMain.onmouseup = e => { - const { x, y } = e.data.getLocalPosition(this.pMain); - this.#onMouseUp(x, y); - }; - this.pMain.onmousemove = e => { - const { x } = e.getLocalPosition(this.pMain); - // console.warn(x); - // const html = this.getMouseOverHtml(x, y); - // console.warn(html); - this.#onMouseMove(x); - }; - this.pMain.onmouseout = () => this.#onMouseOut(); + // this.pMain.onmousemove = e => { + // const { x } = e.getLocalPosition(this.pMain); + // this.onMouseMove(x); + // }; + // this.pMain.onmouseout = () => { + // this.#onMouseOut(); + // }; this.flipText = this.options.spec.orientation === 'vertical'; // We do not use HiGlass' trackNotFoundText @@ -1094,7 +1084,7 @@ export class GoslingTrackClass extends TiledPixiTrack return; } - #onMouseDown(mouseX: number, mouseY: number, isAltPressed: boolean) { + onMouseDown(mouseX: number, mouseY: number, isAltPressed: boolean) { // Record these so that we do not triger click event when dragged. this.#mouseDownX = mouseX; this.#mouseDownY = mouseY; @@ -1102,22 +1092,23 @@ export class GoslingTrackClass extends TiledPixiTrack // Determine whether to activate a range brush const mouseEvents = this.options.spec.mouseEvents; const rangeSelectEnabled = !!mouseEvents || (IsMouseEventsDeep(mouseEvents) && !!mouseEvents.rangeSelect); - this.#isRangeBrushActivated = rangeSelectEnabled && isAltPressed; + this.isRangeBrushActivated = rangeSelectEnabled && isAltPressed; this.pMouseHover.clear(); } - #onMouseMove(mouseX: number) { + onMouseMove(mouseX: number) { if (this.options.spec.layout === 'circular') { // TODO: We do not yet support range selection on circular tracks return; } - if (this.#isRangeBrushActivated) { + if (this.isRangeBrushActivated) { this.mRangeBrush.updateRange([mouseX, this.#mouseDownX]).drawBrush().visible().disable(); } } - #onMouseUp(mouseX: number, mouseY: number) { + /** Used for range selections */ + onMouseUp(mouseX: number, mouseY: number) { // `trackClick` API this.#publishTrackEvents('trackClick', mouseX, mouseY); @@ -1125,7 +1116,7 @@ export class GoslingTrackClass extends TiledPixiTrack const clickEnabled = !!mouseEvents || (IsMouseEventsDeep(mouseEvents) && !!mouseEvents.click); const isDrag = Math.sqrt((this.#mouseDownX - mouseX) ** 2 + (this.#mouseDownY - mouseY) ** 2) > 1; - if (!this.#isRangeBrushActivated && !isDrag) { + if (!this.isRangeBrushActivated && !isDrag) { // Clicking outside the brush should remove the brush and the selection. this.mRangeBrush.clear(); this.pMouseSelection.clear(); @@ -1134,7 +1125,7 @@ export class GoslingTrackClass extends TiledPixiTrack this.mRangeBrush.enable(); } - this.#isRangeBrushActivated = false; + this.isRangeBrushActivated = false; if (!this.tilesetInfo) { // Do not have enough information @@ -1161,8 +1152,8 @@ export class GoslingTrackClass extends TiledPixiTrack } } - #onMouseOut() { - this.#isRangeBrushActivated = false; + onMouseOut() { + this.isRangeBrushActivated = false; document.body.style.cursor = 'default'; this.pMouseHover.clear(); } @@ -1362,7 +1353,7 @@ export class GoslingTrackClass extends TiledPixiTrack // `trackMouseOver` API this.#publishTrackEvents('trackMouseOver', mouseX, mouseY); - if (this.#isRangeBrushActivated) { + if (this.isRangeBrushActivated) { // In the middle of drawing range brush. return ''; } From 6be04c17630c6b56daf516f2c0bb2cc019b826e8 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Sun, 7 Jul 2024 01:22:39 -0400 Subject: [PATCH 136/139] no zoom pan with alt key --- src/interactors/panZoom.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/interactors/panZoom.ts b/src/interactors/panZoom.ts index f568ad3ce..609f42549 100644 --- a/src/interactors/panZoom.ts +++ b/src/interactors/panZoom.ts @@ -42,10 +42,11 @@ export function panZoom(plot: Plot, xDomain: Signal<[number, number]>, yDomain?: const isRect = event.target.tagName === 'rect'; const isMousedown = event.type === 'mousedown'; const isDraggingBrush = isRect && isMousedown; + const isAltPressed = event.altKey; // Here are the default filters const defaultFilter = (!event.ctrlKey || event.type === 'wheel') && !event.button; // Use the default filter and our custom filter - return defaultFilter && !isDraggingBrush; + return defaultFilter && !isDraggingBrush && !isAltPressed; }) // @ts-expect-error We need to reset the transform when the user stops zooming .on('end', () => (plot.domOverlay.__zoom = new ZoomTransform(1, 0, 0))) From 0e34e751f1b19d9711293b4c8a94284669c04249 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Sun, 7 Jul 2024 12:59:37 -0400 Subject: [PATCH 137/139] clear brush when click --- src/tracks/gosling-track/gosling-track-plot.ts | 8 ++++++-- src/tracks/gosling-track/gosling-track.ts | 9 +++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/tracks/gosling-track/gosling-track-plot.ts b/src/tracks/gosling-track/gosling-track-plot.ts index bed446049..361dc568b 100644 --- a/src/tracks/gosling-track/gosling-track-plot.ts +++ b/src/tracks/gosling-track/gosling-track-plot.ts @@ -98,8 +98,8 @@ export class GoslingTrack extends GoslingTrackClass implements Plot { // Every time the domain gets changed we want to update the zoom effect(() => { - const newScaleX = this._refXScale.domain(this.xDomain.value); - const newScaleY = this._refYScale.domain(this.yDomain.value); + const newScaleX = scaleLinear().range(this._refXScale.range()).domain(this.xDomain.value); + const newScaleY = scaleLinear().range(this._refYScale.range()).domain(this.yDomain.value); this.zoomed(newScaleX, newScaleY); }); this.addTooltip(); @@ -159,6 +159,10 @@ export class GoslingTrack extends GoslingTrackClass implements Plot { const { x, y } = getRelativePosition(this.domOverlay, e); this.onMouseUp(x, y); }); + this.domOverlay.addEventListener('click', e => { + const { x, y } = getRelativePosition(this.domOverlay, e); + this.onMouseClick(x, y); + }); } addInteractor(interactor: (plot: GoslingTrack) => void) { diff --git a/src/tracks/gosling-track/gosling-track.ts b/src/tracks/gosling-track/gosling-track.ts index c49dfe0a2..ccc58e82d 100644 --- a/src/tracks/gosling-track/gosling-track.ts +++ b/src/tracks/gosling-track/gosling-track.ts @@ -1157,6 +1157,15 @@ export class GoslingTrackClass extends TiledPixiTrack document.body.style.cursor = 'default'; this.pMouseHover.clear(); } + + onMouseClick(mouseX: number, mouseY: number) { + const isDrag = Math.sqrt((this.#mouseDownX - mouseX) ** 2 + (this.#mouseDownY - mouseY) ** 2) > 1; + // Clear the brush if we are not dragging + if (!isDrag) { + this.mRangeBrush.clear(); + this.pMouseSelection.clear(); + } + } /** * From all tiles and overlaid tracks, collect element(s) that are withing a mouse position. */ From 69ff028c945d56d7284f524f7bccb7af292855b4 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Sun, 7 Jul 2024 22:54:33 -0400 Subject: [PATCH 138/139] clarify linked encoding --- demo/linking/linkedEncoding.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/linking/linkedEncoding.ts b/demo/linking/linkedEncoding.ts index 01fe465a4..08ae574cc 100644 --- a/demo/linking/linkedEncoding.ts +++ b/demo/linking/linkedEncoding.ts @@ -13,7 +13,7 @@ export interface LinkedEncoding { signal: Signal; tracks: { id: string; - encoding: 'x' | 'brush'; + encoding: 'x' | 'y' | 'brush'; }[]; } From 6b62a32955f16d2f911ce92fc8f107eca1c450e0 Mon Sep 17 00:00:00 2001 From: etowahadams Date: Sun, 7 Jul 2024 23:05:51 -0400 Subject: [PATCH 139/139] add explanation --- explanation.md | 122 ++++++++++++++++++++++++++++++++++++++ img/diagrams/overview.png | Bin 0 -> 363473 bytes 2 files changed, 122 insertions(+) create mode 100644 explanation.md create mode 100644 img/diagrams/overview.png diff --git a/explanation.md b/explanation.md new file mode 100644 index 000000000..21ca34590 --- /dev/null +++ b/explanation.md @@ -0,0 +1,122 @@ +# How Gosling works + +Gosling contains two main components: +1. A language for specifying genomic visualizations, a Gosling specification. +2. A renderer which take a Gosling specification and renders it to the screen + +Potentially there could be many renderers for Gosling, but currently there is only one which is implemented in this repository. It relies on PixiJS, a WebGL 2D graphics rendering library. + +Here is a diagram which captures the inner workings of the renderer at a high level: + +![Overview diagram](img/diagrams/overview.png) + +After a user creates a Gosling specification, the following sequence of events occur: + +1. Gosling specification is compiled. This creates a processed Gosling specification and bounding boxes for each of the tracks. +2. Using the processed specification, identify which features are shared between tracks. We call these linked encodings +3. From the compiled specification, generate options for each class associated with each type of track. +4. Using the linked encodings and the track definitions, instantiate track classes which render the the visualization to the screen. + +# Definitions + +There are many concepts that are frequently used in Gosling. Feel free to skip this on a first read. + +Track: A track is a data visualization which has an domain with genomic coordinates, a “genomic axis”. Typically, all tracks will have an x-domain with genomic coordinates. Some tracks, like a heatmap track, also contain a genomic y-domain. + +View: A view is a collection of tracks, usually adjacent to each other. The view defines things for the tracks within it. + +A *track class* is a class which corresponds to a certain visual element, such as an axis or a plot. Note that this is different from a *track*. Multiple track classes may be needed to render a single track, for example, it is common for a single track to correspond to a GoslingTrack and a AxisTrack. Here are all of the track classes: + +- GoslingTrack +- HeatmapTrack +- AxisTrack +- BrushLinearTrack +- BrushCircularTrack +- DummyTrack + +Track classes commonly use PixiJS, a WebGl 2D graphics rendering library. + +A *processed spec* refers to a Gosling specification which has been compiled (the resulting specification after running `compile()`). Unlike the Gosling specification, many properties in the processed spec are always defined, such as track `id`. Thus the type associated with this processed spec is different from a Gosling spec, although they resemble each other. Each track in the processed spec has the type `ProcessedTrack`. + +A *track definition*, or `TrackDef`, contains all of the information (besides domain signals) to render a single track to the canvas. There are two main components to a TrackDef: a bounding box, and the options for the corresponding track class. + +```jsx +export interface TrackDef { + type: TrackType; + trackId: string; + boundingBox: { x: number; y: number; width: number; height: number }; + options: T; +} +``` + +A *linked encoding*, or `LinkedEncoding` , is an object containing a signal and information about which encodings in classes use that signal. Track class instances will use the appropriate signal found in a linked encoding. + +```jsx +interface LinkedEncoding { + linkingId: string; + signal: Signal; + tracks: { + id: string; + encoding: 'x' | 'y' | 'brush'; + }[]; +} +``` + +## Linking between tracks + +### How are tracks linked together + +Gosling relies on *Signals* to share state between tracks such as x-axis domain. An explanation of signals can be found [here](https://preactjs.com/blog/introducing-signals/). + +Let’s take a look a what happens when a user zooms into a track. When a user zooms in on a track, the x-domain of the track will change. If other tracks share the same x-domain signal, they will also get zoomed in. This is because each track contains an `effect` which updates the track scales when the signal changes. + +Currently signals are used to link the genomic x- and y-domains of tracks, as well as brushes. + +### Creating linked encodings + +From a processed Gosling specification, we identify which encodings are linked and create signals for them. + +**Creating x and y domain signals** + +Tracks that are in the same view share the same x-axis and y-axis genomic domain and therefore the same signal. There are two exceptions to this: + +1. Track has a `linkingId` which is different from the `linkingId` defined in the view +2. Track has a `domain` defined in the `x` encoding which is different from the `xDomain` defined in the view + +Views can also receive a `linkingId` property. Tracks which share the same `linkingId` in their genomic x- or y-encoding will share the same x-domain as the the view. + +## Creating track definitions + +From `compile()` we receive a list of `TrackInfo` objects. Each object contains a `ProcessedTrack` and a bounding box (x, y, width, height). We want to use this information to generate the options for each track class. + +Let’s look at an example to get a better understanding: + +Here is a single `ProcessedTrack` that a `TrackInfo` might contain: + +```jsx +{ + _overlay: [{ mark: "brush", x: { linkingId: "brush" } }], + x: { axis: "top", field: "x", type: "genomic" }, + y: { axis: "left", field: "y", type: "quantitative" }, + mark: "point", + ... +}; +``` + +For this single `ProcessedTrack`, we need to generate options for three different track classes + +- GoslingTrack, since we have a point mark. +- AxisTrack, since there the `x` encoding specifies axis on the top. +- BrushTrack, since the track has an an `_overlay` containing a brush track. + +# Compiler Improvements + +Here are a list of things that I think could be improved on the Gosling compiler. + +The original purpose of the Gosling compiler was to convert a Gosling spec to a HiGlass spec. But since then, the purpose of the compiler has evolved. + +1. **Fix: Better bounding boxes for overlaid tracks:** Tracks can be overlaid on top of each other. When a track has a x-axis, the height is increased by 30px. However, the (x,y) position of every overlaid track is the same regardless of whether there is an x-axis or not. This is makes the (x,y) of the bounding box incorrect. The current workaround is to require that overlaid tracks define an axis in the same way. +2. **Refactor: types for final compiled spec**: Currently I have something called “ProcessedTrack” which begins to capture the final representation of a track. This needs to be made more complete to ensure that we can accurately generate track definitions. The final representation is similar to a Gosling spec but with some required properties and some new properties. **Benefit: Makes generating options from compiled spec more robust.** +3. **Refactor: types for intermediate representations**: There are not good types for the intermediate representations used in the compiler due to the ad hoc development of the compiler. This makes working on the compiler much more difficult. **Benefit: Improved develop experience. Do this before adding support for plug-in tracks.** +4. **Feat: Support for plug-in tracks**: We want the compiler to also support plug-in tracks. i.e. user creates a gosling spec which contains typical Gosling tracks but also +5. **Refactor: Remove HiGlass spec**: We do not rely on HiGlass to for rendering, there are parts of the compiler that are unneeded. diff --git a/img/diagrams/overview.png b/img/diagrams/overview.png new file mode 100644 index 0000000000000000000000000000000000000000..77164400373621451a21f3c167135a506b52a484 GIT binary patch literal 363473 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 z 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[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 RDf!kYzz;Q)N);?Z{tv?)s;K|~ literal 0 HcmV?d00001