diff --git a/README.md b/README.md index 462c26e..408d760 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ # LocalZoom -This is a demonstration of loading GWAS results via the web browser, fetching only the data -required for that region. It works with Tabixed GWAS data files in a variety of formats. +This is a tool for creating interactive [LocusZoom.js](https://github.com/statgen/locuszoom/) GWAS plots via the web browser, without uploading sensitive data to a remote web server. It works with Tabix-indexed data files in a variety of formats, and supports adding companion tracks (such as BED files). -A live [demonstration](https://statgen.github.io/localzoom/) is available. +Try it for yourself at [https://statgen.github.io/localzoom/](https://statgen.github.io/localzoom/) ## Getting help @@ -26,7 +25,7 @@ npm run serve ### Building for production The production build is a minified, concatenated bundle suitable for distribution on a server. -In order to use the Sentry error logging and Google Analytics feature, you will need to create a +In order to use the Sentry error logging and Google Analytics features, you will need to create a file named *.env.production.local* (ignored by git) with the following contents (both values are optional if you don't want to use these features): ```dotenv @@ -47,7 +46,7 @@ npm run deploy When ready, verify the built app and push to production. -### Lints and fixes files +### Lint and fixes files This project uses a style and syntax checker for code quality. The following command can help to identify (and automatically fix) common issues. ``` diff --git a/package-lock.json b/package-lock.json index fa8ee59..9836e52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "localzoom", - "version": "0.7.5", + "version": "0.8.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1424,9 +1424,9 @@ "dev": true }, "@vue/test-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-1.1.3.tgz", - "integrity": "sha512-BAY1Cwe9JpkJseimC295EW3YlAmgIJI9OPkg2FSP62+PHZooB0B+wceDi9TYyU57oqzL0yLbcP73JKFpKiLc9A==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-1.2.2.tgz", + "integrity": "sha512-P+yiAsszoy8z1TqXiVUnAZaJj0WGGz5fCxm4bOSI6Cpwy1+PNYwYxDv0ROAA/SUtOPppV+aD8tp/QWwxf8ROJw==", "dev": true, "requires": { "dom-event-types": "^1.0.0", @@ -2026,16 +2026,16 @@ "dev": true }, "autoprefixer": { - "version": "9.8.6", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-9.8.6.tgz", - "integrity": "sha512-XrvP4VVHdRBCdX1S3WXVD8+RyG9qeb1D5Sn1DeLiG2xfSpzellk5k54xbUERJ3M5DggQxes39UGOTP8CFrEGbg==", + "version": "9.8.8", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-9.8.8.tgz", + "integrity": "sha512-eM9d/swFopRt5gdJ7jrpCwgvEMIayITpojhkkSMRsFHYuH5bkSQ4p/9qTEHtmNudUZh22Tehu7I6CxAW0IXTKA==", "dev": true, "requires": { "browserslist": "^4.12.0", "caniuse-lite": "^1.0.30001109", - "colorette": "^1.2.1", "normalize-range": "^0.1.2", "num2fraction": "^1.2.2", + "picocolors": "^0.2.1", "postcss": "^7.0.32", "postcss-value-parser": "^4.1.0" } @@ -2850,16 +2850,16 @@ "dev": true }, "chai": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.0.tgz", - "integrity": "sha512-/BFd2J30EcOwmdOgXvVsmM48l0Br0nmZPlO0uOW4XKh6kpsUumRXBgPV+IlaqFaqr9cYbeoZAM1Npx0i4A+aiA==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.4.tgz", + "integrity": "sha512-yS5H68VYOCtN1cjfwumDSuzn/9c+yza4f3reKXlE5rUg7SFcCEy90gJvydNgOYtblyf4Zi6jIWRnXOgErta0KA==", "dev": true, "requires": { "assertion-error": "^1.1.0", "check-error": "^1.0.2", "deep-eql": "^3.0.1", "get-func-name": "^2.0.0", - "pathval": "^1.1.0", + "pathval": "^1.1.1", "type-detect": "^4.0.5" } }, @@ -3241,9 +3241,9 @@ "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, "color-string": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.4.tgz", - "integrity": "sha512-57yF5yt8Xa3czSEW1jfQDE79Idk0+AkN/4KWad6tbdxUmAs3MvjxlWSWD4deYytcRfoZ9nhKyFl1kj5tBvidbw==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.6.0.tgz", + "integrity": "sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA==", "dev": true, "requires": { "color-name": "^1.0.0", @@ -3384,9 +3384,9 @@ } }, "config-chain": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.12.tgz", - "integrity": "sha512-a1eOIcu8+7lUInge4Rpf/n4Krkf3Dd9lqhljRzII1/Zno/kRtUWnznPO3jOKBmTEktkt3fkxisUcivoj0ebzoA==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", "dev": true, "requires": { "ini": "^1.3.4", @@ -3810,21 +3810,78 @@ "dev": true }, "cssnano": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-4.1.10.tgz", - "integrity": "sha512-5wny+F6H4/8RgNlaqab4ktc3e0/blKutmq8yNlBFXA//nSFFAqAngjNVRzUvCgYROULmZZUoosL/KSoZo5aUaQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-4.1.11.tgz", + "integrity": "sha512-6gZm2htn7xIPJOHY824ERgj8cNPgPxyCSnkXc4v7YvNW+TdVfzgngHcEhy/8D11kUWRUMbke+tC+AUcUsnMz2g==", "dev": true, "requires": { "cosmiconfig": "^5.0.0", - "cssnano-preset-default": "^4.0.7", + "cssnano-preset-default": "^4.0.8", "is-resolvable": "^1.0.0", "postcss": "^7.0.0" + }, + "dependencies": { + "cssnano-preset-default": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-4.0.8.tgz", + "integrity": "sha512-LdAyHuq+VRyeVREFmuxUZR1TXjQm8QQU/ktoo/x7bz+SdOge1YKc5eMN6pRW7YWBmyq59CqYba1dJ5cUukEjLQ==", + "dev": true, + "requires": { + "css-declaration-sorter": "^4.0.1", + "cssnano-util-raw-cache": "^4.0.1", + "postcss": "^7.0.0", + "postcss-calc": "^7.0.1", + "postcss-colormin": "^4.0.3", + "postcss-convert-values": "^4.0.1", + "postcss-discard-comments": "^4.0.2", + "postcss-discard-duplicates": "^4.0.2", + "postcss-discard-empty": "^4.0.1", + "postcss-discard-overridden": "^4.0.1", + "postcss-merge-longhand": "^4.0.11", + "postcss-merge-rules": "^4.0.3", + "postcss-minify-font-values": "^4.0.2", + "postcss-minify-gradients": "^4.0.2", + "postcss-minify-params": "^4.0.2", + "postcss-minify-selectors": "^4.0.2", + "postcss-normalize-charset": "^4.0.1", + "postcss-normalize-display-values": "^4.0.2", + "postcss-normalize-positions": "^4.0.2", + "postcss-normalize-repeat-style": "^4.0.2", + "postcss-normalize-string": "^4.0.2", + "postcss-normalize-timing-functions": "^4.0.2", + "postcss-normalize-unicode": "^4.0.1", + "postcss-normalize-url": "^4.0.1", + "postcss-normalize-whitespace": "^4.0.2", + "postcss-ordered-values": "^4.1.2", + "postcss-reduce-initial": "^4.0.3", + "postcss-reduce-transforms": "^4.0.2", + "postcss-svgo": "^4.0.3", + "postcss-unique-selectors": "^4.0.1" + } + }, + "postcss-svgo": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-4.0.3.tgz", + "integrity": "sha512-NoRbrcMWTtUghzuKSoIm6XV+sJdvZ7GZSc3wdBN0W19FTtp2ko8NqLsgoh/m9CzNhU3KLPvQmjIwtaNFkaFTvw==", + "dev": true, + "requires": { + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0", + "svgo": "^1.0.0" + } + }, + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true + } } }, "cssnano-preset-default": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-4.0.7.tgz", - "integrity": "sha512-x0YHHx2h6p0fCl1zY9L9roD7rnlltugGu7zXSKQx6k2rYw0Hi3IqxcoAGF7u9Q5w1nt7vK0ulxV8Lo+EvllGsA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-4.0.8.tgz", + "integrity": "sha512-LdAyHuq+VRyeVREFmuxUZR1TXjQm8QQU/ktoo/x7bz+SdOge1YKc5eMN6pRW7YWBmyq59CqYba1dJ5cUukEjLQ==", "dev": true, "requires": { "css-declaration-sorter": "^4.0.1", @@ -3855,7 +3912,7 @@ "postcss-ordered-values": "^4.1.2", "postcss-reduce-initial": "^4.0.3", "postcss-reduce-transforms": "^4.0.2", - "postcss-svgo": "^4.0.2", + "postcss-svgo": "^4.0.3", "postcss-unique-selectors": "^4.0.1" } }, @@ -6554,12 +6611,6 @@ "integrity": "sha1-wc56MWjIxmFAM6S194d/OyJfnDg=", "dev": true }, - "html-comment-regex": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/html-comment-regex/-/html-comment-regex-1.1.2.tgz", - "integrity": "sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ==", - "dev": true - }, "html-encoding-sniffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-1.0.2.tgz", @@ -7332,15 +7383,6 @@ "integrity": "sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==", "dev": true }, - "is-svg": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-3.0.0.tgz", - "integrity": "sha512-gi4iHK53LR2ujhLVVj+37Ykh9GLqYHX6JOVXbLAucaG/Cqw9xwdFOjDM2qeifLs1sF1npXXFvDu0r5HNgCMrzQ==", - "dev": true, - "requires": { - "html-comment-regex": "^1.1.0" - } - }, "is-symbol": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", @@ -7419,24 +7461,17 @@ "dev": true }, "js-beautify": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.13.5.tgz", - "integrity": "sha512-MsXlH6Z/BiRYSkSRW3clNDqDjSpiSNOiG8xYVUBXt4k0LnGvDhlTGOlHX1VFtAdoLmtwjxMG5qiWKy/g+Ipv5w==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.14.0.tgz", + "integrity": "sha512-yuck9KirNSCAwyNJbqW+BxJqJ0NLJ4PwBUzQQACl5O3qHMBXVkXb/rD0ilh/Lat/tn88zSZ+CAHOlk0DsY7GuQ==", "dev": true, "requires": { "config-chain": "^1.1.12", "editorconfig": "^0.15.3", "glob": "^7.1.3", - "mkdirp": "^1.0.4", "nopt": "^5.0.0" }, "dependencies": { - "mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true - }, "nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -7616,8 +7651,13 @@ } }, "jszlib": { - "version": "git+ssh://git@github.com/dasmoth/jszlib.git#4e562c7b88749f3e1e402737ddef5f96bf0906c6", - "from": "jszlib@git+https://github.com/dasmoth/jszlib.git#4e562c7" + "version": "git+https://github.com/dasmoth/jszlib.git#4e562c7b88749f3e1e402737ddef5f96bf0906c6", + "from": "git+https://github.com/dasmoth/jszlib.git#4e562c7" + }, + "just-clone": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/just-clone/-/just-clone-3.2.1.tgz", + "integrity": "sha512-PFotEVrrzAnwuWTUOFquDShWrHnUnhxNrVs1VFqkNfnoH3Sn5XUlDOePYn2Vv5cN8xV2y69jf8qEoQHm7eoLnw==" }, "killable": { "version": "1.0.1", @@ -7803,10 +7843,14 @@ } }, "locuszoom": { - "version": "git+https://github.com/statgen/locuszoom.git#ea3894e8d06a040a0036844d83a222b577a3ca40", - "from": "git+https://github.com/statgen/locuszoom.git#ea3894e", + "version": "git+https://github.com/statgen/locuszoom.git#773eb8770a7935ee503eb08a9fb33084680ceac0", + "from": "git+https://github.com/statgen/locuszoom.git#773eb87", "requires": { - "d3": "^5.16.0" + "d3": "^5.16.0", + "gwas-credible-sets": "^0.1.0", + "just-clone": "^3.2.1", + "tabix-reader": "^1.0.1", + "undercomplicate": "^0.1.1" } }, "lodash": { @@ -9503,9 +9547,9 @@ "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=" }, "path-parse": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", - "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, "path-to-regexp": { @@ -9556,6 +9600,12 @@ "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", "dev": true }, + "picocolors": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", + "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==", + "dev": true + }, "picomatch": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", @@ -10312,12 +10362,11 @@ } }, "postcss-svgo": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-4.0.2.tgz", - "integrity": "sha512-C6wyjo3VwFm0QgBy+Fu7gCYOkCmgmClghO+pjcxvrcBKtiKt0uCF+hvbMO1fyv5BMImRK90SMb+dwUnfbGd+jw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-4.0.3.tgz", + "integrity": "sha512-NoRbrcMWTtUghzuKSoIm6XV+sJdvZ7GZSc3wdBN0W19FTtp2ko8NqLsgoh/m9CzNhU3KLPvQmjIwtaNFkaFTvw==", "dev": true, "requires": { - "is-svg": "^3.0.0", "postcss": "^7.0.0", "postcss-value-parser": "^3.0.0", "svgo": "^1.0.0" @@ -10904,9 +10953,9 @@ } }, "nth-check": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.0.tgz", - "integrity": "sha512-i4sc/Kj8htBrAiH1viZ0TgU8Y5XqCaV/FziYK6TBczxmeKm3AEFWqqF3195yKudrarqy7Zu80Ra5dobFjn9X/Q==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.1.tgz", + "integrity": "sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w==", "dev": true, "requires": { "boolbase": "^1.0.0" @@ -11178,9 +11227,9 @@ } }, "rxjs": { - "version": "6.6.6", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.6.tgz", - "integrity": "sha512-/oTwee4N4iWzAMAL9xdGKjkEHmIwupR3oXbQjCKywF1BeFohswF3vZdogbmEF6pZkOsXTzWkrZszrWpQTByYVg==", + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", + "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", "requires": { "tslib": "^1.9.0" } @@ -12298,6 +12347,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, "requires": { "ansi-regex": "^4.1.0" } @@ -12405,13 +12455,13 @@ "integrity": "sha512-AtGAUP9yLamN8X6yJqWHZqDlTb2DcEyoBHlNVRrVjyLyshVUVnVi0Fwvl4Ag8n49yGH7YoRYgMemmy5L0pQ6lg==", "requires": { "eslint": "^5.16.0", - "jszlib": "jszlib@git+https://github.com/dasmoth/jszlib.git#4e562c7" + "jszlib": "git+https://github.com/dasmoth/jszlib.git#4e562c7" }, "dependencies": { "acorn-jsx": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.1.tgz", - "integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==" + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==" }, "ajv": { "version": "6.12.6", @@ -12424,11 +12474,6 @@ "uri-js": "^4.2.2" } }, - "ansi-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", - "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=" - }, "chardet": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", @@ -12495,16 +12540,6 @@ "strip-json-comments": "^2.0.1", "table": "^5.2.3", "text-table": "^0.2.0" - }, - "dependencies": { - "strip-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", - "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", - "requires": { - "ansi-regex": "^3.0.0" - } - } } }, "eslint-scope": { @@ -12591,6 +12626,16 @@ "string-width": "^2.1.0", "strip-ansi": "^5.1.0", "through": "^2.3.6" + }, + "dependencies": { + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "requires": { + "ansi-regex": "^4.1.0" + } + } } }, "json-schema-traverse": { @@ -12631,6 +12676,21 @@ "is-fullwidth-code-point": "^2.0.0" } }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "requires": { + "ansi-regex": "^3.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=" + } + } + }, "table": { "version": "5.4.6", "resolved": "https://registry.npmjs.org/table/-/table-5.4.6.tgz", @@ -12651,6 +12711,14 @@ "is-fullwidth-code-point": "^2.0.0", "strip-ansi": "^5.1.0" } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "requires": { + "ansi-regex": "^4.1.0" + } } } }, @@ -13236,6 +13304,30 @@ } } }, + "undercomplicate": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/undercomplicate/-/undercomplicate-0.1.1.tgz", + "integrity": "sha512-F/rOQ2x3YgH5YcEU+xUKxbv1GGF1znTrBi7MRvqtcDgwubNQ/HGLZbgZh2mHpX0z9AM0T2Pg3zqPwKWYK+VJhQ==", + "requires": { + "@hapi/topo": "^5.1.0", + "just-clone": "^3.2.1" + }, + "dependencies": { + "@hapi/hoek": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.2.1.tgz", + "integrity": "sha512-gfta+H8aziZsm8pZa0vj04KO6biEiisppNgA1kbJvFrrWu9Vm7eaUEy76DIxsuTaWvti5fkJVhllWc6ZTE+Mdw==" + }, + "@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "requires": { + "@hapi/hoek": "^9.0.0" + } + } + } + }, "unicode-canonical-property-names-ecmascript": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", @@ -13478,9 +13570,9 @@ } }, "url-parse": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.1.tgz", - "integrity": "sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q==", + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz", + "integrity": "sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==", "dev": true, "requires": { "querystringify": "^2.1.1", @@ -13584,9 +13676,9 @@ "dev": true }, "vue": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/vue/-/vue-2.6.12.tgz", - "integrity": "sha512-uhmLFETqPPNyuLLbsKz6ioJ4q7AZHzD8ZVFNATNyICSZouqP2Sz0rotWQC8UNBF6VGSCs5abnKJoStA6JbCbfg==" + "version": "2.6.14", + "resolved": "https://registry.npmjs.org/vue/-/vue-2.6.14.tgz", + "integrity": "sha512-x2284lgYvjOMj3Za7kqzRcUSxBboHqtgRE2zlos1qWaOye5yUmHn42LB1250NJBLRwEcdrB0JRwyPTEPhfQjiQ==" }, "vue-bootstrap-typeahead": { "version": "0.2.6", @@ -13659,9 +13751,9 @@ } }, "vue-template-compiler": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.6.12.tgz", - "integrity": "sha512-OzzZ52zS41YUbkCBfdXShQTe69j1gQDZ9HIX8miuC9C3rBCk9wIRjLiZZLrmX9V+Ftq/YEyv1JaVr5Y/hNtByg==", + "version": "2.6.14", + "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.6.14.tgz", + "integrity": "sha512-ODQS1SyMbjKoO1JBJZojSw6FE4qnh9rIpUZn2EUT86FKizx9uH5z6uXiIrm4/Nb/gwxTi/o17ZDEGWAXHvtC7g==", "dev": true, "requires": { "de-indent": "^1.0.2", diff --git a/package.json b/package.json index 9897d0a..188ddad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "localzoom", - "version": "0.7.5", + "version": "0.8.0", "license": "MIT", "engines": { "node": ">=10.13.0" @@ -20,12 +20,10 @@ "@sentry/browser": "^4.5.2", "bootstrap": "^4.4.1", "bootstrap-vue": "^2.21.2", - "gwas-credible-sets": "^0.1.0", - "locuszoom": "https://github.com/statgen/locuszoom#ea3894e", + "locuszoom": "git+https://github.com/statgen/locuszoom.git#773eb87", "lodash": "^4.17.11", - "tabix-reader": "^1.0.1", - "tabulator-tables": "^4.1.4", - "vue": "^2.6.10", + "tabulator-tables": "^4.9.0", + "vue": "^2.6.14", "vue-bootstrap-typeahead": "^0.2.6" }, "devDependencies": { @@ -34,12 +32,12 @@ "@vue/cli-plugin-unit-mocha": "^3.0.0", "@vue/cli-service": "^3.0.0", "@vue/eslint-config-airbnb": "^4.0.0", - "@vue/test-utils": "^1.0.0-beta.20", - "autoprefixer": "^9.8.4", - "chai": "^4.3.0", + "@vue/test-utils": "^1.2.2", + "autoprefixer": "^9.8.8", + "chai": "^4.3.4", "node-sass": "^4.9.0", "sass-loader": "^7.0.1", "source-map-loader": "^0.2.4", - "vue-template-compiler": "^2.5.17" + "vue-template-compiler": "^2.6.14" } } diff --git a/public/index.html b/public/index.html index 10630dd..e2de21a 100644 --- a/public/index.html +++ b/public/index.html @@ -17,7 +17,12 @@ } - + <% if (process.env.NODE_ENV === 'production' && process.env.VUE_APP_GOOGLE_ANALYTICS_KEY) { %> @@ -133,25 +122,13 @@ export default { v-if="!batch_mode_active" class="col-sm-6">
- - -
- -
-
- + + +
@@ -173,7 +156,7 @@ export default { @@ -184,9 +167,9 @@ export default { title="Export"> + @requested-data="subscribeToData"/> @@ -199,7 +182,7 @@ export default { .placeholder-plot { width: 100%; height: 500px; - border-style: dashed; + border-style: dotted; } .scroll-extra { overflow-x: scroll; diff --git a/src/components/RegionPicker.vue b/src/components/RegionPicker.vue index f7edaaa..f911294 100644 --- a/src/components/RegionPicker.vue +++ b/src/components/RegionPicker.vue @@ -13,10 +13,10 @@ + + diff --git a/src/gwas/parser_utils.js b/src/gwas/parser_utils.js deleted file mode 100644 index 68051c9..0000000 --- a/src/gwas/parser_utils.js +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Constant values used by GWAS parser - */ - -const MISSING_VALUES = new Set(['', '.', 'NA', 'N/A', 'n/a', 'nan', '-nan', 'NaN', '-NaN', 'null', 'NULL', 'None', null]); -const REGEX_MARKER = /^(?:chr)?([a-zA-Z0-9]+?)[_:-](\d+)[_:|-]?(\w+)?[/_:|-]?([^_]+)?_?(.*)?/; -const REGEX_PVAL = /([\d.-]+)([\sxeE]*)([0-9-]*)/; - - -/** - * Utility helper that checks for the presence of a numeric value (incl 0), - * eg "has column specified" - * @param num - * @returns {boolean} - */ -function has(num) { - return Number.isInteger(num); -} - -/** - * Convert all missing values to a standardized input form - * Useful for columns like pvalue, where a missing value explicitly allowed - */ -function missingToNull(values, nulls = MISSING_VALUES, placeholder = null) { - // TODO Make this operate on a single value; cache for efficiency? - return values.map((v) => (nulls.has(v) ? placeholder : v)); -} - -/** - * Parse a single marker, cleaning up values as necessary - * @param {String} value - * @param {boolean} test If called in testing mode, do not throw an exception - * @returns {[string, number, string|null, string|null] | null} chr, pos, ref, alt (if match found) - */ -function parseMarker(value, test = false) { - const match = value.match(REGEX_MARKER); - if (match) { - return match.slice(1); - } - if (!test) { - throw new Error('Could not understand marker format. Must be of format chr:pos or chr:pos_ref/alt'); - } else { - return null; - } -} - -/** - * Parse (and validate) a given number, and return the -log10 pvalue. - * @param value - * @param {boolean} is_neg_log - * @returns {number||null} The -log10 pvalue - */ -function parsePvalToLog(value, is_neg_log = false) { - if (value === null) { - return value; - } - const val = +value; - if (is_neg_log) { // Take as is - return val; - } - // Regular pvalue: validate and convert - if (val < 0 || val > 1) { - throw new Error('p value is not in the allowed range'); - } - // 0-values are explicitly allowed and will convert to infinity by design, as they often - // indicate underflow errors in the input data. - if (val === 0) { - // Determine whether underflow is due to the source data, or value conversion - if (value === '0') { - // The source data is bad, so insert an obvious placeholder value - return Infinity; - } - // h/t @welchr: aggressively turn the underflowing string value into -log10 via regex - // Only do this if absolutely necessary, because it is a performance hit - - let [, base, , exponent] = value.match(REGEX_PVAL); - base = +base; - - if (exponent !== '') { - exponent = +exponent; - } else { - exponent = 0; - } - if (base === 0) { - return Infinity; - } - return -(Math.log10(+base) + +exponent); - } - return -Math.log10(val); -} - -function parseAlleleFrequency({ freq, allele_count, n_samples, is_alt_effect = true }) { - if (freq !== undefined && allele_count !== undefined) { - throw new Error('Frequency and allele count options are mutually exclusive'); - } - - let result; - if (freq === undefined && (MISSING_VALUES.has(allele_count) || MISSING_VALUES.has(n_samples))) { - // Allele count parsing - return null; - } - if (freq === undefined && allele_count !== undefined) { - result = +allele_count / +n_samples / 2; - } else if (MISSING_VALUES.has(freq)) { // Frequency-based parsing - return null; - } else { - result = +freq; - } - - // No matter how the frequency is specified, this stuff is always done - if (result < 0 || result > 1) { - throw new Error('Allele frequency is not in the allowed range'); - } - if (!is_alt_effect) { // Orient the frequency to the alt allele - return 1 - result; - } - return result; -} - -export { - MISSING_VALUES, REGEX_MARKER, - missingToNull as _missingToNull, - has, - // Exports for unit testing - parseAlleleFrequency, - parseMarker, - parsePvalToLog, -}; diff --git a/src/gwas/parsers.js b/src/gwas/parsers.js deleted file mode 100644 index 3b97bd1..0000000 --- a/src/gwas/parsers.js +++ /dev/null @@ -1,184 +0,0 @@ -import { - MISSING_VALUES, - has, - parseAlleleFrequency, - parseMarker, - parsePvalToLog, -} from './parser_utils'; - - -/** - * Specify how to parse a GWAS file, given certain column information. - * Outputs an object with fields in portal API format. - * @param [marker_col] - * @param [chrom_col] - * @param [pos_col] - * @param [ref_col] - * @param [alt_col] - * @param [rsid_col] - * @param pvalue_col - * @param [beta_col] - * @param [stderr_beta_col] - * @param [allele_freq_col] - * @param [allele_count_col] - * @param [n_samples_col] - * @param [is_alt_effect=true] - * @param [is_neg_log_pvalue=false] - * @param [delimiter='\t'] - * @return {function(*): {chromosome: *, position: number, ref_allele: *, - * log_pvalue: number, variant: string}} - */ -function makeParser( - { - // Required fields - marker_col, // position - chrom_col, - pos_col, - ref_col, - alt_col, - rsid_col, - pvalue_col, // pvalue - // Optional fields - beta_col, - stderr_beta_col, - allele_freq_col, // Frequency: given directly, OR in terms of counts - allele_count_col, - n_samples_col, - is_alt_effect = true, // whether effect allele is oriented towards alt - is_neg_log_pvalue = false, - delimiter = '\t', - } = {}, -) { - // Column IDs should be 1-indexed (human friendly) - if (has(marker_col) && has(chrom_col) && has(pos_col)) { - throw new Error('Must specify either marker OR chr + pos'); - } - if (!(has(marker_col) || (has(chrom_col) && has(pos_col)))) { - throw new Error('Must specify how to locate marker'); - } - - if (has(allele_count_col) && has(allele_freq_col)) { - throw new Error('Allele count and frequency options are mutually exclusive'); - } - if (has(allele_count_col) && !has(n_samples_col)) { - throw new Error('To calculate allele frequency from counts, you must also provide n_samples'); - } - - - return (line) => { - const fields = line.split(delimiter); - let chr; - let pos; - let ref; - let alt; - let rsid = null; - - let freq; - let beta = null; - let stderr_beta = null; - let alt_allele_freq = null; - let allele_count; - let n_samples; - - if (has(marker_col)) { - [chr, pos, ref, alt] = parseMarker(fields[marker_col - 1], false); - } else if (has(chrom_col) && has(pos_col)) { - chr = fields[chrom_col - 1].replace(/^chr/g, ''); - pos = fields[pos_col - 1]; - } else { - throw new Error('Must specify all fields required to identify the variant'); - } - - chr = chr.toUpperCase(); - if (chr.startsWith('RS')) { - throw new Error(`Invalid chromosome specified: value "${chr}" is an rsID`); - } - - if (has(ref_col)) { - ref = fields[ref_col - 1]; - } - - if (has(alt_col)) { - alt = fields[alt_col - 1]; - } - - if (has(rsid_col)) { - rsid = fields[rsid_col - 1]; - } - - if (MISSING_VALUES.has(ref)) { - ref = null; - } - if (MISSING_VALUES.has(alt)) { - alt = null; - } - - if (MISSING_VALUES.has(rsid)) { - rsid = null; - } else if (rsid) { - rsid = rsid.toLowerCase(); - if (!rsid.startsWith('rs')) { - rsid = `rs${rsid}`; - } - } - - const log_pval = parsePvalToLog(fields[pvalue_col - 1], is_neg_log_pvalue); - ref = ref || null; - alt = alt || null; - - if (has(allele_freq_col)) { - freq = fields[allele_freq_col - 1]; - } - if (has(allele_count_col)) { - allele_count = fields[allele_count_col - 1]; - n_samples = fields[n_samples_col - 1]; - } - - if (has(beta_col)) { - beta = fields[beta_col - 1]; - beta = MISSING_VALUES.has(beta) ? null : (+beta); - } - - if (has(stderr_beta_col)) { - stderr_beta = fields[stderr_beta_col - 1]; - stderr_beta = MISSING_VALUES.has(stderr_beta) ? null : (+stderr_beta); - } - - if (allele_freq_col || allele_count_col) { - alt_allele_freq = parseAlleleFrequency({ - freq, - allele_count, - n_samples, - is_alt_effect, - }); - } - const ref_alt = (ref && alt) ? `_${ref}/${alt}` : ''; - return { - chromosome: chr, - position: +pos, - ref_allele: ref ? ref.toUpperCase() : null, - alt_allele: alt ? alt.toUpperCase() : null, - variant: `${chr}:${pos}${ref_alt}`, - rsid, - log_pvalue: log_pval, - beta, - stderr_beta, - alt_allele_freq, - }; - }; -} - -// Preconfigured parser with defaults for a standard file format -const standard_gwas_parser = makeParser({ - chrom_col: 1, - pos_col: 2, - ref_col: 3, - alt_col: 4, - pvalue_col: 5, - beta_col: 6, - stderr_beta_col: 7, - is_neg_log_pvalue: true, - delimiter: '\t', -}); - -export { makeParser, standard_gwas_parser }; diff --git a/src/gwas/sniffers.js b/src/gwas/sniffers.js deleted file mode 100644 index 57b5868..0000000 --- a/src/gwas/sniffers.js +++ /dev/null @@ -1,257 +0,0 @@ -/** - * Sniffers: auto detect file format and parsing options for GWAS files. - * TODO: Reorganize code base and move more logic here - */ - -import { MISSING_VALUES, parsePvalToLog, parseMarker, _missingToNull } from './parser_utils'; - -function isNumeric(val) { - // Check whether an unparsed string is a numeric value" - if (MISSING_VALUES.has(val)) { - return true; - } - return !Number.isNaN(+val); -} - -function isHeader(row, { comment_char = '#', delimiter = '\t' } = {}) { - // This assumes two basic rules: the line is not a comment, and gwas data is more likely - // to be numeric than headers - return row.startsWith(comment_char) || row.split(delimiter).every((item) => !isNumeric(item)); -} - -/** - * Compute the levenshtein distance between two strings. Useful for finding the single best column - * name that matches a given rule. - * @private - */ -function levenshtein(a, b) { // https://github.com/trekhleb/javascript-algorithms - // Create empty edit distance matrix for all possible modifications of - // substrings of a to substrings of b. - const distanceMatrix = Array(b.length + 1) - .fill(null) - .map(() => Array(a.length + 1) - .fill(null)); - - // Fill the first row of the matrix. - // If this is first row then we're transforming empty string to a. - // In this case the number of transformations equals to size of a substring. - for (let i = 0; i <= a.length; i += 1) { - distanceMatrix[0][i] = i; - } - - // Fill the first column of the matrix. - // If this is first column then we're transforming empty string to b. - // In this case the number of transformations equals to size of b substring. - for (let j = 0; j <= b.length; j += 1) { - distanceMatrix[j][0] = j; - } - - for (let j = 1; j <= b.length; j += 1) { - for (let i = 1; i <= a.length; i += 1) { - const indicator = a[i - 1] === b[j - 1] ? 0 : 1; - distanceMatrix[j][i] = Math.min( - distanceMatrix[j][i - 1] + 1, // deletion - distanceMatrix[j - 1][i] + 1, // insertion - distanceMatrix[j - 1][i - 1] + indicator, // substitution - ); - } - } - return distanceMatrix[b.length][a.length]; -} - -/** - * Return the index of the first column name that meets acceptance criteria - * @param {String[]} column_synonyms - * @param {String[]}header_names - * @param {Number} threshold Tolerance for fuzzy matching (# edits) - * @return {Number|null} Index of the best matching column, or null if no match possible - */ -function findColumn(column_synonyms, header_names, threshold = 2) { - // Find the column name that best matches - let best_score = threshold + 1; - let best_match = null; - for (let i = 0; i < header_names.length; i++) { - const header = header_names[i]; - if (header === null) { - // If header is empty, don't consider it for a match - // Nulling a header provides a way to exclude something from future searching - continue; // eslint-disable-line no-continue - } - const score = Math.min(...column_synonyms.map((s) => levenshtein(header, s))); - if (score < best_score) { - best_score = score; - best_match = i; - } - } - return best_match; -} - - -/** - * Return parser configuration for pvalues - * - * Returns 1-based column indices, for compatibility with parsers - * @param header_row - * @param data_rows - * @returns {{}} - */ -function getPvalColumn(header_row, data_rows) { - // TODO: Allow overrides - const LOGPVALUE_FIELDS = ['neg_log_pvalue', 'log_pvalue', 'log_pval', 'logpvalue']; - const PVALUE_FIELDS = ['pvalue', 'p.value', 'p-value', 'pval', 'p_score', 'p']; - - let ps; - const validateP = (col, data, is_log) => { // Validate pvalues - const cleaned_vals = _missingToNull(data.map((row) => row[col])); - try { - ps = cleaned_vals.map((p) => parsePvalToLog(p, is_log)); - } catch (e) { - return false; - } - return ps.every((val) => !Number.isNaN(val)); - }; - - const log_p_col = findColumn(LOGPVALUE_FIELDS, header_row); - const p_col = findColumn(PVALUE_FIELDS, header_row); - - if (log_p_col !== null && validateP(log_p_col, data_rows, true)) { - return { - pvalue_col: log_p_col + 1, - is_neg_log_pvalue: true, - }; - } - if (p_col && validateP(p_col, data_rows, false)) { - return { - pvalue_col: p_col + 1, - is_neg_log_pvalue: false, - }; - } - // Could not auto-determine an appropriate pvalue column - return null; -} - - -function getChromPosRefAltColumns(header_row, data_rows) { - // Returns 1-based column indices, for compatibility with parsers - // Get from either a marker, or 4 separate columns - const MARKER_FIELDS = ['snpid', 'marker', 'markerid', 'snpmarker', 'chr:position']; - const CHR_FIELDS = ['chrom', 'chr']; - const POS_FIELDS = ['position', 'pos', 'begin', 'beg', 'bp', 'end', 'ps']; - - // TODO: How to handle orienting ref vs effect? - // Order matters: consider ambiguous field names for ref before alt - const REF_FIELDS = ['A1', 'ref', 'reference', 'allele0', 'allele1']; - const ALT_FIELDS = ['A2', 'alt', 'alternate', 'allele1', 'allele2']; - - const first_row = data_rows[0]; - let marker_col = findColumn(MARKER_FIELDS, header_row); - if (marker_col !== null && parseMarker(first_row[marker_col], true)) { - marker_col += 1; - return { marker_col }; - } - - // If single columns were incomplete, attempt to auto detect 4 separate columns. All 4 must - // be found for this function to report a match. - const headers_marked = header_row.slice(); - const find = [ - ['chrom_col', CHR_FIELDS], - ['pos_col', POS_FIELDS], - ['ref_col', REF_FIELDS], - ['alt_col', ALT_FIELDS], - ]; - const config = {}; - for (let i = 0; i < find.length; i++) { - const [col_name, choices] = find[i]; - const col = findColumn(choices, headers_marked); - if (col === null) { - return null; - } - config[col_name] = col + 1; - // Once a column has been assigned, remove it from consideration - headers_marked[col] = null; - } - return config; -} - -/** - * Identify which columns contain effect size (beta) and stderr of the effect size - * @param {String[]} header_names - * @param {Array[]} data_rows - * @returns {{}} - */ -function getEffectSizeColumns(header_names, data_rows) { - const BETA_FIELDS = ['beta', 'effect_size', 'alt_effsize', 'effect']; - const STDERR_BETA_FIELDS = ['stderr_beta', 'stderr', 'sebeta', 'effect_size_sd', 'se']; - - function validate_numeric(col, data) { - const cleaned_vals = _missingToNull(data.map((row) => row[col])); - let nums; - try { - nums = cleaned_vals.filter((val) => val !== null).map((val) => +val); - } catch (e) { - return false; - } - return nums.every((val) => !Number.isNaN(val)); - } - - const beta_col = findColumn(BETA_FIELDS, header_names, 0); - const stderr_beta_col = findColumn(STDERR_BETA_FIELDS, header_names, 0); - - const ret = {}; - if (beta_col !== null && validate_numeric(beta_col, data_rows)) { - ret.beta_col = beta_col + 1; - } - if (stderr_beta_col !== null && validate_numeric(stderr_beta_col, data_rows)) { - ret.stderr_beta_col = stderr_beta_col + 1; - } - return ret; -} -/** - * Attempt to guess the correct parser settings given a set of header rows and a set of data rows - * @param {String[]} header_row - * @param {String[][]} data_rows - * @param {int} offset Used to convert between 0 and 1-based indexing. - */ -function guessGWAS(header_row, data_rows, offset = 1) { - // 1. Find a specific set of info: marker OR chr/pos/ref/alt ; pvalue OR log_pvalue - // 2. Validate that we will be able to parse the required info: fields present and make sense - // 3. Based on the field names selected, attempt to infer meaning: verify whether log is used, - // and check ref/alt vs effect/noneffect - // 4. Return a parser config object if all tests pass, OR null. - - // Normalize case and remove leading comment marks from line for easier comparison - const headers = header_row.map((item) => (item ? item.toLowerCase() : item)); - headers[0].replace('/^#+/', ''); - // Lists of fields are drawn from Encore (AssocResultReader) and Pheweb (conf_utils.py) - const pval_config = getPvalColumn(headers, data_rows, offset); - if (!pval_config) { - return null; - } - headers[pval_config.pvalue_col - 1] = null; // Remove this column from consideration - const position_config = getChromPosRefAltColumns(headers, data_rows); - if (!position_config) { - return null; - } - // Remove the position config from consideration for future matches - Object.keys(position_config).forEach((key) => { - headers[position_config[key]] = null; - }); - - const beta_config = getEffectSizeColumns(headers, data_rows); - - if (pval_config && position_config) { - return Object.assign({}, pval_config, position_config, beta_config || {}); - } - return null; -} - -export { - // Public members - guessGWAS, - isNumeric, - isHeader, - // Symbols exported for testing - getPvalColumn as _getPvalColumn, - findColumn as _findColumn, - levenshtein as _levenshtein, -}; diff --git a/src/util/constants.js b/src/util/constants.js index 368385e..edb6268 100644 --- a/src/util/constants.js +++ b/src/util/constants.js @@ -4,8 +4,10 @@ const LD_SERVER_BASE_URL = 'https://portaldev.sph.umich.edu/ld/'; const REGEX_POSITION = /^(?:chr)?(\w+)\s*:\s*(\d+)$/; const REGEX_REGION = /^(?:chr)?(\w+)\s*:\s*(\d+)-(\d+)$/; +const DEFAULT_REGION_SIZE = 500000; export { + DEFAULT_REGION_SIZE, REGEX_REGION, REGEX_POSITION, PORTAL_API_BASE_URL, LD_SERVER_BASE_URL, }; diff --git a/src/util/entity-helpers.js b/src/util/entity-helpers.js index ef71ba8..5bbd5fb 100644 --- a/src/util/entity-helpers.js +++ b/src/util/entity-helpers.js @@ -3,8 +3,7 @@ * that appear in a wide range of genetic datasets */ -import { REGEX_POSITION, REGEX_REGION } from './constants'; - +import { DEFAULT_REGION_SIZE, REGEX_POSITION, REGEX_REGION } from './constants'; /** * Parse a single position and return a range, based on the region size @@ -12,20 +11,32 @@ import { REGEX_POSITION, REGEX_REGION } from './constants'; * @param {Number} region_size * @returns {Number[]} [start, end] */ -function positionToRange(position, { region_size = 500000 }) { +function positionToMidRange(position, { region_size = DEFAULT_REGION_SIZE } = {}) { const bounds = Math.floor(region_size / 2); return [Math.max(position - bounds, 1), position + bounds]; } +/** + * Parse a single position and return a range with this position near the start (with some padding) + * @param position + * @param DEFAULT_REGION_SIZE + * @returns {Number[]} [start, end] + */ +function positionToStartRange(position, { region_size = DEFAULT_REGION_SIZE, padding = 0.025 } = {}) { + const start = Math.max(1, position - region_size * padding); + const end = start + region_size * (1 + padding); + return [start, end]; +} + /** * Parse a variant/region identifier, in the format chr:start-end or chr:center. Return start * and end positions. * * @param {string} spec - * @param {number} [region_size=500000] If specified, enforces a max region size. + * @param {number} [region_size=DEFAULT_REGION_SIZE] If specified, enforces a max region size. * @returns {[string, number, number]} [chr, start, end] */ -function parseRegion(spec, { region_size = 500000 }) { +function parseRegion(spec, { region_size = DEFAULT_REGION_SIZE }) { const range_match = spec.match(REGEX_REGION); const single_match = spec.match(REGEX_POSITION); let chr; @@ -40,7 +51,7 @@ function parseRegion(spec, { region_size = 500000 }) { let pos; [chr, pos] = single_match.slice(1); pos = +pos; - [start, end] = positionToRange(pos, { region_size }); + [start, end] = positionToMidRange(pos, { region_size }); } else { throw new Error(`Could not parse the specified range: ${spec}`); } @@ -55,4 +66,4 @@ function parseRegion(spec, { region_size = 500000 }) { return { chr, start, end }; } -export { parseRegion, positionToRange }; +export { parseRegion, positionToMidRange, positionToStartRange }; diff --git a/src/util/lz-helpers.js b/src/util/lz-helpers.js index 187de6e..c3e0d37 100644 --- a/src/util/lz-helpers.js +++ b/src/util/lz-helpers.js @@ -1,42 +1,25 @@ import LocusZoom from 'locuszoom'; -import { AssociationLZ } from 'locuszoom/esm/data/adapters'; import credibleSets from 'locuszoom/esm/ext/lz-credible-sets'; +import tabixSource from 'locuszoom/esm/ext/lz-tabix-source'; +import intervalTracks from 'locuszoom/esm/ext/lz-intervals-track'; +import lzParsers from 'locuszoom/esm/ext/lz-parsers'; import { PORTAL_API_BASE_URL, LD_SERVER_BASE_URL } from './constants'; -import { makeParser } from '../gwas/parsers'; LocusZoom.use(credibleSets); +LocusZoom.use(tabixSource); +LocusZoom.use(intervalTracks); +LocusZoom.use(lzParsers); -const stateUrlMapping = Object.freeze({ chr: 'chrom', start: 'start', end: 'end' }); +const stateUrlMapping = Object.freeze({ chr: 'chrom', start: 'start', end: 'end', ldrefvar: 'ld_variant' }); -class TabixAssociationLZ extends AssociationLZ { - parseInit(init) { - this.params = init.params; // Used to create a parser - this.parser = makeParser(this.params); - this.reader = init.tabix_reader; - } - - getCacheKey(state, chain, fields) { - return [state.chr, state.start, state.end].join('_'); - } - - fetchRequest(state, chain, fields) { - const self = this; - return new Promise((resolve, reject) => { - self.reader.fetch(state.chr, state.start, state.end, (data, err) => { - if (err) { - reject(new Error('Could not read requested region. This may indicate an error with the .tbi index.')); - } - resolve(data); - }); - }); - } - - normalizeResponse(data) { +const TabixUrlSource = LocusZoom.Adapters.get('TabixUrlSource'); +class TabixAssociationLZ extends TabixUrlSource { + _annotateRecords(records) { // Some GWAS files will include variant rows, even if no pvalue can be calculated. // Eg, EPACTS fills in "NA" for pvalue in this case. These rows are not useful for a // scatter plot, and this data source should ignore them. - return data.map(this.parser).filter((item) => !Number.isNaN(item.log_pvalue)); + return records.filter((item) => !Number.isNaN(item['log_pvalue'])); } } @@ -49,41 +32,41 @@ LocusZoom.Adapters.add('TabixAssociationLZ', TabixAssociationLZ); * @return {string} */ function sourceName(display_name) { + // FIXME: incorporate datatype when generating source name; fix various caller locations return display_name.replace(/[^A-Za-z0-9_]/g, '_'); } /** * Create customized panel layout(s) for a single association study, with functionality from all * of the features selected. - * @param {string} source_label + * @param {string} track_id Internal track ID, expected to be unique across the entire plot. + * @param {string} display_name * @param annotations - * @param build * @return {*[]} */ -function createStudyLayout( - source_label, - annotations = { credible_sets: false, gwas_catalog: true }, - build, +function createGwasStudyLayout( + track_id, + display_name, + annotations = { has_credible_sets: false, has_gwas_catalog: true }, ) { const new_panels = []; - const source_name = sourceName(source_label); // Other namespaces won't be overridden; they will be reused as is. const namespace = { - assoc: `assoc_${source_name}`, - credset: `credset_${source_name}`, + assoc: `assoc_${track_id}`, + credset: `credset_${track_id}`, catalog: 'catalog', }; const assoc_panel = LocusZoom.Layouts.get('panel', 'association', { - id: `association_${source_name}`, - title: { text: source_label }, + id: `association_${track_id}`, + title: { text: display_name }, height: 275, namespace, }); const assoc_layer = assoc_panel.data_layers[2]; // layer 1 = recomb rate assoc_layer.label = { - text: '{{#if {{namespace[assoc]}}rsid}}{{{{namespace[assoc]}}rsid}}{{#else}}{{{{namespace[assoc]}}variant}}{{/if}}', + text: '{{#if assoc:rsid}}{{assoc:rsid}}{{#else}}{{assoc:variant}}{{/if}}', spacing: 12, lines: { style: { 'stroke-width': '2px', stroke: '#333333', 'stroke-dasharray': '2px 2px' } }, filters: [ @@ -94,10 +77,10 @@ function createStudyLayout( assoc_layer.tooltip = LocusZoom.Layouts.get('tooltip', 'standard_association_with_label', { namespace }); const assoc_tooltip = assoc_layer.tooltip; - assoc_tooltip.html += '{{#if {{namespace[assoc]}}beta|is_numeric}}
β: {{{{namespace[assoc]}}beta|scinotation|htmlescape}}{{/if}}'; - assoc_tooltip.html += '{{#if {{namespace[assoc]}}stderr_beta|is_numeric}}
SE (β): {{{{namespace[assoc]}}stderr_beta|scinotation|htmlescape}}{{/if}}'; - assoc_tooltip.html += '{{#if {{namespace[assoc]}}alt_allele_freq|is_numeric}}
Alt. freq: {{{{namespace[assoc]}}alt_allele_freq|scinotation|htmlescape}} {{/if}}'; - assoc_tooltip.html += '{{#if {{namespace[assoc]}}rsid}}
rsID: {{{{namespace[assoc]}}rsid|htmlescape}}{{/if}}'; + assoc_tooltip.html += `{{#if assoc:beta|is_numeric}}
β: {{assoc:beta|scinotation|htmlescape}}{{/if}} +{{#if assoc:stderr_beta|is_numeric}}
SE (β): {{assoc:stderr_beta|scinotation|htmlescape}}{{/if}} +{{#if assoc:alt_allele_freq|is_numeric}}
Alt. freq: {{assoc:alt_allele_freq|scinotation|htmlescape}} {{/if}} +{{#if assoc:rsid}}
rsID: {{assoc:rsid|htmlescape}}{{/if}}`; const dash_extra = []; // Build Display options widget & add to toolbar iff features selected if (Object.values(annotations).some((item) => !!item)) { @@ -114,41 +97,30 @@ function createStudyLayout( options: [], }); } - const fields_extra = [ - '{{namespace[assoc]}}rsid', - '{{namespace[assoc]}}beta', - '{{namespace[assoc]}}stderr_beta', - '{{namespace[assoc]}}alt_allele_freq', - ]; - if (annotations.credible_sets) { + if (annotations.has_credible_sets) { // Grab the options object from a pre-existing layout const basis = LocusZoom.Layouts.get('panel', 'association_credible_set', { namespace }); dash_extra[0].options.push(...basis.toolbar.widgets.pop().options); - fields_extra.push('{{namespace[credset]}}posterior_prob', '{{namespace[credset]}}contrib_fraction', '{{namespace[credset]}}is_member'); - assoc_tooltip.html += '{{#if {{namespace[credset]}}posterior_prob}}
Posterior probability: {{{{namespace[credset]}}posterior_prob|scinotation}}{{/if}}
'; + assoc_tooltip.html += '{{#if credset:posterior_prob}}
Posterior probability: {{credset:posterior_prob|scinotation}}{{/if}}
'; } - if (annotations.gwas_catalog) { + if (annotations.has_gwas_catalog) { // Grab the options object from a pre-existing layout const basis = LocusZoom.Layouts.get('panel', 'association_catalog', { namespace }); // TODO Clarify this; make small registry pieces more reusable dash_extra[0].options.push(...basis.toolbar.widgets.pop().options); - fields_extra.push('{{namespace[catalog]}}rsid', '{{namespace[catalog]}}trait', '{{namespace[catalog]}}log_pvalue'); - assoc_tooltip.html += '{{#if {{namespace[catalog]}}rsid}}
See hits in GWAS catalog{{/if}}'; + assoc_tooltip.html += '{{#if catalog:rsid}}
See hits in GWAS catalog{{/if}}'; } - - assoc_layer.fields.push(...fields_extra); assoc_panel.toolbar.widgets.push(...dash_extra); // After all custom options added, run mods through Layouts.get once more to apply namespacing // TODO: rewrite this new_panels.push(LocusZoom.Layouts.get('panel', 'association', assoc_panel)); - if (annotations.gwas_catalog) { + if (annotations.has_gwas_catalog) { new_panels.push(LocusZoom.Layouts.get('panel', 'annotation_catalog', { - id: `catalog_${source_name}`, + id: `catalog_${track_id}`, namespace, - toolbar: { widgets: [] }, title: { - text: 'Hits in GWAS Catalog', + text: `GWAS Catalog hits for ${display_name}`, style: { 'font-size': '14px' }, x: 50, }, @@ -158,29 +130,76 @@ function createStudyLayout( } /** - * Create all the datasources needed to plot a specific study, from Tabixed data + * Create an appropriate layout based on datatype + */ +function createStudyLayouts (data_type, filename, display_name, annotations) { + const track_id = `${data_type}_${sourceName(filename)}`; + + if (data_type === 'gwas') { + return createGwasStudyLayout(track_id, display_name, annotations); + } else if (data_type === 'bed') { + return [ + LocusZoom.Layouts.get('panel', 'bed_intervals', { + id: track_id, + namespace: { intervals: track_id }, + title: { text: display_name }, + }), + ]; + } else if (data_type === 'plink_ld') { + throw new Error('Not yet implemented'); + } else { + throw new Error('Unrecognized datatype'); + } +} + +/** + * Create all the datasources needed to plot a specific assoc study, including study-specific annotations * @return Array Return an array of [name, source_config] entries */ -function createStudyTabixSources(label, tabix_reader, parser_options) { - const assoc_name = `assoc_${sourceName(label)}`; - const source_params = { ...parser_options, id_field: 'variant' }; +function createGwasTabixSources(track_id, tabix_reader, parser_func) { + const assoc_name = `assoc_${track_id}`; return [ - [assoc_name, ['TabixAssociationLZ', { tabix_reader, params: source_params }]], + [assoc_name, ['TabixAssociationLZ', { reader: tabix_reader, parser_func }]], [ // Always add a credible set source; it won't be called unless used in a layout - `credset_${sourceName(label)}`, [ - 'CredibleSetLZ', - { params: { fields: { log_pvalue: `${assoc_name}:log_pvalue` }, threshold: 0.95 } }, - ], + `credset_${track_id}`, ['CredibleSetLZ', { threshold: 0.95 }], ], ]; } +/** + * Create appropriate LocusZoom sources for the study of interest + * @param data_type + * @param filename A unique track identifier (typically the filename). Used to construct the internal datasource ID name. + * @param tabix_reader + * @param parser_func + * @returns {[[string, [string, {reader: *, parser_func: function(string)}]], [string, [string, {threshold: number}]]]|(string|[string, {reader, parser_func: ((function(*): {blockCount: *, score: *, chromStart: *, thickStart: *, chromEnd: *, strand: *, blockSizes: *, name: *, itemRgb: *, blockStarts: *, thickEnd: *, chrom: *})|*)}])[]} + */ +function createStudySources(data_type, tabix_reader, filename, parser_func) { + // todo rename to GET from CREATE, for consistency + const track_id = `${data_type}_${sourceName(filename)}`; + if (data_type === 'gwas') { + return createGwasTabixSources(track_id, tabix_reader, parser_func); + } else if (data_type === 'bed') { + return [ + [track_id, ['TabixUrlSource', {reader: tabix_reader, parser_func }]], + ]; + } else if (data_type === 'plink_ld') { + throw new Error('Not yet implemented'); + } else { + throw new Error('Unrecognized datatype'); + } +} + + function addPanels(plot, data_sources, panel_options, source_options) { - source_options.forEach((source) => data_sources.add(...source)); + source_options.forEach(([name, options]) => { + if (!data_sources.has(name)) { + data_sources.add(name, options); + } + }); panel_options.forEach((panel_layout) => { panel_layout.y_index = -1; // Make sure genes track is always the last one - const panel = plot.addPanel(panel_layout); - panel.addBasicLoader(); + plot.addPanel(panel_layout); }); } @@ -191,10 +210,12 @@ function addPanels(plot, data_sources, panel_options, source_options) { function getBasicSources(study_sources = []) { return [ ...study_sources, + // Used by GWAS scatter plots + ['recomb', ['RecombLZ', { url: `${PORTAL_API_BASE_URL}annotation/recomb/results/` }]], ['catalog', ['GwasCatalogLZ', { url: `${PORTAL_API_BASE_URL}annotation/gwascatalog/results/` }]], - ['ld', ['LDLZ2', { url: LD_SERVER_BASE_URL, params: { source: '1000G', population: 'ALL' } }]], + ['ld', ['LDLZ2', { url: LD_SERVER_BASE_URL, source: '1000G', population: 'ALL' }]], + // Genes track ['gene', ['GeneLZ', { url: `${PORTAL_API_BASE_URL}annotation/genes/` }]], - ['recomb', ['RecombLZ', { url: `${PORTAL_API_BASE_URL}annotation/recomb/results/` }]], ['constraint', ['GeneConstraintLZ', { url: 'https://gnomad.broadinstitute.org/api/' }]], ]; } @@ -212,37 +233,12 @@ function getBasicLayout(initial_state = {}, study_panels = [], mods = {}) { return LocusZoom.Layouts.get('plot', 'standard_association', extra); } -/** - * Remove the `sourcename:` prefix from field names in the data returned by an LZ datasource - * - * This is a convenience method for writing external widgets (like tables) that subscribe to the - * plot; typically we don't want to have to redefine the table layout every time someone selects - * a different association study. - * As with all convenience methods, it has limits: don't use it if the same field name is requested - * from two different sources! - * @param {Object} data An object representing the fields for one row of data - * @param {String} [prefer] Sometimes, two sources provide a field with same name. Specify which - * source will take precedence in the event of a conflict. - */ -function deNamespace(data, prefer) { - return Object.keys(data).reduce((acc, key) => { - const new_key = key.replace(/.*?:/, ''); - if (!Object.prototype.hasOwnProperty.call(acc, new_key) - || (!prefer || key.startsWith(prefer))) { - acc[new_key] = data[key]; - } - return acc; - }, {}); -} - export { // Basic definitions getBasicSources, getBasicLayout, - createStudyTabixSources, createStudyLayout, + createStudySources, createStudyLayouts, // Plot manipulation sourceName, addPanels, - // General helpers - deNamespace, // Constants stateUrlMapping, }; diff --git a/src/util/metrics.js b/src/util/metrics.js index 10521c1..0083eeb 100644 --- a/src/util/metrics.js +++ b/src/util/metrics.js @@ -91,5 +91,20 @@ if (window.gtag) { window.addEventListener('pushState', count_region_view); } -export default count_region_view; -export { setup_feature_metrics }; +/** + * Report when new tracks/ datatypes are added to the plot, so we can have a rough guess as + * to which visualization features get used most. + * @param data_type + */ +const count_add_track = (data_type) => { + if (!window.gtag) { + return; + } + + window.gtag('event', 'add_track', { + event_category: 'features', + event_label: data_type, + }); +}; + +export { count_add_track, count_region_view, setup_feature_metrics }; diff --git a/tests/unit/constants.spec.js b/tests/unit/constants.spec.js index faf0200..3845551 100644 --- a/tests/unit/constants.spec.js +++ b/tests/unit/constants.spec.js @@ -1,45 +1,7 @@ import { assert } from 'chai'; import { REGEX_POSITION, REGEX_REGION } from '../../src/util/constants'; -import { REGEX_MARKER } from '@/gwas/parser_utils'; -describe('REGEX_MARKER', () => { - it('handles various marker formats', () => { - const has_chr_pos = ['chr1:23', 'chrX:23', '1:23', 'X:23']; - const has_chr_pos_refalt = ['chr1:23_A/C', '1:23_A/C', 'chr:1:23:AAAA:G', '1:23_A|C', 'chr1:281876_AC/A']; - const has_chr_pos_refalt_extra = [ - 'chr1:23_A/C_gibberish', '1:23_A/C_gibberish', '1:23_A|C_gibberish', - '1:51873951_G/GT_1:51873951_G/GT', - ]; - - has_chr_pos.forEach((item) => { - const match = item.match(REGEX_MARKER); - assert.ok(match, `Match found for ${item}`); - assert.lengthOf(match.filter((e) => !!e), 3, `Found chr:pos for ${item}`); - }); - has_chr_pos_refalt.forEach((item) => { - const match = item.match(REGEX_MARKER); - assert.ok(match, `Match found for ${item}`); - assert.lengthOf(match.filter((e) => !!e), 5, `Found chr:pos_ref/alt for ${item}`); - }); - has_chr_pos_refalt_extra.forEach((item) => { - const match = item.match(REGEX_MARKER); - assert.ok(match, `Match found for ${item}`); - assert.lengthOf(match.filter((e) => !!e), 6, `Found chr:pos_ref/alt_extra for ${item}`); - }); - - // Pathological edge cases - let match = '1:51873951_G/GT_1:51873951_G/GT'.match(REGEX_MARKER); - assert.equal(match[1], '1', 'Found correct chrom'); - assert.equal(match[2], '51873951', 'Found correct pos'); - assert.equal(match[3], 'G', 'Found correct ref'); - assert.equal(match[4], 'GT', 'Found correct alt'); - - match = 'sentence_goes_here_1:51873951_G/GT'.match(REGEX_MARKER); - assert.isNotOk(match, 'Marker must be at start of string'); - }); -}); - describe('REGEX_POSITION', () => { it('handles various region formats', () => { const scenarios = [ diff --git a/tests/unit/parser_utils.spec.js b/tests/unit/parser_utils.spec.js deleted file mode 100644 index bc0d32d..0000000 --- a/tests/unit/parser_utils.spec.js +++ /dev/null @@ -1,62 +0,0 @@ -import { assert } from 'chai'; -import { _missingToNull, parseAlleleFrequency, parsePvalToLog } from '@/gwas/parser_utils'; - - -describe('missingToNull', () => { - it('converts a range of missing values to null values', () => { - // Every other one should get converted - const values = [0, null, 5, 'n/a', 'bob', '-NaN']; - const result = _missingToNull(values); - assert.deepStrictEqual(result, [0, null, 5, null, 'bob', null]); - }); -}); - -describe('parsePvalToLog', () => { - it('sidesteps language underflow', () => { - const val = '1.93e-780'; - const res = parsePvalToLog(val, false); - assert.equal(res, 779.7144426909922, 'Handled value that would otherwise have underflowed'); - }); - - it('handles underflow that occurred in the source data', () => { - let val = '0'; - let res = parsePvalToLog(val); - assert.equal(res, Infinity, 'Provides a placeholder when the input data underflowed'); - - val = '0.0'; - res = parsePvalToLog(val); - assert.equal(res, Infinity, 'Provides a placeholder when the input data underflowed (slow path)'); - }); -}); - -describe('parseAlleleFrequency', () => { - it('returns freq given frequency', () => { - const res = parseAlleleFrequency({ freq: '0.25', is_alt_effect: true }); - assert.equal(res, 0.25); - }); - - it('returns freq given frequency, and orients to alt', () => { - const res = parseAlleleFrequency({ freq: '0.25', is_alt_effect: false }); - assert.equal(res, 0.75); - }); - - it('handles missing data', () => { - const res = parseAlleleFrequency({ freq: 'NA', is_alt_effect: true }); - assert.equal(res, null); - }); - - it('calculates freq from counts', () => { - const res = parseAlleleFrequency({ allele_count: '25', n_samples: '100' }); - assert.equal(res, 0.125); - }); - - it('calculates freq from counts, and orients to alt', () => { - const res = parseAlleleFrequency({ allele_count: '75', n_samples: '100', is_alt_effect: false }); - assert.equal(res, 0.625); - }); - - it('handles missing data when working with counts', () => { - const res = parseAlleleFrequency({ allele_count: 'NA', n_samples: '100' }); - assert.equal(res, null); - }); -}); diff --git a/tests/unit/parsers.spec.js b/tests/unit/parsers.spec.js deleted file mode 100644 index 0d7c71f..0000000 --- a/tests/unit/parsers.spec.js +++ /dev/null @@ -1,139 +0,0 @@ -import { assert } from 'chai'; -import { makeParser } from '@/gwas/parsers'; - - -describe('GWAS parsing', () => { - describe('Mode selection', () => { - it.skip('Warns if no marker could be identified', () => {}); - }); - - describe('Handles sample data correctly', () => { - it.skip('parses EPACTS data', () => { - // FIXME: Alan notes edge cases that may not be handled yet: - // -when *PVALUE = 0*, it always indicates a variant is very significant (such that it - // underflows R's precision limit), and *should be plotted* - // -when *PVALUE = NA*, it indicates that no test was run for that variant because there - // were too few copies of the alt allele in the sample, and running the test is a waste - // of time since it will never be significant. *These can be safely skipped.*" - }); - - it('parses SAIGE data', () => { - const saige_sample = 'chr1\t76792\tchr1:76792:A:C\tA\tC\t57\t0.00168639048933983\t16900\t0.573681678183941\t0.663806747906141\t1.30193005902619\t0.387461577915637\t0.387461577915637\t1\t2.2694293866027\t2.41152256615949'; - const parser = makeParser({ marker_col: 3, pvalue_col: 12, is_neg_log_pvalue: false }); - const actual = parser(saige_sample); - assert.deepEqual(actual, { - alt_allele: 'C', - chromosome: '1', - log_pvalue: 0.41177135722616476, - position: 76792, - ref_allele: 'A', - variant: '1:76792_A/C', - rsid: null, - beta: null, - stderr_beta: null, - alt_allele_freq: null, - }); - }); - - it('parses RVTESTS data', () => { - const rvtests_sample = '1\t761893\tG\tT\t19292\t2.59624e-05:0.000655308:0\t1:1:0\t0.998289:0.996068:0.998381\t1:1:1\t19258:759:18499\t1:1:0\t0:0:0\t1.33113\t0.268484\t18.4664\t7.12493e-07'; - const parser = makeParser({ - chrom_col: 1, - pos_col: 2, - ref_col: 3, - alt_col: 4, - pvalue_col: 16, - is_neg_log_pvalue: false, - alt_allele_freq: null, - }); - const actual = parser(rvtests_sample); - assert.deepEqual(actual, { - alt_allele: 'T', - chromosome: '1', - log_pvalue: 6.147219398093217, - position: 761893, - ref_allele: 'G', - variant: '1:761893_G/T', - rsid: null, - beta: null, - stderr_beta: null, - alt_allele_freq: null, - }); - }); - - it('parses beta and stderr where appropriate', () => { - const line = 'X:12_A/T\t0.1\t0.5\t0.6'; - const parser = makeParser({ - marker_col: 1, - pvalue_col: 2, - beta_col: 3, - stderr_beta_col: 4, - }); - const actual = parser(line); - - assert.deepEqual(actual, { - chromosome: 'X', - position: 12, - ref_allele: 'A', - alt_allele: 'T', - variant: 'X:12_A/T', - rsid: null, - log_pvalue: 1, - beta: 0.5, - stderr_beta: 0.6, - alt_allele_freq: null, - }); - // Also handles missing data for beta - const line2 = 'X:12_A/T\t0.1\t.\t.'; - const actual2 = parser(line2); - assert.equal(actual2.beta, null); - assert.equal(actual2.stderr_beta, null); - }); - - it('ensures that ref and alt are uppercase', () => { - const line = 'X:12\ta\tNA\t0.1'; - const parser = makeParser({ - marker_col: 1, - ref_col: 2, - alt_col: 3, - pvalue_col: 4, - }); - const actual = parser(line); - - assert.deepEqual(actual, { - chromosome: 'X', - position: 12, - ref_allele: 'A', - alt_allele: null, - variant: 'X:12', - rsid: null, - log_pvalue: 1, - beta: null, - stderr_beta: null, - alt_allele_freq: null, - }); - }); - - it('handles rsid in various formats', () => { - const parser = makeParser({ - marker_col: 1, - ref_col: 2, - alt_col: 3, - pvalue_col: 4, - rsid_col: 5, - }); - - const scenarios = [ - ['.', null], - ['14', 'rs14'], - ['RS14', 'rs14'], - ]; - const line = 'X:12\ta\tNA\t0.1\t'; - - scenarios.forEach(([raw, parsed]) => { - const actual = parser(line + raw); - assert.equal(actual.rsid, parsed); - }); - }); - }); -}); diff --git a/tests/unit/sniffers.spec.js b/tests/unit/sniffers.spec.js deleted file mode 100644 index d0adfce..0000000 --- a/tests/unit/sniffers.spec.js +++ /dev/null @@ -1,341 +0,0 @@ -import { assert } from 'chai'; -import { isHeader, _findColumn, _getPvalColumn, _levenshtein, guessGWAS } from '@/gwas/sniffers'; - -describe('Automatic header detection', () => { - it('Correctly identifies various header rules', () => { - assert.isOk(isHeader('#Comment'), 'Comment lines are headers!'); - assert.isOk(isHeader('Header\tLabels'), 'Headers tend to be text'); - assert.isNotOk(isHeader('X\t100'), 'Data has numbers'); - assert.isNotOk(isHeader('X\t.'), 'Missing data is still data'); - assert.isNotOk(isHeader('X,100', { delimiter: ',' }), 'Handles data as csv'); - assert.isOk(isHeader('//100', { comment_char: '//' }), 'Handles different comments'); - }); -}); - - -describe('Levenshtein distance metric', () => { - it('Computes levenshtein distance for sample strings', () => { - const scenarios = [ - ['bob', 'bob', 0], - ['bob', 'bib', 1], - ['alice', 'bob', 5], - ['pvalue', 'p.value', 1], - ['p.value', 'pvalue', 1], - ['pvalue', 'log_pvalue', 4], - ]; - scenarios.forEach((s) => { - const [a, b, score] = s; - const val = _levenshtein(a, b); - assert.equal(score, val, `Incorrect match score for ${a}, ${b}`); - }); - }); -}); - -describe('_findColumn can fuzzy match column names', () => { - const pval_names = ['pvalue', 'p.value', 'pval', 'p_score']; - - it('finds the first header that exactly matches a synonym', () => { - const headers = ['chr', 'pos', 'p.value', 'marker']; - const match = _findColumn(pval_names, headers); - assert.equal(match, 2); - }); - - it('chooses the first exact match when more than one is present', () => { - const headers = ['chr', 'pvalue', 'p.value', 'marker']; - const match = _findColumn(pval_names, headers); - assert.equal(match, 1); - }); - - it('prefers exact matches over fuzzy matches', () => { - const headers = ['chr1', 'pos1', 'pvalues', 'p.value', '1marker']; - const match = _findColumn(pval_names, headers); - assert.equal(match, 3); - }); - - it('finds the first header that closely matches a synonym', () => { - const headers = ['chr', 'pos', 'marker', 'p-value']; - const match = _findColumn(pval_names, headers); - assert.equal(match, 3); - }); - - it('returns null if no good match can be found', () => { - const headers = ['chr', 'pos', 'marker', 'pval_score']; - const match = _findColumn(pval_names, headers); - assert.equal(match, null); - }); - - it('will match based on a configurable threshold', () => { - const headers = ['chr', 'marker', 'pval_score']; - const match = _findColumn(pval_names, headers, 3); - assert.equal(match, 2); - }); - - it('skips headers with a null value', () => { - const headers = ['chr', null, 'marker', 'pval']; - const match = _findColumn(pval_names, headers); - assert.equal(match, 3); - }); -}); - -describe('getPvalColumn', () => { - it('finds logp before p', () => { - const headers = ['logpvalue', 'pval']; - const data_rows = [[0.5, 0.5]]; - - const actual = _getPvalColumn(headers, data_rows); - assert.deepEqual(actual, { pvalue_col: 1, is_neg_log_pvalue: true }); - }); - - it('checks that pvalues are in a realistic range 0..1', () => { - const headers = ['pval']; - const data_rows = [[100]]; - - const actual = _getPvalColumn(headers, data_rows); - assert.deepEqual(actual, null); - }); -}); - -describe('guessGWAS format detection', () => { - it('Returns null if columns could not be identified', () => { - const headers = ['rsid', 'pval']; - const data = [['rs1234', 0.5]]; - - const actual = guessGWAS(headers, data); - assert.deepEqual(actual, null); - }); - - it('handles zorp standard format', () => { - const headers = ['#chrom', 'pos', 'ref', 'alt', 'neg_log_pvalue', 'beta', 'stderr_beta']; - const data = [['1', '762320', 'C', 'T', '0.36947042857317597', '0.5', '0.1']]; - - const actual = guessGWAS(headers, data); - assert.deepEqual(actual, { - chrom_col: 1, - pos_col: 2, - ref_col: 3, - alt_col: 4, - pvalue_col: 5, - is_neg_log_pvalue: true, - beta_col: 6, - stderr_beta_col: 7, - }); - }); - - it('handles BOLT-LMM', () => { - // https://data.broadinstitute.org/alkesgroup/BOLT-LMM/#x1-450008.1 - // This sample drawn from: - // ftp://ftp.ebi.ac.uk/pub/databases/gwas/summary_statistics/ZhuZ_30940143_GCST007609 - // TODO: The official format spec may use other pvalue col names; add tests for that - const headers = ['SNP', 'CHR', 'BP', 'A1', 'A0', 'MAF', 'HWEP', 'INFO', 'BETA', 'SE', 'P']; - const data = [['10:48698435_A_G', '10', '48698435', 'A', 'G', '0.01353', '0.02719', '0.960443', '0.0959329', '0.0941266', '3.3E-01']]; - - const actual = guessGWAS(headers, data); - assert.deepEqual(actual, { - marker_col: 1, - pvalue_col: 11, - is_neg_log_pvalue: false, - beta_col: 9, - stderr_beta_col: 10, - }); - }); - - it('handles EPACTS', () => { - // https://genome.sph.umich.edu/wiki/EPACTS#Output_Text_of_All_Test_Statistics - const headers = ['#CHROM', 'BEGIN', 'END', 'MARKER_ID', 'NS', 'AC', 'CALLRATE', 'MAF', 'PVALUE', 'SCORE', 'N.CASE', 'N.CTRL', 'AF.CASE', 'AF.CTRL']; - const data = [['20', '1610894', '1610894', '20:1610894_G/A_Synonymous:SIRPG', '266', '138.64', '1', '0.26061', '6.9939e-05', '3.9765', '145', '121', '0.65177', '0.36476']]; - - const actual = guessGWAS(headers, data); - assert.deepEqual(actual, { marker_col: 4, pvalue_col: 9, is_neg_log_pvalue: false }); - }); - - it('handles EMMAX-EPACTS', () => { - // Sample from a file that used multiple tools - const headers = ['#CHROM', 'BEG', 'END', 'MARKER_ID', 'NS', 'AC', 'CALLRATE', 'GENOCNT', 'MAF', 'STAT', 'PVALUE', 'BETA', 'SEBETA', 'R2']; - const data = [['1', '762320', '762320', '1:762320_C/T_rs75333668', '3805', '100.00', '1.00000', '3707/96/2', '0.01314', '0.7942', '0.4271', '0.08034', '0.1012', '0.0001658']]; - - const actual = guessGWAS(headers, data); - assert.deepEqual(actual, { - marker_col: 4, - pvalue_col: 11, - is_neg_log_pvalue: false, - beta_col: 12, - stderr_beta_col: 13, - }); - }); - - it('handles METAL', () => { - const headers = ['#CHROM', 'POS', 'REF', 'ALT', 'N', 'POOLED_ALT_AF', 'DIRECTION_BY_STUDY', 'EFFECT_SIZE', 'EFFECT_SIZE_SD', 'H2', 'PVALUE']; - const data = [['1', '10177', 'A', 'AC', '491984', '0.00511094', '?-????????????????-????+???????????????????????????????????????????????????????????????????-????????????????????????????????????????????????????????????????????????????????', '-0.0257947', '0.028959', '1.61266e-06', '0.373073']]; - - const actual = guessGWAS(headers, data); - assert.deepEqual( - actual, - { - chrom_col: 1, - pos_col: 2, - ref_col: 3, - alt_col: 4, - pvalue_col: 11, - is_neg_log_pvalue: false, - beta_col: 8, - stderr_beta_col: 9, - }, - ); - }); - - it('handles PLINK', () => { - // Format: https://www.cog-genomics.org/plink2/formats - // Sample: https://github.com/babelomics/babelomics/wiki/plink.assoc - // h/t Josh Weinstock - const headers = ['CHR', 'SNP', 'BP', 'A1', 'F_A', 'F_U', 'A2', 'CHISQ', 'P']; - const data = [['1', 'rs3094315', '742429', 'C', '0.1509', '0.1394', 'T', '0.0759', '0.782', '1.097']]; - const actual = guessGWAS(headers, data); - assert.deepEqual( - actual, - { - chrom_col: 1, - pos_col: 3, - ref_col: 4, - alt_col: 7, - pvalue_col: 9, - is_neg_log_pvalue: false, - }, - ); - }); - - it('handles RAREMETAL', () => { - const headers = ['#CHROM', 'POS', 'REF', 'ALT', 'N', 'POOLED_ALT_AF', 'DIRECTION_BY_STUDY', 'EFFECT_SIZE', 'EFFECT_SIZE_SD', 'H2', 'PVALUE']; - const data = [['1', '10177', 'A', 'AC', '491984', '0.00511094', '?-????????????????-????+???????????????????????????????????????????????????????????????????-????????????????????????????????????????????????????????????????????????????????', '-0.0257947', '0.028959', '1.61266e-06', '0.373073']]; - - const actual = guessGWAS(headers, data); - assert.deepEqual( - actual, - { - chrom_col: 1, - pos_col: 2, - ref_col: 3, - alt_col: 4, - pvalue_col: 11, - is_neg_log_pvalue: false, - beta_col: 8, - stderr_beta_col: 9, - }, - ); - }); - - it('handles RAREMETALWORKER', () => { - const headers = ['#CHROM', 'POS', 'REF', 'ALT', 'N_INFORMATIVE', 'FOUNDER_AF', 'ALL_AF', 'INFORMATIVE_ALT_AC', 'CALL_RATE', 'HWE_PVALUE', 'N_REF', 'N_HET', 'N_ALT', 'U_STAT', 'SQRT_V_STAT', 'ALT_EFFSIZE', 'PVALUE']; - const data = [['9', '400066155', 'T', 'C', '432', '0', '0', '0', '1', '1', '432', '0', '0', 'NA', 'NA', 'NA', 'NA']]; - - const actual = guessGWAS(headers, data); - assert.deepEqual( - actual, - { - chrom_col: 1, - pos_col: 2, - ref_col: 3, - alt_col: 4, - pvalue_col: 17, - is_neg_log_pvalue: false, - beta_col: 16, - }, - ); - }); - - it('handles RVTESTS', () => { - // Courtesy of xyyin and gzajac - const headers = ['CHROM', 'POS', 'REF', 'ALT', 'N_INFORMATIVE', 'AF', 'INFORMATIVE_ALT_AC', 'CALL_RATE', 'HWE_PVALUE', 'N_REF', 'N_HET', 'N_ALT', 'U_STAT', 'SQRT_V_STAT', 'ALT_EFFSIZE', 'PVALUE']; - const data = [['1', '761893', 'G', 'T', '19292', '2.59624e-05:0.000655308:0', '1:1:0', '0.998289:0.996068:0.998381', '1:1:1', '19258:759:18499', '1:1:0', '0:0:0', '1.33113', '0.268484', '18.4664', '7.12493e-07']]; - - const actual = guessGWAS(headers, data); - assert.deepEqual( - actual, - { - chrom_col: 1, - pos_col: 2, - ref_col: 3, - alt_col: 4, - pvalue_col: 16, - is_neg_log_pvalue: false, - beta_col: 15, - }, - ); - }); - - it('handles SAIGE', () => { - // https://github.com/weizhouUMICH/SAIGE/wiki/SAIGE-Hands-On-Practical - const headers = ['CHR', 'POS', 'SNPID', 'Allele1', 'Allele2', 'AC_Allele2', 'AF_Allele2', 'N', 'BETA', 'SE', 'Tstat', 'p.value', 'p.value.NA', 'Is.SPA.converge', 'varT', 'varTstar']; - const data = [['chr1', '76792', 'chr1:76792:A:C', 'A', 'C', '57', '0.00168639048933983', '16900', '0.573681678183941', '0.663806747906141', '1.30193005902619', '0.387461577915637', '0.387461577915637', '1', '2.2694293866027', '2.41152256615949']]; - - const actual = guessGWAS(headers, data); - assert.deepEqual( - actual, - { - marker_col: 3, - pvalue_col: 12, - is_neg_log_pvalue: false, - beta_col: 9, - stderr_beta_col: 10, - }, - ); - }); - - it('parses a mystery format', () => { - // TODO: Identify the program used and make test more explicit - // FIXME: This test underscores difficulty of reliable ref/alt detection- a1 comes - // before a0, but it might be more valid to switch the order of these columns - const headers = ['chr', 'rs', 'ps', 'n_mis', 'n_obs', 'allele1', 'allele0', 'af', 'beta', 'se', 'p_score']; - const data = [['1', 'rs75333668', '762320', '0', '3610', 'T', 'C', '0.013', '-5.667138e-02', '1.027936e-01', '5.814536e-01']]; - const actual = guessGWAS(headers, data); - assert.deepEqual( - actual, - { - chrom_col: 1, - pos_col: 3, - ref_col: 6, - alt_col: 7, - pvalue_col: 11, - is_neg_log_pvalue: false, - beta_col: 9, - stderr_beta_col: 10, - }, - ); - }); - - it('handles output of AlisaM pipeline', () => { - const headers = ['MarkerName', 'chr', 'pos', 'ref', 'alt', 'minor.allele', 'maf', 'mac', 'n', 'pvalue', 'SNPID', 'BETA', 'SE', 'ALTFreq', 'SNPMarker']; - const data = [['chr1-281876-AC-A', 'chr1', '281876', 'AC', 'A', 'alt', '0.231428578495979', '1053', '2275', '0.447865946615285', 'rs72502741', '-0.0872936159370696', '0.115014743551501', '0.231428578495979', 'chr1:281876_AC/A']]; - - const actual = guessGWAS(headers, data); - assert.deepEqual( - actual, - { - chrom_col: 2, - pos_col: 3, - ref_col: 4, - alt_col: 5, - pvalue_col: 10, - is_neg_log_pvalue: false, - beta_col: 12, - stderr_beta_col: 13, - }, - ); - }); - - it('handles whatever diagram was using', () => { - const headers = ['Chr:Position', 'Allele1', 'Allele2', 'Effect', 'StdErr', 'P-value', 'TotalSampleSize']; - const data = [['5:29439275', 'T', 'C', '-0.0003', '0.015', '0.99', '111309']]; - - const actual = guessGWAS(headers, data); - assert.deepEqual( - actual, - { - marker_col: 1, - pvalue_col: 6, - is_neg_log_pvalue: false, - beta_col: 4, - stderr_beta_col: 5, - }, - ); - }); -});