├── .browserslistrc ├── .editorconfig ├── .env ├── .env-sample ├── .eslintignore ├── .eslintrc.js ├── .flake8 ├── .fourmat ├── .github ├── pull_request_template.md └── workflows │ └── pythonpackage.yml ├── .gitignore ├── .isort.cfg ├── .nvmrc ├── .pre-commit-config.yaml ├── CONTRIBUTORS.md ├── LICENSE ├── README.md ├── babel.config.js ├── data ├── credible_sets │ ├── ge │ │ ├── FUSION │ │ │ ├── FUSION.muscle_naive_ge.purity_filtered.sorted.txt.gz │ │ │ └── FUSION.muscle_naive_ge.purity_filtered.sorted.txt.gz.tbi │ │ ├── GTEx │ │ │ ├── GTEx.adipose_subcutaneous_ge.purity_filtered.sorted.txt.gz │ │ │ ├── GTEx.adipose_subcutaneous_ge.purity_filtered.sorted.txt.gz.tbi │ │ │ ├── GTEx.liver_ge.purity_filtered.sorted.txt.gz │ │ │ └── GTEx.liver_ge.purity_filtered.sorted.txt.gz.tbi │ │ ├── chr1.ge.credible_set.tsv.gz │ │ ├── chr1.ge.credible_set.tsv.gz.tbi │ │ └── pip.best.variant.summary.sorted.indexed.sqlite3.db │ └── txrev │ │ ├── FUSION │ │ ├── FUSION.muscle_naive_txrev.purity_filtered.sorted.txt.gz │ │ └── FUSION.muscle_naive_txrev.purity_filtered.sorted.txt.gz.tbi │ │ ├── GTEx │ │ ├── GTEx.adipose_subcutaneous_txrev.purity_filtered.sorted.txt.gz │ │ ├── GTEx.adipose_subcutaneous_txrev.purity_filtered.sorted.txt.gz.tbi │ │ ├── GTEx.esophagus_gej_txrev.purity_filtered.sorted.txt.gz │ │ └── GTEx.esophagus_gej_txrev.purity_filtered.sorted.txt.gz.tbi │ │ ├── chr1.txrev.credible_set.tsv.gz │ │ └── chr1.txrev.credible_set.tsv.gz.tbi ├── ebi_ge │ └── 1 │ │ ├── all.EBI.ge.data.chr1.109000001-110000000.tsv.gz │ │ └── all.EBI.ge.data.chr1.109000001-110000000.tsv.gz.tbi ├── ebi_original │ ├── ge │ │ ├── FUSION │ │ │ ├── FUSION_ge_muscle_naive.all.tsv.gz │ │ │ └── FUSION_ge_muscle_naive.all.tsv.gz.tbi │ │ └── GTEx │ │ │ ├── GTEx_ge_adipose_subcutaneous.all.tsv.gz │ │ │ ├── GTEx_ge_adipose_subcutaneous.all.tsv.gz.tbi │ │ │ ├── GTEx_ge_liver.all.tsv.gz │ │ │ └── GTEx_ge_liver.all.tsv.gz.tbi │ └── txrev │ │ ├── FUSION │ │ ├── FUSION_txrev_muscle_naive.all.tsv.gz │ │ └── FUSION_txrev_muscle_naive.all.tsv.gz.tbi │ │ └── GTEx │ │ ├── GTEx_txrev_adipose_subcutaneous.all.tsv.gz │ │ ├── GTEx_txrev_adipose_subcutaneous.all.tsv.gz.tbi │ │ ├── GTEx_txrev_liver.all.tsv.gz │ │ └── GTEx_txrev_liver.all.tsv.gz.tbi ├── ebi_txrev │ └── 1 │ │ ├── all.EBI.txrev.data.chr1.109000001-110000000.tsv.gz │ │ └── all.EBI.txrev.data.chr1.109000001-110000000.tsv.gz.tbi ├── gencode │ ├── convert.gencode.genes.to.tss.py │ ├── gencode.v30.annotation.gtf.genes.bed.gz │ ├── gencode.v30.annotation.gtf.genes.bed.gz.tbi │ ├── gencode.v30.annotation.gtf.transcripts.bed.gz │ ├── gencode.v30.annotation.gtf.transcripts.bed.gz.tbi │ └── tss.json.gz ├── gene.id.symbol.map.json.gz └── rsid.sqlite3.db ├── deploy.sh ├── deploy ├── README.md ├── fivex.service ├── sample-apache-https.conf └── wsgi.py ├── fivex ├── __init__.py ├── api │ ├── __init__.py │ └── format.py ├── frontend │ ├── __init__.py │ └── format.py ├── model.py └── settings │ ├── __init__.py │ ├── base.py │ ├── dev.py │ ├── prod.py │ └── test.py ├── media ├── FIVEx_tutorial_1.mp4 ├── FIVEx_tutorial_2.mp4 ├── FIVEx_tutorial_3.mp4 ├── FIVEx_tutorial_4.mp4 ├── FIVEx_tutorial_5.mp4 └── FIVEx_tutorial_6.mp4 ├── package-lock.json ├── package.json ├── public ├── favicon.png └── index.html ├── pyproject.toml ├── requirements ├── base.txt ├── dev.txt └── prod.txt ├── run-development.sh ├── setup.cfg ├── src ├── App.vue ├── assets │ └── common.css ├── components │ ├── AddTrack.vue │ ├── LzPlot.vue │ ├── SearchBox.vue │ ├── SelectAnchors.vue │ └── TabulatorTable.vue ├── lz-helpers.js ├── main.js ├── router │ └── index.js ├── util │ ├── common.js │ ├── region-helpers.js │ └── variant-helpers.js └── views │ ├── About.vue │ ├── Error.vue │ ├── Help.vue │ ├── Home.vue │ ├── NotFound.vue │ ├── Region.vue │ ├── Tutorial.vue │ └── Variant.vue ├── tests ├── __init__.py ├── conftest.py ├── test_api.py ├── test_frontend.py └── unit │ └── example.spec.js ├── util ├── README.md ├── create.UM.statgen.links.sh ├── create.index.file.for.gene.expression.EBI.data.py ├── create.rsid.sqlite3.py ├── generate.commands.to.merge.EBI.credible_sets.py ├── generate.commands.to.merge.EBI.gene.expressions.py ├── join-spot-cred-marginal-add-genenames.py ├── merge.files.with.sorted.positions.py └── summarize.highest.pip.for.each.variant.sql.py └── vue.config.js /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx}] 2 | indent_style = space 3 | indent_size = 4 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | max_line_length = 100 8 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Machine-specific settings that should not be checked into git 2 | # This file is untracked. To update the example file, see: 3 | # https://stackoverflow.com/a/6964322 4 | 5 | # Flask secret key 6 | SECRET_KEY= 7 | 8 | # Where to find the pheget data. Defaults to sample data provided with the repo. 9 | PHEGET_DATA_DIR = 'data/' 10 | 11 | # A configuration key used for an error reporting and monitoring service 12 | SENTRY_DSN= 13 | -------------------------------------------------------------------------------- /.env-sample: -------------------------------------------------------------------------------- 1 | # Machine-specific settings that should not be checked into git. Copy to `.env` (or `.env.local` or `.env.production`) 2 | # to provide custom settings for your app in the target environment. 3 | 4 | # Flask secret key 5 | SECRET_KEY= 6 | 7 | # Where to find the fivex data. Defaults to sample data provided with the repo. 8 | FIVEX_DATA_DIR = 'data/' 9 | 10 | # A configuration key used for an error reporting and monitoring service. Typically only used in production builds. 11 | # Note that the VUE build process will only incorporate user-defined values in this file that are prefixed with 12 | # "VUE_APP_", to avoid exposing secret keys. 13 | # See: https://cli.vuejs.org/guide/mode-and-env.html#environment-variables 14 | SENTRY_DSN= 15 | VUE_APP_SENTRY_DSN= 16 | 17 | # A configuration key used by Google Analytics, if applicable. Typically only used in production builds. 18 | # The key will be a measurement ID of the form "G-XXXXXXXXXX" 19 | VUE_APP_GOOGLE_ANALYTICS= 20 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Python application assets 2 | data/ 3 | .venv/ 4 | .env/ 5 | venv/ 6 | 7 | # Javascript / build assets 8 | node_modules/ 9 | package.json 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | 'extends': [ 7 | 'eslint:recommended', 8 | 'plugin:vue/essential', 9 | 'plugin:vue/recommended', 10 | ], 11 | parserOptions: { 12 | parser: 'babel-eslint' 13 | }, 14 | rules: { 15 | 'arrow-parens': 'error', 16 | 'brace-style': 'error', 17 | 'camelcase': 'off', 18 | 'comma-dangle': ['warn', 'always-multiline'], 19 | 'comma-spacing': 'warn', 20 | 'curly': 'error', 21 | 'eol-last': 'error', 22 | 'eqeqeq': ['error', 'smart'], 23 | 'indent': [ 24 | 'error', 25 | 4, 26 | { 27 | 'FunctionExpression': { 'parameters': 'first' }, 28 | 'CallExpression': { 'arguments': 'first' } 29 | } 30 | ], 31 | 'keyword-spacing': 'warn', 32 | 'linebreak-style': ['error', 'unix'], 33 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 34 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 35 | 'no-param-reassign': ['error', { 'props': false }], 36 | 'no-plusplus': ['error', { 'allowForLoopAfterthoughts': true }], 37 | 'no-underscore-dangle': 'off', 38 | 'no-unused-vars': ['error', { 'args': 'none' }], 39 | 'object-curly-newline': ['error', { consistent: true }], 40 | 'prefer-template': 'error', 41 | 'quotes': [ 42 | 'warn', 43 | 'single', 44 | { 45 | 'avoidEscape': true, 46 | 'allowTemplateLiterals': true 47 | } 48 | ], 49 | 'semi': [ 50 | 'error', 51 | 'always' 52 | ], 53 | 'space-before-blocks': 'warn', 54 | 'space-infix-ops': 'warn', 55 | 'vue/prop-name-casing': 'off', 56 | }, 57 | overrides: [ 58 | { 59 | files: [ 60 | '**/__tests__/*.{j,t}s?(x)', 61 | '**/tests/unit/**/*.spec.{j,t}s?(x)' 62 | ], 63 | env: { 64 | mocha: true 65 | } 66 | } 67 | ] 68 | }; 69 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E,W503 # Let Black handle all the formatting. 3 | exclude = 4 | tests/snapshots, 5 | .svn,CVS,.bzr,.hg,.git,__pycache__,.tox,.eggs,*.egg 6 | -------------------------------------------------------------------------------- /.fourmat: -------------------------------------------------------------------------------- 1 | fivex 2 | tests 3 | util 4 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Ticket 2 | 3 | 4 | ## Purpose 5 | 6 | 7 | ## How to test this 8 | 9 | 10 | ## Deployment / configuration notes 11 | (none) 12 | -------------------------------------------------------------------------------- /.github/workflows/pythonpackage.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | max-parallel: 4 11 | matrix: 12 | python-version: [3.8] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v1 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - uses: actions/setup-node@v1 21 | with: 22 | node-version: '12.x' 23 | - uses: actions/cache@v1 24 | with: 25 | path: ~/.cache/pip 26 | key: ${{ runner.os }}-pip-${{ hashFiles('requirements/*.txt') }} 27 | restore-keys: | 28 | ${{ runner.os }}-pip- 29 | - uses: actions/cache@v1 30 | with: 31 | path: ~/.npm 32 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 33 | restore-keys: | 34 | ${{ runner.os }}-node- 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install --upgrade pip 38 | # Install `wheel` so that pip can cache wheels it builds to save a few minutes on each run. 39 | # Pip keeps a cache of HTTPS requests in `~/.cache/pip/http/`. For packages that have only 40 | # a tar file available (and no wheel), pip builds the package itself. It only caches to 41 | # `~/.cache/pip/wheels/` if `wheel` is installed. 42 | pip install wheel 43 | pip install -r requirements/dev.txt 44 | npm ci 45 | - name: Standard linting rules 46 | run: | 47 | npx eslint . 48 | fourmat check 49 | mypy . 50 | - name: Unit tests 51 | run: | 52 | pytest tests/ 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Common python generated files 2 | __pycache__/ 3 | .mypy_cache/ 4 | .pytype/ 5 | 6 | .DS_Store 7 | node_modules 8 | /dist 9 | 10 | # Common python venv folders 11 | .venv/ 12 | venv/ 13 | 14 | # Machine-specific configuration lives in a file called .env and should not be committed. 15 | # (an example file is provided in the repo as a template) 16 | .env 17 | 18 | # local env files 19 | .env.local 20 | .env.*.local 21 | 22 | # Log files 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # JS / build assets 28 | node_modules/ 29 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | combine_as_imports = True 3 | default_section = THIRDPARTY 4 | force_grid_wrap = 0 5 | include_trailing_comma = True 6 | # Overrides pending timothycrosley/isort#719. 7 | known_standard_library = contextvars, dataclasses 8 | line_length = 79 9 | multi_line_output = 3 10 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/erbium 2 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/4Catalyzer/fourmat 3 | rev: master # or specify a version 4 | hooks: 5 | - id: fourmat 6 | 7 | - repo: https://github.com/pre-commit/mirrors-eslint 8 | rev: 'v6.7.0' 9 | hooks: 10 | - id: eslint 11 | 12 | - repo: https://github.com/pre-commit/mirrors-mypy 13 | rev: 'v0.740' # Use the sha / tag you want to point at 14 | hooks: 15 | - id: mypy 16 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | Contributors: 2 | 3 | - Alan Kwong 4 | - Mukai Wang 5 | - Peter VandeHaar 6 | - Andy Boughton 7 | - Hyun Min Kang 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 University of Michigan Center for Statistical Genetics 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FIVEx 2 | 3 | **FIVEx**: Functional Interpretation and Visualization of Expression 4 | 5 | Visualize and query eQTL data in various ways 6 | 7 | ## Development instructions 8 | This app can be installed and run on your local machine. It uses PySAM, and therefore may not install on Windows. 9 | 10 | ### Setup 11 | This code was written and tested against **Python 3.6-3.8**. We highly recommend developing in a virtual environment, 12 | created using your tool of choice (many IDEs can create and manage the virtualenv for you). For the most basic use: 13 | 14 | `$ python3 -m virtualenv venv` 15 | 16 | Then activate the virtual environment (which must be done in every command line/terminal session): 17 | `$ source venv/bin/activate/` 18 | 19 | 20 | Install dependencies (within your virtual environment), and activate pre-commit hooks for easier development: 21 | ```bash 22 | $ pip3 install -r requirements/dev.txt 23 | $ pre-commit install 24 | $ pre-commit install-hooks 25 | ``` 26 | 27 | For a development instance, you will also need to install additional dependencies: 28 | ```bash 29 | $ pip3 install -r requirements/dev.txt 30 | $ npm install --dev 31 | ``` 32 | 33 | ### Source data 34 | For the prototype, source data will live in the folder `data/`. Really large files should not be checked into github, 35 | so you will need to download them separately for your environment. 36 | 37 | To change settings specific to an individual machine (such as the data directory), edit the contents of `.env` 38 | in the root directory of your project. A `.env-sample` file is provided as a template. 39 | 40 | For University of Michigan internal development, files can be found at the following locations: 41 | 42 | Tutorial videos: /net/amd/amkwong/media/ 43 | Data files: /net/amd/amkwong/FIVEx/data/ 44 | 45 | Tutorial videos should be copied or linked to the `media/` directory, and 46 | data files should be copied or linked to the `data/` directory. 47 | 48 | ### Running the development server 49 | Make sure to activate your virtualenv at the start of every new terminal session: `$ source venv/bin/activate` 50 | 51 | The following command will start a basic flask app server that reloads whenever code is changed: 52 | `$ ./run-development.sh` 53 | 54 | Flask powers the backend. The frontend uses Vue.js, and you will need to start the Vue CLI development server 55 | (in a separate terminal) in order to make UI changes. This will cause your HTML to continuously rebuild, making it 56 | easier to modify the code for your site: 57 | `$ npm run serve` 58 | 59 | Then follow the instructions printed to the vue-server console, to visit the app in your web browser. You should visit 60 | the URL specified by Vue (not flask): in development, the vue server will proxy requests to flask for a seamless 61 | experience. 62 | 63 | ### Additional production options 64 | You may configure Sentry error monitoring by setting the config option `SENTRY_DSN` in your `.env` file. 65 | 66 | ### Testing and code quality 67 | Before any commit, please run the following commands. (a sample pre-commit hook is provided that will do this for you, 68 | automatically) 69 | 70 | These commands will perform static analysis to catch common bugs, and auto-format your code in a way intended to 71 | reduce merge conflicts due to formatting issues. (so that you won't have to satisfy the linter manually) 72 | 73 | ```bash 74 | $ fourmat fix 75 | $ eslint . --fix 76 | $ mypy . 77 | $ pytest . 78 | $ npm run test:unit 79 | ``` 80 | 81 | The linting commands are run on every commit, and can be triggered manually via: `pre-commit run --all-files`. 82 | 83 | Because unit tests can be more complex, these must be run separately (or during the CI step). Mostly, we separate this 84 | step to avoid making commits slow. 85 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset', 4 | ], 5 | }; 6 | -------------------------------------------------------------------------------- /data/credible_sets/ge/FUSION/FUSION.muscle_naive_ge.purity_filtered.sorted.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/credible_sets/ge/FUSION/FUSION.muscle_naive_ge.purity_filtered.sorted.txt.gz -------------------------------------------------------------------------------- /data/credible_sets/ge/FUSION/FUSION.muscle_naive_ge.purity_filtered.sorted.txt.gz.tbi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/credible_sets/ge/FUSION/FUSION.muscle_naive_ge.purity_filtered.sorted.txt.gz.tbi -------------------------------------------------------------------------------- /data/credible_sets/ge/GTEx/GTEx.adipose_subcutaneous_ge.purity_filtered.sorted.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/credible_sets/ge/GTEx/GTEx.adipose_subcutaneous_ge.purity_filtered.sorted.txt.gz -------------------------------------------------------------------------------- /data/credible_sets/ge/GTEx/GTEx.adipose_subcutaneous_ge.purity_filtered.sorted.txt.gz.tbi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/credible_sets/ge/GTEx/GTEx.adipose_subcutaneous_ge.purity_filtered.sorted.txt.gz.tbi -------------------------------------------------------------------------------- /data/credible_sets/ge/GTEx/GTEx.liver_ge.purity_filtered.sorted.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/credible_sets/ge/GTEx/GTEx.liver_ge.purity_filtered.sorted.txt.gz -------------------------------------------------------------------------------- /data/credible_sets/ge/GTEx/GTEx.liver_ge.purity_filtered.sorted.txt.gz.tbi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/credible_sets/ge/GTEx/GTEx.liver_ge.purity_filtered.sorted.txt.gz.tbi -------------------------------------------------------------------------------- /data/credible_sets/ge/chr1.ge.credible_set.tsv.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/credible_sets/ge/chr1.ge.credible_set.tsv.gz -------------------------------------------------------------------------------- /data/credible_sets/ge/chr1.ge.credible_set.tsv.gz.tbi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/credible_sets/ge/chr1.ge.credible_set.tsv.gz.tbi -------------------------------------------------------------------------------- /data/credible_sets/ge/pip.best.variant.summary.sorted.indexed.sqlite3.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/credible_sets/ge/pip.best.variant.summary.sorted.indexed.sqlite3.db -------------------------------------------------------------------------------- /data/credible_sets/txrev/FUSION/FUSION.muscle_naive_txrev.purity_filtered.sorted.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/credible_sets/txrev/FUSION/FUSION.muscle_naive_txrev.purity_filtered.sorted.txt.gz -------------------------------------------------------------------------------- /data/credible_sets/txrev/FUSION/FUSION.muscle_naive_txrev.purity_filtered.sorted.txt.gz.tbi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/credible_sets/txrev/FUSION/FUSION.muscle_naive_txrev.purity_filtered.sorted.txt.gz.tbi -------------------------------------------------------------------------------- /data/credible_sets/txrev/GTEx/GTEx.adipose_subcutaneous_txrev.purity_filtered.sorted.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/credible_sets/txrev/GTEx/GTEx.adipose_subcutaneous_txrev.purity_filtered.sorted.txt.gz -------------------------------------------------------------------------------- /data/credible_sets/txrev/GTEx/GTEx.adipose_subcutaneous_txrev.purity_filtered.sorted.txt.gz.tbi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/credible_sets/txrev/GTEx/GTEx.adipose_subcutaneous_txrev.purity_filtered.sorted.txt.gz.tbi -------------------------------------------------------------------------------- /data/credible_sets/txrev/GTEx/GTEx.esophagus_gej_txrev.purity_filtered.sorted.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/credible_sets/txrev/GTEx/GTEx.esophagus_gej_txrev.purity_filtered.sorted.txt.gz -------------------------------------------------------------------------------- /data/credible_sets/txrev/GTEx/GTEx.esophagus_gej_txrev.purity_filtered.sorted.txt.gz.tbi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/credible_sets/txrev/GTEx/GTEx.esophagus_gej_txrev.purity_filtered.sorted.txt.gz.tbi -------------------------------------------------------------------------------- /data/credible_sets/txrev/chr1.txrev.credible_set.tsv.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/credible_sets/txrev/chr1.txrev.credible_set.tsv.gz -------------------------------------------------------------------------------- /data/credible_sets/txrev/chr1.txrev.credible_set.tsv.gz.tbi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/credible_sets/txrev/chr1.txrev.credible_set.tsv.gz.tbi -------------------------------------------------------------------------------- /data/ebi_ge/1/all.EBI.ge.data.chr1.109000001-110000000.tsv.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/ebi_ge/1/all.EBI.ge.data.chr1.109000001-110000000.tsv.gz -------------------------------------------------------------------------------- /data/ebi_ge/1/all.EBI.ge.data.chr1.109000001-110000000.tsv.gz.tbi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/ebi_ge/1/all.EBI.ge.data.chr1.109000001-110000000.tsv.gz.tbi -------------------------------------------------------------------------------- /data/ebi_original/ge/FUSION/FUSION_ge_muscle_naive.all.tsv.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/ebi_original/ge/FUSION/FUSION_ge_muscle_naive.all.tsv.gz -------------------------------------------------------------------------------- /data/ebi_original/ge/FUSION/FUSION_ge_muscle_naive.all.tsv.gz.tbi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/ebi_original/ge/FUSION/FUSION_ge_muscle_naive.all.tsv.gz.tbi -------------------------------------------------------------------------------- /data/ebi_original/ge/GTEx/GTEx_ge_adipose_subcutaneous.all.tsv.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/ebi_original/ge/GTEx/GTEx_ge_adipose_subcutaneous.all.tsv.gz -------------------------------------------------------------------------------- /data/ebi_original/ge/GTEx/GTEx_ge_adipose_subcutaneous.all.tsv.gz.tbi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/ebi_original/ge/GTEx/GTEx_ge_adipose_subcutaneous.all.tsv.gz.tbi -------------------------------------------------------------------------------- /data/ebi_original/ge/GTEx/GTEx_ge_liver.all.tsv.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/ebi_original/ge/GTEx/GTEx_ge_liver.all.tsv.gz -------------------------------------------------------------------------------- /data/ebi_original/ge/GTEx/GTEx_ge_liver.all.tsv.gz.tbi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/ebi_original/ge/GTEx/GTEx_ge_liver.all.tsv.gz.tbi -------------------------------------------------------------------------------- /data/ebi_original/txrev/FUSION/FUSION_txrev_muscle_naive.all.tsv.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/ebi_original/txrev/FUSION/FUSION_txrev_muscle_naive.all.tsv.gz -------------------------------------------------------------------------------- /data/ebi_original/txrev/FUSION/FUSION_txrev_muscle_naive.all.tsv.gz.tbi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/ebi_original/txrev/FUSION/FUSION_txrev_muscle_naive.all.tsv.gz.tbi -------------------------------------------------------------------------------- /data/ebi_original/txrev/GTEx/GTEx_txrev_adipose_subcutaneous.all.tsv.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/ebi_original/txrev/GTEx/GTEx_txrev_adipose_subcutaneous.all.tsv.gz -------------------------------------------------------------------------------- /data/ebi_original/txrev/GTEx/GTEx_txrev_adipose_subcutaneous.all.tsv.gz.tbi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/ebi_original/txrev/GTEx/GTEx_txrev_adipose_subcutaneous.all.tsv.gz.tbi -------------------------------------------------------------------------------- /data/ebi_original/txrev/GTEx/GTEx_txrev_liver.all.tsv.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/ebi_original/txrev/GTEx/GTEx_txrev_liver.all.tsv.gz -------------------------------------------------------------------------------- /data/ebi_original/txrev/GTEx/GTEx_txrev_liver.all.tsv.gz.tbi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/ebi_original/txrev/GTEx/GTEx_txrev_liver.all.tsv.gz.tbi -------------------------------------------------------------------------------- /data/ebi_txrev/1/all.EBI.txrev.data.chr1.109000001-110000000.tsv.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/ebi_txrev/1/all.EBI.txrev.data.chr1.109000001-110000000.tsv.gz -------------------------------------------------------------------------------- /data/ebi_txrev/1/all.EBI.txrev.data.chr1.109000001-110000000.tsv.gz.tbi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/ebi_txrev/1/all.EBI.txrev.data.chr1.109000001-110000000.tsv.gz.tbi -------------------------------------------------------------------------------- /data/gencode/convert.gencode.genes.to.tss.py: -------------------------------------------------------------------------------- 1 | import gzip 2 | import json 3 | 4 | # Extract transcript start site information from the file containing GENCODE gene information 5 | # Also encodes the strand information as the sign of the TSS: 6 | # a TSS on the + strand is recorded as a positive TSS, while 7 | # a TSS on the - strand is recorded as a negative TSS 8 | tssDict = dict() 9 | with gzip.open('gencode.v30.annotation.gtf.genes.bed.gz', 'r') as f: 10 | for line in f: 11 | temp = line.decode('utf-8').rstrip('\n').split() 12 | if len(temp) == 9: 13 | (chrom, dataSource, dataType, start, end, strand, geneID, geneType, geneSymbol) = temp 14 | if strand == '+': 15 | tss = int(start) 16 | elif strand == '-': 17 | tss = -1 * int(end) 18 | else: 19 | tss = -1 20 | tssDict[geneSymbol] = tss 21 | tssDict[geneID.split(".")[0]] = tss 22 | with gzip.open('tss.json.gz', 'wb') as w: 23 | w.write(json.dumps(tssDict, separators=(',',':')).encode('utf-8')) 24 | -------------------------------------------------------------------------------- /data/gencode/gencode.v30.annotation.gtf.genes.bed.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/gencode/gencode.v30.annotation.gtf.genes.bed.gz -------------------------------------------------------------------------------- /data/gencode/gencode.v30.annotation.gtf.genes.bed.gz.tbi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/gencode/gencode.v30.annotation.gtf.genes.bed.gz.tbi -------------------------------------------------------------------------------- /data/gencode/gencode.v30.annotation.gtf.transcripts.bed.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/gencode/gencode.v30.annotation.gtf.transcripts.bed.gz -------------------------------------------------------------------------------- /data/gencode/gencode.v30.annotation.gtf.transcripts.bed.gz.tbi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/gencode/gencode.v30.annotation.gtf.transcripts.bed.gz.tbi -------------------------------------------------------------------------------- /data/gencode/tss.json.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/gencode/tss.json.gz -------------------------------------------------------------------------------- /data/gene.id.symbol.map.json.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/gene.id.symbol.map.json.gz -------------------------------------------------------------------------------- /data/rsid.sqlite3.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/data/rsid.sqlite3.db -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Sample production deployment script. Must be run by a user with write permissions on all relevant folders. 4 | 5 | # Stop on failure and echo each instruction run (to aid debugging deploy failures) 6 | set -e 7 | set -x 8 | 9 | ## For now, update code manually: it doesn't make sense to run a deployment script that is out of date 10 | # As we use this script for a while, we can consider automating the git steps too (once we know that deploy.sh won't 11 | # change much) 12 | #git checkout master 13 | #git pull 14 | 15 | # Update dependencies (respecting package lock files where relevant) 16 | /data/eqtl-browser/venv/bin/pip3 install -r requirements/prod.txt 17 | npm ci 18 | 19 | # Remove previous built assets and replace with the newest vue.js frontend code 20 | npm run build 21 | rm -rf /var/www/fivex/* 22 | cp -r dist/* /var/www/fivex 23 | 24 | sudo service fivex restart 25 | -------------------------------------------------------------------------------- /deploy/README.md: -------------------------------------------------------------------------------- 1 | # Deployment instructions 2 | 3 | This app is deployed using gunicorn and a reverse proxy. Sample files are provided in this folder. 4 | 5 | You will need to read them carefully, and update paths as appropriate. 6 | 7 | ## First time setup steps 8 | 1. Follow the setup instructions in the README to check out the code and create a virtual environment. 9 | * Note that some distros (such as Ubuntu 16.04 LTS) may come with an older Python version by default, but 10 | Python >=3.6 is required. 11 | 2. Make sure to populate a settings file (`.env`) in the code directory with the required information. For production, 12 | acquire a copy of the processed eQTL data and update your `.env` file to point to it. 13 | 3. Activate the virtual environment and install dependencies: `source .venv/bin/activate && pip install -r requirements/prod.txt` 14 | 4. Update the paths in `fivex.service` to point at your application folder and follow the instructions in that file 15 | to activate this as a systemd service 16 | 5. Check that the site is hosted on port 8877 by running `curl http://localhost:8877`. 17 | 6. Copy `sample-apache-https.conf` to `/etc/apache2/sites-available/002-fivex.conf` and update the domain name 18 | (and `DocumentRoot`) to match your environment. Make sure to grant permissions to the `dist` folder. 19 | - Build the static JS assets by running `npm install && npm run build`. The apache config file should point to 20 | the resulting `dist/` file as the new `DocumentRoot`. 21 | - Run `sudo a2enmod headers proxy proxy_http rewrite` 22 | - Activate the new configuration using `sudo a2ensite 002-fivex.conf && sudo service apache2 reload` 23 | 7. Enable HTTPS using LetsEncrypt: 24 | - Follow the instructions to create an SSL certificate using [LetsEncrypt](https://certbot.eff.org/), 25 | installing the certificate with `sudo certbot --apache`. 26 | - Test the site in your browser. 27 | 28 | 29 | ## Subsequent deployments 30 | Once your app is configured, you can use the provided script `./deploy.sh` to run all the required steps. 31 | This must be run from the project root folder, by a user with write access to all directories. 32 | The static asset folder in the script should match your apache configuration (eg `/var/www/fivex`) and you should 33 | have write access. 34 | -------------------------------------------------------------------------------- /deploy/fivex.service: -------------------------------------------------------------------------------- 1 | # A systemd service file to run the app, and ensure it restarts when the server goes down. Install in: 2 | # /etc/systemd/system/ 3 | 4 | # Sample commands to use: 5 | # sudo systemctl daemon-reload # makes systemd notice changes to this file 6 | # sudo systemctl enable fivex # run once (re-running is fine) so that systemd knows to run this when the system starts 7 | # sudo systemctl start fivex 8 | # sudo systemctl restart fivex 9 | # sudo systemctl status -n30 fivex # show status and the last 30 lines of output 10 | 11 | # Patterned on: 12 | # https://www.digitalocean.com/community/tutorials/how-to-serve-flask-applications-with-gunicorn-and-nginx-on-ubuntu-18-04 13 | 14 | # See also: 15 | # https://www.digitalocean.com/community/tutorials/how-to-use-systemctl-to-manage-systemd-services-and-units 16 | 17 | [Unit] 18 | Description=Gunicorn instance to serve FIVEx 19 | After=network.target 20 | 21 | [Service] 22 | User=www-data 23 | Group=www-data 24 | WorkingDirectory=/data/eqtl-browser/fivex/ 25 | ExecStart=/data/eqtl-browser/venv/bin/gunicorn -k gevent --workers 4 -m 007 --bind localhost:8877 --pythonpath /data/eqtl-browser/fivex/deploy/,/data/eqtl-browser/fivex/ wsgi:app 26 | 27 | [Install] 28 | WantedBy=multi-user.target 29 | -------------------------------------------------------------------------------- /deploy/sample-apache-https.conf: -------------------------------------------------------------------------------- 1 | 2 | # All HTTP requests are redirected to HTTPS 3 | ServerName example.org 4 | 5 | RewriteEngine on 6 | RewriteCond %{SERVER_NAME} =example.org 7 | RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent] 8 | 9 | LogLevel warn 10 | ErrorLog ${APACHE_LOG_DIR}/fivex-error.log 11 | CustomLog ${APACHE_LOG_DIR}/fivex-access.log combined 12 | 13 | 14 | 15 | 16 | 17 | ServerName example.org 18 | 19 | # For this application, root should be the static asset folder (eg, built assets are copied to /var/www/fivex 20 | # before deploy) 21 | DocumentRoot /var/www/fivex 22 | 23 | # Grant permission to the DocumentRoot directory 24 | Options -Indexes 25 | Require all granted 26 | 27 | 28 | # Send /api/ requests to flask 29 | ProxyPass /api/ http://127.0.0.1:8877/ keepalive=On 30 | ProxyPassReverse /api/ http://127.0.0.1:8877/ 31 | 32 | # All other requests are directed to the frontend. Static assets are served directly, and any other url is sent 33 | # to index.html, to see if it is recognized by vue-router 34 | # Requires `a2enmod rewrite` 35 | RewriteEngine On 36 | RewriteRule ^index\.html$ - [L] 37 | RewriteRule ^/api/ - [L] 38 | RewriteCond %{DOCUMENT_ROOT}%{REQUEST_FILENAME} !-f 39 | RewriteCond %{DOCUMENT_ROOT}%{REQUEST_FILENAME} !-d 40 | RewriteRule . /index.html [L] 41 | 42 | # Miscellaneous configuration 43 | # SSL configuration goes here! This config file will work well with certbot/apache 44 | 45 | LogLevel warn 46 | ErrorLog ${APACHE_LOG_DIR}/fivex-error.log 47 | CustomLog ${APACHE_LOG_DIR}/fivex-access.log combined 48 | 49 | 50 | -------------------------------------------------------------------------------- /deploy/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | Sample wsgi file for running the app in production 3 | """ 4 | import fivex 5 | 6 | app = fivex.create_app("fivex.settings.prod") 7 | -------------------------------------------------------------------------------- /fivex/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines the FIVEx web application 3 | """ 4 | import flask 5 | 6 | from fivex.api import api_blueprint 7 | from fivex.frontend import views_blueprint 8 | 9 | 10 | def create_app(settings_module="fivex.settings.dev"): 11 | """Application factory (allows different settings for dev, prod, or test environments)""" 12 | app = flask.Flask(__name__) 13 | app.config.from_object(settings_module) 14 | 15 | # This flask app implements a JSON API, and is presented via proxy as, eg `/api/data` and `/api/views` 16 | # In prod, the proxy is handled by apache. In development, vue handles it. This means that for development, 17 | # you will need to run both the Vue and Flask servers, and access the API via the Vue CLI server URL. 18 | app.register_blueprint(api_blueprint, url_prefix="/data") 19 | app.register_blueprint(views_blueprint, url_prefix="/views") 20 | 21 | if app.config["SENTRY_DSN"]: 22 | # Only activate sentry if it is configured for this app 23 | import sentry_sdk # type: ignore 24 | from sentry_sdk.integrations.flask import FlaskIntegration # type: ignore 25 | 26 | sentry_sdk.init( 27 | app.config["SENTRY_DSN"], integrations=[FlaskIntegration()] 28 | ) 29 | return app 30 | -------------------------------------------------------------------------------- /fivex/api/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | API endpoints (return JSON, not HTML) 3 | """ 4 | from flask import Blueprint, jsonify, request 5 | from zorp import readers # type: ignore 6 | 7 | from .. import model 8 | from .format import CIParser, query_variants 9 | 10 | api_blueprint = Blueprint("api", __name__) 11 | 12 | 13 | @api_blueprint.route( 14 | "/region//-///", 15 | methods=["GET"], 16 | ) 17 | def region_query(chrom, start, end, study, tissue): 18 | """ 19 | Fetch the eQTL data for a given region, optionally filtering by tissue and gene_id 20 | 21 | In its current form, this allows fetching ALL points across any gene and tissue. We may wish to revisit this 22 | due to performance considerations. (FIXME) 23 | """ 24 | # Study and tissue are now both required parameters 25 | gene_id = request.args.get("gene_id", None) 26 | transcript = request.args.get("transcript", None) 27 | piponly = request.args.get("piponly", None) 28 | datatype = request.args.get("datatype", "ge") 29 | if gene_id is not None: 30 | gene_id = gene_id.split(".")[0] 31 | 32 | data = [ 33 | res.to_dict() 34 | for res in query_variants( 35 | chrom=chrom, 36 | start=start, 37 | rowstoskip=1, # Region query uses the original EBI data files, which all have a header row 38 | end=end, 39 | study=study, 40 | tissue=tissue, 41 | gene_id=gene_id, 42 | transcript=transcript, 43 | piponly=piponly, 44 | datatype=datatype, 45 | ) 46 | ] 47 | 48 | for i, item in enumerate(data): 49 | # TODO: This may be unnecessary when we have a proper marker or variant field 50 | item["id"] = i 51 | 52 | results = {"data": data} 53 | return jsonify(results) 54 | 55 | 56 | @api_blueprint.route( 57 | "/best/region//-/", methods=["GET"] 58 | ) 59 | def region_query_bestvar(chrom: str, start: int, end: int): 60 | """ 61 | Given a region, returns the tissue with the strongest single eQTL signal, along with gene symbol and gene_id. 62 | 63 | Optionally, a gene_id can be specified as query param, in which it will get the best signal within that gene. 64 | """ 65 | gene_id = request.args.get("gene_id", None) 66 | if gene_id is not None: 67 | gene_id = gene_id.split(".")[0] 68 | gene_json = model.get_gene_names_conversion() 69 | if chrom is not None and chrom[0:3] == "chr": 70 | chrom = chrom[3:] 71 | 72 | ( 73 | gene_id, 74 | chrom, 75 | pos, 76 | ref, 77 | alt, 78 | pip, 79 | study, 80 | tissue, 81 | ) = model.get_best_study_tissue_gene( 82 | chrom, start=start, end=end, gene_id=gene_id 83 | ) 84 | 85 | data = { 86 | "chrom": chrom, 87 | "pos": pos, 88 | "ref": ref, 89 | "alt": alt, 90 | "study": study, 91 | "tissue": tissue, 92 | "gene_id": gene_id, 93 | "symbol": gene_json.get(gene_id, "Unknown_gene"), 94 | } 95 | results = {"data": data} 96 | return jsonify(results) 97 | 98 | 99 | @api_blueprint.route("/variant/_/", methods=["GET"]) 100 | def variant_query(chrom: str, pos: int): 101 | """ 102 | Fetch the data for a single variant (for a PheWAS plot) 103 | 104 | This can optionally filter by gene or tissue, but by default it returns all data. 105 | """ 106 | tissue = request.args.get("tissue", None) 107 | gene_id = request.args.get("gene_id", None) 108 | transcript = request.args.get("transcript", None) 109 | datatype = request.args.get("datatype", "ge") 110 | study = request.args.get( 111 | "study", None 112 | ) # We now have data from multiple studies from EBI 113 | 114 | data = [ 115 | res.to_dict() 116 | for res in query_variants( 117 | chrom=chrom, 118 | start=pos, 119 | rowstoskip=0, 120 | end=None, 121 | tissue=tissue, 122 | study=study, 123 | gene_id=gene_id, 124 | transcript=transcript, 125 | datatype=datatype, 126 | ) 127 | ] 128 | 129 | for i, item in enumerate(data): 130 | # FIXME: replace this synthetic field with some other unique identifier (like a marker) 131 | item["id"] = i 132 | 133 | results = {"data": data} 134 | return jsonify(results) 135 | 136 | 137 | @api_blueprint.route( 138 | "/cs//-/", methods=["GET"] 139 | ) 140 | def region_data_for_region_table(chrom: str, start: int, end: int): 141 | """ 142 | Fetch the data for a region to populate the table in region view 143 | Retrieves all data from the chromosome-specific merged credible_sets file 144 | """ 145 | datatype = request.args.get("datatype", "ge") 146 | gene_id = request.args.get("gene_id", None) 147 | source = model.get_credible_data_table(chrom, datatype) 148 | reader = readers.TabixReader( 149 | source=source, parser=CIParser(study=None, tissue=None), skip_rows=0, 150 | ) 151 | if gene_id is not None: 152 | reader.add_filter("gene_id", gene_id) 153 | ciRows = reader.fetch(chrom, start - 1, end + 1) 154 | # We want to retrieve the following data to fill the columns of our table: 155 | # Variant (chr:pos_ref/alt), study, tissue, P-value, effect size, SD(effect size), PIP, cs_label, cs_size 156 | # Each row holds the following data: 157 | # study: str 158 | # tissue: str 159 | # gene_id: str # this column is labeled "phenotype_id" in the original file 160 | # var_id: str # in chrom_pos_ref_alt format -- not used 161 | # chromosome: str 162 | # position: int 163 | # ref_allele: str 164 | # alt_allele: str 165 | # cs_id: str 166 | # cs_index: str 167 | # finemapped_region: str 168 | # pip: float 169 | # z: float 170 | # cs_min_r2: float 171 | # cs_avg_r2: float 172 | # cs_size: int 173 | # posterior_mean: float 174 | # posterior_sd: float 175 | # cs_log10bf: float 176 | 177 | # The CI file contains a variety of information that is not used by the table; limit what gets sent to the frontend 178 | subset_fields = { 179 | "study", 180 | "tissue", 181 | "gene_id", 182 | "chromosome", 183 | "position", 184 | "ref_allele", 185 | "alt_allele", 186 | "cs_index", 187 | "pip", 188 | "cs_size", 189 | "variant_id", 190 | "log_pvalue", 191 | "beta", 192 | "stderr_beta", 193 | "symbol", 194 | } 195 | data = [{k: getattr(row, k) for k in subset_fields} for row in ciRows] 196 | results = {"data": data} 197 | return jsonify(results) 198 | -------------------------------------------------------------------------------- /fivex/frontend/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Front end views: provide the data needed by pages that are visited in the web browser 3 | """ 4 | import gzip 5 | import json 6 | import sqlite3 7 | 8 | from flask import Blueprint, abort, jsonify, redirect, request, url_for 9 | from genelocator import exception as gene_exc, get_genelocator # type: ignore 10 | from zorp import readers # type: ignore 11 | 12 | from .. import model 13 | from ..api.format import ( 14 | TISSUES_PER_STUDY, 15 | TISSUES_TO_SYSTEMS, 16 | position_to_variant_id, 17 | ) 18 | from .format import gencodeParser, gencodeTranscriptParser 19 | 20 | gl = get_genelocator("GRCh38", gencode_version=32, coding_only=True) 21 | 22 | views_blueprint = Blueprint("frontend", __name__, template_folder="templates") 23 | 24 | 25 | @views_blueprint.route("/region/", methods=["GET"]) 26 | def region_view(): 27 | """Region view""" 28 | # The canonical form of this view uses start and end query params 29 | # However, for convenience, some plots will want to link by a single position. 30 | # We implement this as a simple redirect that otherwise preserves the query string. 31 | pos = request.args.get("position", None) 32 | if pos: 33 | args = request.args.copy() 34 | pos = int(pos) 35 | args.pop("position") 36 | # FIXME: Add a smarter way of choosing default region size etc (eg making MAX_SIZE a config option, to allow 37 | # performance to be tuned per dataset) 38 | args["start"] = max(pos - 500000, 1) 39 | args["end"] = pos + 500000 40 | return redirect(url_for("frontend.region_view", **args)) 41 | 42 | # Chromosome is always required 43 | try: 44 | chrom = request.args["chrom"] 45 | if chrom[0:3] == "chr": 46 | chrom = chrom[3:] 47 | except (KeyError, ValueError): 48 | return abort(400) 49 | 50 | # We allow a few different combinations of other missing data 51 | # by looking up the best tissue, best gene, or proper range if they are missing 52 | # We will use the new get_best_study_tissue_gene 53 | # parameters: (chrom, start=None, end=None, study=None, tissue=None, gene_id=None) 54 | 55 | # First, process start, end, tissue, study, and gene 56 | start = request.args.get("start", None) 57 | end = request.args.get("end", None) 58 | if start is not None: 59 | start = int(start) 60 | if end is not None: 61 | end = int(end) 62 | 63 | tissue = request.args.get("tissue", None) 64 | # If study is missing, we will fetch it from get_best_study_tissue_gene 65 | study = request.args.get("study", None) 66 | 67 | # One of these params is needed (TODO: Pick one of these and resolve differences via omnisearch) 68 | # Always strip version numbers from ENSG# 69 | gene_id = request.args.get("gene_id", None) 70 | if gene_id is not None: 71 | gene_id = gene_id.split(".")[0] 72 | symbol = request.args.get("symbol", None) 73 | 74 | # If the request does not include a start or end position, then find the TSS and strand information, 75 | # then generate a window based on this information 76 | if start is None and end is None: 77 | with gzip.open(model.locate_tss_data(), "rb") as f: 78 | tss_dict = json.load(f) 79 | # tss contains two pieces of information: the genomic position of the TSS, and the strand 80 | # if it is the + strand, then the tss is positive; if it is the - strand, then the tss is negative 81 | tss = tss_dict.get(gene_id, None) 82 | if tss is None: 83 | return abort(400) 84 | else: 85 | # We will generate a viewing window 250k around the TSS for simplicity 86 | # TODO: actually retrieve the start and end positions from gencode and use those instead 87 | # (if it's not too wide) 88 | start = max(abs(int(tss)) - 250000, 1) 89 | end = start + 500000 90 | 91 | # First, load the gene_id -> gene_symbol conversion table (no version numbers at the end of ENSG's) 92 | gene_json = model.get_gene_names_conversion() 93 | 94 | # If either gene_id or symbol is present, then fill in the other 95 | if symbol is not None and gene_id is None: 96 | gene_id = gene_json.get(symbol, None) 97 | 98 | # We will use get_best_study_tissue_gene(chrom, start, end) to directly query for a recommendation 99 | # Given only a chromosome or a range, this will return the following: 100 | # (gene_id, chrom, pos, ref, alt, pip, study, tissue) 101 | # from which we will grab study, tissue, and gene_id as the recommended plot to show. 102 | # The returned data is: (gene_id, chrom, pos, ref, alt, pip, study, tissue) 103 | 104 | # Get the full tissue list from TISSUE_DATA 105 | tissue_list = TISSUES_TO_SYSTEMS.keys() 106 | 107 | # If there are missing pieces of data, try to fill it in using get_best_study_tissue_gene 108 | if None in (study, tissue, gene_id): 109 | # Retrieve the study x tissue x gene combination with highest PIP 110 | ( 111 | gene_id, 112 | chrom, 113 | pos, 114 | _, 115 | _, 116 | _, 117 | study, 118 | tissue, 119 | ) = model.get_best_study_tissue_gene( 120 | chrom, 121 | start=start, 122 | end=end, 123 | study=study, 124 | tissue=tissue, 125 | gene_id=gene_id, 126 | ) 127 | center = pos 128 | # If both start and end exist, calculate the center 129 | if start and end: 130 | center = (end + start) // 2 131 | 132 | if symbol is None and gene_id is not None: 133 | # After looking up best gene, fill in missing symbol. TODO: In future, this should be sourced from the SQL query, once the database file / credsets file contains both gene name and ID in one place 134 | symbol = gene_json.get(gene_id.split(".")[0], None) 135 | 136 | source = model.locate_gencode_data() 137 | reader = readers.TabixReader(source, parser=gencodeParser(), skip_rows=0) 138 | gencodeRows = reader.fetch(f"chr{chrom}", start - 1, end + 1) 139 | gene_list = [] 140 | for row in gencodeRows: 141 | gene_list.append(row.gene_id) 142 | gene_dict = { 143 | str(geneid): str(gene_json.get(geneid, geneid)) for geneid in gene_list 144 | } 145 | 146 | return jsonify( 147 | { 148 | "chrom": chrom, 149 | "start": start, 150 | "end": end, 151 | "center": center, 152 | "gene_id": gene_id, 153 | "study": study, 154 | "tissue": tissue, 155 | "symbol": symbol, 156 | "tissue_list": list(tissue_list), 157 | "tissues_per_study": TISSUES_PER_STUDY, 158 | "gene_list": gene_dict, 159 | } 160 | ) 161 | 162 | 163 | @views_blueprint.route("/variant/_/") 164 | def variant_view(chrom: str, pos: int): 165 | """Single variant (PheWAS) view""" 166 | try: 167 | nearest_genes = gl.at(chrom, pos) 168 | except (gene_exc.NoResultsFoundException, gene_exc.BadCoordinateException): 169 | nearest_genes = [] 170 | 171 | data_type = request.args.get("data_type", "ge") 172 | if data_type not in ["ge", "txrev"]: 173 | # eqtl or sqtls supported. TODO dedup with enum 174 | return abort(400) 175 | 176 | # Query the best variant SQLite3 database to retrieve the top gene by PIP 177 | pipIndexErrorFlag = False 178 | conn = sqlite3.connect( 179 | model.get_best_per_variant_lookup(data_type=data_type) 180 | ) 181 | with conn: 182 | try: 183 | ( 184 | _, 185 | top_study, 186 | top_tissue, 187 | top_gene, 188 | chrom, 189 | pos, 190 | ref, 191 | alt, 192 | _, 193 | _, 194 | _, 195 | ) = list( 196 | conn.execute( 197 | "SELECT * FROM sig WHERE chrom=? and pos=? ORDER BY pip DESC LIMIT 1;", 198 | (chrom, pos), 199 | ) 200 | )[ 201 | 0 202 | ] 203 | # Sometimes the variant is not present at all in the best variant database 204 | # This is expected behavior, in this case we store valid empty responses 205 | except IndexError: 206 | top_study = "No_study" 207 | top_tissue = "No_tissue" 208 | top_gene = "No_gene" 209 | pipIndexErrorFlag = True 210 | # return abort(400) 211 | 212 | # Are the "nearest genes" nearby, or is the variant actually inside the gene? 213 | # These rules are based on the defined behavior of the genes locator 214 | is_inside_gene = len(nearest_genes) > 1 or ( 215 | len(nearest_genes) == 1 216 | and nearest_genes[0]["start"] <= pos <= nearest_genes[0]["end"] 217 | ) 218 | 219 | # Retrieve gene_symbol from gene_id 220 | gene_json = model.get_gene_names_conversion() 221 | gene_symbol = gene_json.get(top_gene, "Unknown_gene") 222 | 223 | # Query rsid database for chrom, pos, ref, alt, rsid (we only keep the last 3) 224 | (_, _, rref, ralt, rsid) = model.return_rsid(chrom, pos) 225 | 226 | # If the variant is missing from our credible_sets database, 227 | # use ref and alt from rsid database 228 | if pipIndexErrorFlag: 229 | ref = rref 230 | alt = ralt 231 | 232 | variant_id = position_to_variant_id(chrom, pos, ref, alt) 233 | 234 | return jsonify( 235 | dict( 236 | chrom=chrom, 237 | pos=pos, 238 | ref=ref, 239 | alt=alt, 240 | top_gene=top_gene, 241 | top_gene_symbol=gene_symbol, 242 | top_study=top_study, 243 | top_tissue=top_tissue, 244 | study_names=list(TISSUES_PER_STUDY.keys()), 245 | nearest_genes=nearest_genes, 246 | is_inside_gene=is_inside_gene, 247 | rsid=rsid, 248 | variant_id=variant_id, 249 | ) 250 | ) 251 | 252 | 253 | # Duplicate code from the region API above -- separated into its own API call 254 | @views_blueprint.route( 255 | "/gencode/genes//-/", methods=["GET"] 256 | ) 257 | def get_genes_in_region(chrom: str, start: int, end: int): 258 | """ 259 | Fetch the genes data for a region, and returns a dictionary of ENSG: Gene Symbols 260 | Retrieves data from our gencode genes file 261 | """ 262 | source = model.locate_gencode_data() 263 | reader = readers.TabixReader(source, parser=gencodeParser(), skip_rows=0) 264 | gencodeRows = reader.fetch(f"chr{chrom}", start - 1, end + 1) 265 | gene_list = [] 266 | gene_json = model.get_gene_names_conversion() 267 | for row in gencodeRows: 268 | gene_list.append(row.gene_id) 269 | gene_dict = { 270 | str(geneid): str(gene_json.get(geneid, geneid)) for geneid in gene_list 271 | } 272 | return jsonify({"data": gene_dict}) 273 | 274 | 275 | # Same concept as the above route, returning data for looking up transcripts 276 | @views_blueprint.route( 277 | "/gencode/transcripts//-/", 278 | methods=["GET"], 279 | ) 280 | def get_transcripts_in_region(chrom: str, start: int, end: int): 281 | """ 282 | Fetch the transcript data for a region 283 | Retrieves data from our gencode transcripts file 284 | """ 285 | gene_id = request.args.get("gene_id", None) 286 | source = model.locate_gencode_transcript_data() 287 | reader = readers.TabixReader( 288 | source, parser=gencodeTranscriptParser(), skip_rows=0 289 | ) 290 | gencodeRows = reader.fetch(f"chr{chrom}", start - 1, end + 1) 291 | transcriptDict = dict() # type: ignore 292 | # If we decide that we want both ENSG:ENST and gene_symbol:transcript_symbol dictionaries 293 | # Then we can return the extra information below (double dictionary) 294 | # symbolDict = {} 295 | 296 | # Variables in gencodeTranscriptContainer: 297 | # chrom: str 298 | # source: str 299 | # element: str 300 | # start: int 301 | # end: int 302 | # strand: str 303 | # gene_id: str 304 | # transcript_id: str 305 | # datatype: str 306 | # symbol: str 307 | # transcript_type: str 308 | # transcript_symbol: str 309 | 310 | for row in gencodeRows: 311 | if gene_id is None or gene_id == row.gene_id: 312 | ## Extra information for double dictionary 313 | # if row.gene_id not in transcriptDict: 314 | # transcriptDict[row.gene_id] = [row.transcript_id] 315 | # else: 316 | # transcriptDict[row.gene_id].append(row.transcript_id) 317 | # if row.symbol not in symbolDict: 318 | # symbolDict[row.symbol] = [row.transcript_symbol] 319 | # else: 320 | # symbolDict[row.symbol].append(row.transcript_symbol) 321 | if row.symbol not in transcriptDict: 322 | transcriptDict[row.symbol] = [row.transcript_id] 323 | else: 324 | transcriptDict[row.symbol].append(row.transcript_id) 325 | 326 | return jsonify( 327 | { 328 | "chrom": chrom, 329 | "start": start, 330 | "end": end, 331 | "transcriptIDs": transcriptDict, 332 | # "transcriptSymbols": symbolDict # Extra information for double dictionary 333 | } 334 | ) 335 | -------------------------------------------------------------------------------- /fivex/frontend/format.py: -------------------------------------------------------------------------------- 1 | import dataclasses as dc 2 | import typing as ty 3 | 4 | try: 5 | # Optional speedup features 6 | from fastnumbers import int # type: ignore 7 | except ImportError: 8 | pass 9 | 10 | 11 | # Container for parsing gencode files (tabix-indexed) 12 | @dc.dataclass 13 | class gencodeContainer: 14 | chrom: str 15 | source: str 16 | element: str 17 | start: int 18 | end: int 19 | strand: str 20 | gene_id: str 21 | datatype: str 22 | symbol: str 23 | 24 | def to_dict(self): 25 | return dc.asdict(self) 26 | 27 | 28 | @dc.dataclass 29 | class gencodeTranscriptContainer: 30 | chrom: str 31 | source: str 32 | element: str 33 | start: int 34 | end: int 35 | strand: str 36 | gene_id: str 37 | transcript_id: str 38 | datatype: str 39 | symbol: str 40 | transcript_type: str 41 | transcript_symbol: str 42 | 43 | def to_dict(self): 44 | return dc.asdict(self) 45 | 46 | 47 | # Parser for tabix-indexed gencode file 48 | class gencodeParser: 49 | def __init__(self): 50 | pass 51 | 52 | def __call__(self, row: str) -> gencodeContainer: 53 | fields: ty.List[ty.Any] = row.split("\t") 54 | fields[0] = fields[0].replace("chr", "") 55 | fields[3] = int(fields[3]) 56 | fields[4] = int(fields[4]) 57 | fields[6] = fields[6].split(".")[0] 58 | return gencodeContainer(*fields) 59 | 60 | 61 | # Parser for tabix-indexed gencode transcripts file 62 | class gencodeTranscriptParser: 63 | def __init__(self): 64 | pass 65 | 66 | def __call__(self, row: str) -> gencodeTranscriptContainer: 67 | fields: ty.List[ty.Any] = row.split("\t") 68 | fields[0] = fields[0].replace("chr", "") 69 | fields[3] = int(fields[3]) 70 | fields[4] = int(fields[4]) 71 | fields[6] = fields[6].split(".")[0] 72 | fields[7] = fields[7].split(".")[0] 73 | return gencodeTranscriptContainer(*fields) 74 | -------------------------------------------------------------------------------- /fivex/model.py: -------------------------------------------------------------------------------- 1 | """ 2 | Models/ datastores 3 | """ 4 | import gzip 5 | import json 6 | import math 7 | import os 8 | import sqlite3 9 | 10 | from flask import abort, current_app 11 | 12 | 13 | # Merged data split into 1Mbps chunks - only query this for single variant data 14 | def locate_data(chrom: str, startpos: int, datatype: str = "ge"): 15 | start = math.floor(startpos / 1000000) * 1000000 + 1 16 | end = start + 999999 17 | 18 | # FIXME: Inject strict validation in callers before this ever hits this function 19 | chrom = os.path.basename(chrom) 20 | 21 | return os.path.join( 22 | current_app.config["FIVEX_DATA_DIR"], 23 | f"ebi_{datatype}", 24 | chrom, 25 | f"all.EBI.{datatype}.data.chr{chrom}.{start}-{end}.tsv.gz", 26 | ) 27 | 28 | 29 | # Study- and tissue-specific data - query this for region view 30 | def locate_study_tissue_data(study, tissue, datatype="ge"): 31 | study = os.path.basename(study) 32 | tissue = os.path.basename(tissue) 33 | 34 | return os.path.join( 35 | current_app.config["FIVEX_DATA_DIR"], 36 | "ebi_original", 37 | datatype, 38 | study, 39 | f"{study}_{datatype}_{tissue}.all.tsv.gz", 40 | ) 41 | 42 | 43 | # Signed tss data: positive TSS = Plus strand, negative TSS = Minus strand 44 | def locate_tss_data(): 45 | return os.path.join( 46 | current_app.config["FIVEX_DATA_DIR"], "gencode", "tss.json.gz", 47 | ) 48 | 49 | 50 | # Sorted and filtered gencode data 51 | def locate_gencode_data(): 52 | return os.path.join( 53 | current_app.config["FIVEX_DATA_DIR"], 54 | "gencode", 55 | "gencode.v30.annotation.gtf.genes.bed.gz", 56 | ) 57 | 58 | 59 | # Sorted and filtered gencode transcripts data 60 | def locate_gencode_transcript_data(): 61 | return os.path.join( 62 | current_app.config["FIVEX_DATA_DIR"], 63 | "gencode", 64 | "gencode.v30.annotation.gtf.transcripts.bed.gz", 65 | ) 66 | 67 | 68 | # A database that stores the point with the highest PIP at each variant 69 | def get_best_per_variant_lookup(data_type: str = "ge",): 70 | # TODO: dedup datatype value usage. make enum with ge or txrev for e and sqtls 71 | """Get the path to an SQLite3 database file describing the best study, 72 | tissue, and gene for any given variant""" 73 | return os.path.join( 74 | current_app.config["FIVEX_DATA_DIR"], 75 | "credible_sets", 76 | data_type, 77 | "pip.best.variant.summary.sorted.indexed.sqlite3.db", 78 | ) 79 | 80 | 81 | # Uses the database above to find the data point with highest PIP value 82 | def get_best_study_tissue_gene( 83 | chrom, start=None, end=None, study=None, tissue=None, gene_id=None 84 | ): 85 | conn = sqlite3.connect(get_best_per_variant_lookup()) 86 | with conn: 87 | try: 88 | cursor = conn.cursor() 89 | sqlCommand = "SELECT * FROM sig WHERE chrom=?" 90 | argsList = [chrom] 91 | if start is not None: 92 | if end is not None: 93 | sqlCommand += " AND pos BETWEEN ? AND ?" 94 | argsList.extend([start, end]) 95 | else: 96 | sqlCommand += " AND pos=?" 97 | argsList.append(start) 98 | if study is not None: 99 | sqlCommand += " AND study=?" 100 | argsList.append(study) 101 | if tissue is not None: 102 | sqlCommand += " AND tissue=?" 103 | argsList.append(tissue) 104 | if gene_id is not None: 105 | sqlCommand += " AND gene_id=?" 106 | argsList.append(gene_id) 107 | sqlCommand += " ORDER BY pip DESC LIMIT 1" 108 | ( 109 | pip, 110 | study, 111 | tissue, 112 | gene_id, 113 | chrom, 114 | pos, 115 | ref, 116 | alt, 117 | _, 118 | _, 119 | _, 120 | ) = list(cursor.execute(sqlCommand, tuple(argsList),))[0] 121 | bestVar = (gene_id, chrom, pos, ref, alt, pip, study, tissue) 122 | return bestVar 123 | except IndexError: 124 | return abort(400) 125 | 126 | 127 | def get_gene_names_conversion(): 128 | """Get the compressed file containing two-way mappings of gene_id to gene_symbol""" 129 | with gzip.open( 130 | os.path.join( 131 | current_app.config["FIVEX_DATA_DIR"], "gene.id.symbol.map.json.gz", 132 | ), 133 | "rt", 134 | ) as f: 135 | return json.loads(f.read()) 136 | 137 | 138 | # If requesting a single variant, then return the merged credible_sets file for a single chromosome 139 | # Otherwise, return the study-specific, tissue-specific file that contains genomewide information 140 | def get_credible_interval_path(chrom, study=None, tissue=None, datatype="ge"): 141 | # FIXME: Inject strict validation in callers before this ever hits this function 142 | chrom = os.path.basename(chrom) 143 | 144 | if not study and not tissue: 145 | # Overall "best" information 146 | return os.path.join( 147 | current_app.config["FIVEX_DATA_DIR"], 148 | "credible_sets", 149 | datatype, 150 | f"chr{chrom}.{datatype}.credible_set.tsv.gz", 151 | ) 152 | else: 153 | # FIXME: Inject strict validation in callers before this ever hits this function 154 | study = os.path.basename(study) 155 | tissue = os.path.basename(tissue) 156 | 157 | return os.path.join( 158 | current_app.config["FIVEX_DATA_DIR"], 159 | "credible_sets", 160 | datatype, 161 | study, 162 | f"{study}.{tissue}_{datatype}.purity_filtered.sorted.txt.gz", 163 | ) 164 | 165 | 166 | # Return the chromosome-specific filename for the merged credible sets data 167 | def get_credible_data_table(chrom, datatype="ge"): 168 | # FIXME: Inject strict validation in callers before this ever hits this function 169 | chrom = os.path.basename(chrom) 170 | 171 | return os.path.join( 172 | current_app.config["FIVEX_DATA_DIR"], 173 | "credible_sets", 174 | datatype, 175 | f"chr{chrom}.{datatype}.credible_set.tsv.gz", 176 | ) 177 | 178 | 179 | # Takes in chromosome and position, and returns (chrom, pos, ref, alt, rsid) 180 | # rsid.sqlite3.db is created by util/create.rsid.sqlite3.py 181 | def return_rsid(chrom, pos): 182 | rsid_db = os.path.join( 183 | current_app.config["FIVEX_DATA_DIR"], "rsid.sqlite3.db" 184 | ) 185 | conn = sqlite3.connect(rsid_db) 186 | with conn: 187 | try: 188 | cursor = conn.cursor() 189 | return list( 190 | cursor.execute( 191 | "SELECT * FROM rsidTable WHERE chrom=? AND pos=?", 192 | (chrom, pos), 193 | ) 194 | )[0] 195 | except ValueError: 196 | # TODO: Document schema of the database table and what these placeholder values mean 197 | return [chrom, pos, "N", "N", "Unknown"] 198 | -------------------------------------------------------------------------------- /fivex/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/fivex/settings/__init__.py -------------------------------------------------------------------------------- /fivex/settings/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Common configuration variables for the application 3 | """ 4 | 5 | import os 6 | 7 | from dotenv import load_dotenv # type: ignore 8 | 9 | # Load machine or deployment-specific configuration values from a local .env file 10 | load_dotenv() 11 | 12 | # Root of this application, useful if it doesn't occupy an entire domain 13 | APPLICATION_ROOT = "/" 14 | SECRET_KEY = os.getenv("SECRET_KEY", None) 15 | 16 | 17 | # Default data directory - this contains both variant-level (tabixed) data files and supporting databases 18 | FIVEX_DATA_DIR = os.getenv( 19 | "FIVEX_DATA_DIR", 20 | os.path.join( 21 | os.path.dirname( 22 | os.path.dirname(os.path.dirname(os.path.realpath(__file__))) 23 | ), 24 | "data", 25 | ), 26 | ) 27 | 28 | # .env file can optionally provide a Sentry key for automatic error reporting 29 | # TODO: add for frontend and backend 30 | SENTRY_DSN = os.getenv("SENTRY_DSN", None) 31 | -------------------------------------------------------------------------------- /fivex/settings/dev.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration specific to a generic development environment 3 | 4 | This controls settings that enable a developer-friendly experience. It is *not* where machine-specific 5 | configuration is stored. For that, use a .env file in your local directory 6 | 7 | Recommended reading: 8 | https://12factor.net/ 9 | """ 10 | from .base import * # noqa 11 | 12 | DEBUG = True 13 | 14 | # Provide a default secret key if one is not present in the user's .env file 15 | SECRET_KEY: str = ( 16 | SECRET_KEY # noqa type: ignore 17 | or "for development environments we provide some generic secret key placeholder" 18 | ) 19 | -------------------------------------------------------------------------------- /fivex/settings/prod.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration specific to a generic production environment 3 | 4 | This controls settings that enable a production-friendly experience. It is *not* where machine-specific 5 | configuration is stored. For that, use a .env file in your local directory 6 | """ 7 | 8 | from .base import * # noqa 9 | 10 | DEBUG = False 11 | 12 | if not SECRET_KEY: # type: ignore # noqa 13 | raise Exception( 14 | "A secret key must be provided to run this app in production" 15 | ) 16 | 17 | SESSION_COOKIE_SECURE = True 18 | -------------------------------------------------------------------------------- /fivex/settings/test.py: -------------------------------------------------------------------------------- 1 | """ 2 | Useful settings for running unit tests 3 | """ 4 | 5 | import os 6 | 7 | from .base import * # noqa 8 | 9 | # All tests must run against the pre-provided sample data, to ensure that test runs are comparable 10 | # This also helps to catch problems like "forgot to add sample data to repo" 11 | FIVEX_DATA_DIR = os.path.join( 12 | os.path.dirname( 13 | os.path.dirname(os.path.dirname(os.path.realpath(__file__))) 14 | ), 15 | "data", 16 | ) 17 | -------------------------------------------------------------------------------- /media/FIVEx_tutorial_1.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/media/FIVEx_tutorial_1.mp4 -------------------------------------------------------------------------------- /media/FIVEx_tutorial_2.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/media/FIVEx_tutorial_2.mp4 -------------------------------------------------------------------------------- /media/FIVEx_tutorial_3.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/media/FIVEx_tutorial_3.mp4 -------------------------------------------------------------------------------- /media/FIVEx_tutorial_4.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/media/FIVEx_tutorial_4.mp4 -------------------------------------------------------------------------------- /media/FIVEx_tutorial_5.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/media/FIVEx_tutorial_5.mp4 -------------------------------------------------------------------------------- /media/FIVEx_tutorial_6.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/media/FIVEx_tutorial_6.mp4 -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fivex", 3 | "version": "0.1.0", 4 | "private": true, 5 | "description": "eQTL browser for EBI data", 6 | "author": { 7 | "name": "University of Michigan Center for Statistical Genetics" 8 | }, 9 | "scripts": { 10 | "serve": "vue-cli-service serve", 11 | "build": "vue-cli-service build", 12 | "test:unit": "vue-cli-service test:unit", 13 | "lint": "vue-cli-service lint", 14 | "test": "echo \"Error: no test specified\" && exit 1" 15 | }, 16 | "main": "index.js", 17 | "dependencies": { 18 | "@fortawesome/fontawesome-free": "^5.14.0", 19 | "@sentry/browser": "^5.21.1", 20 | "@sentry/integrations": "^5.21.1", 21 | "bootstrap": "^4.5.2", 22 | "bootstrap-vue": "^2.16.0", 23 | "core-js": "^3.6.5", 24 | "jquery": "^3.5.1", 25 | "locuszoom": "~0.13.3", 26 | "tabulator-tables": "^4.7.2", 27 | "vue": "^2.6.12", 28 | "vue-gtag": "^1.10.0", 29 | "vue-router": "^3.4.3" 30 | }, 31 | "devDependencies": { 32 | "@vue/cli-plugin-babel": "^4.2.3", 33 | "@vue/cli-plugin-eslint": "^4.5.12", 34 | "@vue/cli-plugin-router": "^4.2.3", 35 | "@vue/cli-plugin-unit-mocha": "^4.2.3", 36 | "@vue/cli-service": "^4.2.3", 37 | "@vue/test-utils": "1.0.0-beta.31", 38 | "babel-eslint": "^10.1.0", 39 | "chai": "^4.1.2", 40 | "eslint": "^6.7.2", 41 | "eslint-plugin-import": "^2.22.0", 42 | "eslint-plugin-vue": "^6.2.2", 43 | "source-map-loader": "^0.2.4", 44 | "vue-template-compiler": "^2.6.12" 45 | }, 46 | "bugs": { 47 | "url": "https://github.com/statgen/fivex/issues" 48 | }, 49 | "homepage": "https://github.com/statgen/fivex#readme", 50 | "license": "Apache-2.0", 51 | "repository": { 52 | "type": "git", 53 | "url": "git+https://github.com/statgen/fivex.git" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statgen/fivex/74673b37ed71406bb872bc87da67a30bc118baa6/public/favicon.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | FIVEx: eQTL Browser 10 | 11 | 12 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 79 3 | target-version = ['py36', 'py37', 'py38'] 4 | -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | flask-debugtoolbar==0.10.1 2 | python-dotenv==0.10.3 3 | fastnumbers==2.2.1 # This can make parsing faster 4 | flask==1.1.1 5 | zorp==0.2.0 6 | genelocator==1.1.1 7 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | -r ./base.txt 2 | 3 | fourmat==0.4.0 4 | pre-commit==1.20.0 5 | mypy==0.740 6 | pytest-flask==0.15.1 7 | -------------------------------------------------------------------------------- /requirements/prod.txt: -------------------------------------------------------------------------------- 1 | -r ./base.txt 2 | 3 | gunicorn==20.0.2 4 | gevent==1.4.0 5 | sentry-sdk[flask] 6 | -------------------------------------------------------------------------------- /run-development.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Clean, build and start server in a development environment 4 | 5 | 6 | # Stop on errors, print commands 7 | set -e 8 | 9 | # Set environment variables 10 | export FLASK_DEBUG=True 11 | export FLASK_APP="fivex:create_app('fivex.settings.dev')" 12 | 13 | 14 | # Run the development server on port 8000 15 | flask run --host 0.0.0.0 --port 5000 16 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | exclude = .tox,.git,.venv,venv,env,*/migrations/*,*/static/CACHE/*,docs,node_modules 4 | 5 | [mypy-util.*] 6 | # The data ingest pipeline is a bit messy; skip typing for now 7 | ignore_errors = True 8 | 9 | [mypy-fivex.settings.*] 10 | # Settings files use some import magic that doesn't play nice with typing 11 | ignore_errors = True 12 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /src/assets/common.css: -------------------------------------------------------------------------------- 1 | .text-with-definition { 2 | text-decoration: underline dotted; 3 | /* Note: new css attribute; not well supported in older browsers */ 4 | text-decoration-thickness: 1%; 5 | } 6 | 7 | .variant-information-grouped-card { 8 | /* Make the cards in the card-group wrap onto separate rows on narrow screens to avoid text overflow off the right. 9 | Boostrap sets `min-width:0` for cards, but we set it back to `auto` so that it will consider the width of its contents. */ 10 | min-width: auto; 11 | } 12 | 13 | /* Formats dt/dd to display on the same line */ 14 | dl.variant-information-dl { 15 | display: flex; 16 | flex-flow: row; 17 | flex-wrap: wrap; 18 | width: 360px; 19 | overflow: visible; 20 | margin-bottom: 0; /* Cards already have 20px padding, so adding margin for the lone
is excessive. */ 21 | } 22 | 23 | dl.variant-information-dl dt { 24 | flex: 0 0 50%; 25 | text-overflow: ellipsis; 26 | overflow: hidden; 27 | } 28 | 29 | dl.variant-information-dl dd { 30 | margin-left: auto; 31 | text-align: left; 32 | text-overflow: ellipsis; 33 | overflow: hidden; 34 | flex: 0 0 50%; 35 | } 36 | 37 | /* Middle information box needs slightly different formatting to display longer tissues correctly */ 38 | dl.variant-info-middle { 39 | display: flex; 40 | flex-flow: row; 41 | flex-wrap: wrap; 42 | width: 360px; 43 | overflow: visible; 44 | margin-bottom: 0; 45 | } 46 | 47 | dl.variant-info-middle dt { 48 | flex: 0 0 30%; /* Reduce the width for the dt text */ 49 | text-overflow: ellipsis; 50 | overflow: hidden; 51 | } 52 | 53 | dl.variant-info-middle dd { 54 | margin-left: auto; 55 | text-align: left; 56 | text-overflow: ellipsis; 57 | overflow: hidden; 58 | flex: 0 0 70%; /* Increase the width available for the dd text */ 59 | } 60 | 61 | /* Scrollable Bootstrap dropdown menus */ 62 | 63 | .scrollable-menu { 64 | height: auto; 65 | max-height: 400px; 66 | overflow-x: hidden; 67 | } 68 | 69 | .tabulator .tabulator-header .tabulator-col .tabulator-col-content .tabulator-col-title { 70 | white-space: normal; 71 | text-overflow: fade; 72 | } 73 | 74 | /* Prevent Bootstrap buttons from covering LZ tooltips, by giving render preference to tooltips */ 75 | .lz-data_layer-tooltip { 76 | z-index: 2; 77 | } 78 | 79 | .searchbox { 80 | width: 600px; 81 | } 82 | 83 | .form-control { 84 | font-size: 75%; 85 | } 86 | 87 | .footer { 88 | text-align: center; 89 | background: #eeeeee; 90 | position: absolute; 91 | bottom: 0; 92 | width: 100%; 93 | height: 2.5em; 94 | line-height: 2.4rem; 95 | } 96 | -------------------------------------------------------------------------------- /src/components/AddTrack.vue: -------------------------------------------------------------------------------- 1 | 64 | 65 | 129 | 130 | 132 | -------------------------------------------------------------------------------- /src/components/LzPlot.vue: -------------------------------------------------------------------------------- 1 | 145 | 146 | 156 | -------------------------------------------------------------------------------- /src/components/SearchBox.vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | 244 | 245 | 247 | -------------------------------------------------------------------------------- /src/components/SelectAnchors.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /src/components/TabulatorTable.vue: -------------------------------------------------------------------------------- 1 | 124 | 125 | 130 | -------------------------------------------------------------------------------- /src/lz-helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility functions and data sources used by LocusZoom 3 | * 4 | * Note: Data sources are registered as a side effect the first time the module is imported 5 | * 6 | * It can be useful to define these in a single central file, so that changes to the display layer 7 | * don't cause these things to be re-registered when the page reloads 8 | */ 9 | import $ from 'jquery'; 10 | 11 | import LocusZoom from 'locuszoom'; 12 | import { AssociationLZ, PheWASLZ } from 'locuszoom/esm/data/adapters'; 13 | import { category_scatter } from 'locuszoom/esm/components/data_layer'; 14 | 15 | /** 16 | * Helper method that fetches a desired field value regardless of namespacing 17 | * @param {object} point_data 18 | * @param {string} field_suffix 19 | */ 20 | function retrieveBySuffix(point_data, field_suffix) { 21 | const match = Object.entries(point_data) 22 | .find((item) => item[0].endsWith(field_suffix)); 23 | return match ? match[1] : null; 24 | } 25 | 26 | /** 27 | * Convert Posterior incl probabilities to a (truncated) log scale for rendering. The return values 28 | * of this scale are (-4..0), so that very small PIPs aren't allowed to dominate the axis scale 29 | */ 30 | LocusZoom.TransformationFunctions.add('pip_yvalue', (x) => Math.max(Math.log10(x), -4)); 31 | 32 | /** 33 | * Convert displayed pip, spip, or pip_cluster to missing '-' if value is 0 34 | */ 35 | // LocusZoom.TransformationFunctions.add('pip_display', (x) => (x ? x.toString() : '-')); 36 | 37 | LocusZoom.TransformationFunctions.add('pip_display', (x) => { 38 | if (!x) { 39 | return '-'; 40 | } 41 | if (Math.abs(x) > 0.1) { 42 | return x.toFixed(2); 43 | } 44 | if (Math.abs(x) >= 0.01) { 45 | return x.toFixed(3); 46 | } 47 | return x.toExponential(1); 48 | }); 49 | 50 | /** 51 | * Assign point shape based on PIP cluster designation. Since there are always just a few clusters, and cluster 1 52 | * is most significant, this hard-coding is a workable approach. 53 | */ 54 | LocusZoom.ScaleFunctions.add('pip_cluster', (parameters, input) => { 55 | if (typeof input !== 'undefined') { 56 | const pip_cluster = retrieveBySuffix(input, ':cs_index'); 57 | // Cluster names refer to SuSIE posterior probability clusters 58 | if (pip_cluster === 'L1') { 59 | return 'cross'; 60 | } 61 | if (pip_cluster === 'L2') { 62 | return 'square'; 63 | } 64 | } 65 | return null; 66 | }); 67 | 68 | /** 69 | * Assign point shape as arrows based on direction of effect 70 | */ 71 | LocusZoom.ScaleFunctions.add('effect_direction', (parameters, input) => { 72 | if (typeof input !== 'undefined') { 73 | const beta = retrieveBySuffix(input, ':beta'); 74 | const stderr_beta = retrieveBySuffix(input, ':stderr_beta'); 75 | if (beta === null || stderr_beta === null) { 76 | return null; 77 | } 78 | if (!Number.isNaN(beta) && !Number.isNaN(stderr_beta)) { 79 | if (beta - 1.96 * stderr_beta > 0) { 80 | return parameters['+'] || null; 81 | } // 1.96*se to find 95% confidence interval 82 | if (beta + 1.96 * stderr_beta < 0) { 83 | return parameters['-'] || null; 84 | } 85 | } 86 | } 87 | return null; 88 | }); 89 | 90 | // ---------------- 91 | // Custom data sources for the variant view 92 | class PheWASFIVEx extends PheWASLZ { 93 | getURL(state, chain) { 94 | chain.header.maximum_tss_distance = state.maximum_tss_distance; 95 | chain.header.minimum_tss_distance = state.minimum_tss_distance; 96 | chain.header.y_field = state.y_field; 97 | chain.header.fivex_studies = new Set(state.fivex_studies || []); 98 | return this.url; 99 | } 100 | 101 | annotateData(records, chain) { 102 | let filtered = records 103 | .filter((record) => ( 104 | record.tss_distance <= chain.header.maximum_tss_distance 105 | && record.tss_distance >= chain.header.minimum_tss_distance 106 | )); 107 | 108 | const study_names = chain.header.fivex_studies; 109 | if (chain.header.fivex_studies.size) { 110 | filtered = filtered.filter((record) => study_names.has(record.study)); 111 | } 112 | 113 | // Add a synthetic field `top_value_rank`, where the best value for a given field gets rank 1. 114 | // This is used to show labels for only a few points with the strongest (y_field) value. 115 | // As this is a source designed to power functionality on one specific page, we can hardcode specific behavior 116 | // to rank individual fields differently 117 | 118 | // To make it, sort a shallow copy of `records` by `y_field`, and then iterate through the shallow copy, modifying each record object. 119 | // Because it's a shallow copy, the record objects in the original array are changed too. 120 | const field = chain.header.y_field; 121 | 122 | function getValue(item) { 123 | if (field === 'log_pvalue') { 124 | return item[field]; 125 | } if (field === 'beta') { 126 | return Math.abs(item[field]); 127 | } if (field === 'pip') { 128 | return item[field]; 129 | } 130 | throw new Error('Unrecognized sort field'); 131 | } 132 | 133 | const shallow_copy = filtered.slice(); 134 | shallow_copy.sort((a, b) => { 135 | const av = getValue(a); 136 | const bv = getValue(b); 137 | // eslint-disable-next-line no-nested-ternary 138 | return (av === bv) ? 0 : (av < bv ? 1 : -1); // log: descending order means most significant first 139 | }); 140 | shallow_copy.forEach((value, index) => { 141 | value.top_value_rank = index + 1; 142 | }); 143 | return filtered; 144 | } 145 | } 146 | 147 | LocusZoom.Adapters.add('PheWASFIVEx', PheWASFIVEx); 148 | 149 | /** 150 | * A special modified datalayer, which sorts points in a unique way (descending), and allows tick marks to be defined 151 | * separate from how things are grouped. Eg, we can sort by tss_distance, but label by gene name 152 | */ 153 | class ScatterFivex extends category_scatter { 154 | _prepareData() { 155 | const xField = this.layout.x_axis.field || 'x'; 156 | // The (namespaced) field from `this.data` that will be used to assign datapoints to a given category & color 157 | const { category_field } = this.layout.x_axis; 158 | if (!category_field) { 159 | throw new Error(`Layout for ${this.layout.id} must specify category_field`); 160 | } 161 | 162 | // Element labels don't have to match the sorting field used to create groups. However, we enforce a rule that 163 | // there must be a 1:1 correspondence 164 | // If two points have the same value of `category_field`, they MUST have the same value of `category_label_field`. 165 | let sourceData; 166 | const { category_order_field } = this.layout.x_axis; 167 | if (category_order_field) { 168 | const unique_categories = {}; 169 | // Requirement: there is (approximately) a 1:1 correspondence between categories and their associated labels 170 | this.data.forEach((d) => { 171 | const item_cat_label = d[category_field]; 172 | const item_cat_order = d[category_order_field]; 173 | // In practice, we find that some gene symbols are ambiguous (appear at multiple positions), and close 174 | // enough Example: "RF00003". Hence, we will build the uniqueness check on TSS, not gene symbol (and 175 | // hope that TSS is more unique than gene name) 176 | // TODO: If TSS can ever be ambiguous, we have traded one "not unique" bug for another. Check closely. 177 | if (!Object.prototype.hasOwnProperty.call(unique_categories, item_cat_label)) { 178 | unique_categories[item_cat_order] = item_cat_order; 179 | } else if (unique_categories[item_cat_order] !== item_cat_order) { 180 | throw new Error(`Unable to sort PheWAS plot categories by ${category_field} because the category ${item_cat_label 181 | } can have either the value "${unique_categories[item_cat_label]}" or "${item_cat_order}".`); 182 | } 183 | }); 184 | 185 | // Sort the data so that things in the same category are adjacent 186 | sourceData = this.data 187 | .sort((a, b) => { 188 | const av = -a[category_order_field]; // sort descending 189 | const bv = -b[category_order_field]; 190 | // eslint-disable-next-line no-nested-ternary 191 | return (av === bv) ? 0 : (av < bv ? -1 : 1); 192 | }); 193 | } else { 194 | // Sort the data so that things in the same category are adjacent (case-insensitive by specified field) 195 | sourceData = this.data 196 | .sort((a, b) => { 197 | const ak = a[category_field]; 198 | const bk = b[category_field]; 199 | const av = ak.toString ? ak.toString().toLowerCase() : ak; 200 | const bv = bk.toString ? bk.toString().toLowerCase() : bk; 201 | // eslint-disable-next-line no-nested-ternary 202 | return (av === bv) ? 0 : (av < bv ? -1 : 1); 203 | }); 204 | } 205 | 206 | sourceData.forEach((d, i) => { 207 | // Implementation detail: Scatter plot requires specifying an x-axis value, and most datasources do not 208 | // specify plotting positions. If a point is missing this field, fill in a synthetic value. 209 | d[xField] = d[xField] || i; 210 | }); 211 | return sourceData; 212 | } 213 | } 214 | 215 | // Redefine the layout base in-place, in order to preserve CSS rules (which incorporate the name of the layer) 216 | LocusZoom.DataLayers.add('category_scatter', ScatterFivex, true); 217 | 218 | LocusZoom.TransformationFunctions.add('twosigfigs', (x) => { 219 | if (Math.abs(x) > 0.1) { 220 | return x.toFixed(2); 221 | } 222 | if (Math.abs(x) >= 0.01) { 223 | return x.toFixed(3); 224 | } 225 | return x.toExponential(1); 226 | }); 227 | 228 | class AssocFIVEx extends AssociationLZ { 229 | getURL(state) { 230 | const url = `${this.url}/${state.chr}/${state.start}-${state.end}/${this.params.study}/${this.params.tissue}/`; 231 | let params = {}; 232 | // TODO: Is there ever a case where a LZ panel data source is allowed to omit gene/tissue/study info? If not, add validation. 233 | if (this.params.gene_id) { 234 | params.gene_id = this.params.gene_id; 235 | } 236 | 237 | params = $.param(params); 238 | return `${url}?${params}`; 239 | } 240 | 241 | annotateData(data) { 242 | data.forEach((item) => { 243 | item.variant = `${item.chromosome}:${item.position}_${item.ref_allele}/${item.alt_allele}`; 244 | }); 245 | return data; 246 | } 247 | } 248 | 249 | LocusZoom.Adapters.add('AssocFIVEx', AssocFIVEx); 250 | 251 | export { retrieveBySuffix }; 252 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueGtag from 'vue-gtag'; 3 | 4 | import App from './App.vue'; 5 | import router from './router'; 6 | 7 | Vue.config.productionTip = false; 8 | 9 | if (process.env.VUE_APP_GOOGLE_ANALYTICS) { 10 | // Track page and router views in google analytics 11 | Vue.use(VueGtag, { 12 | config: { id: process.env.VUE_APP_GOOGLE_ANALYTICS }, 13 | appName: 'FIVEx', 14 | pageTrackerScreenviewEnabled: true, 15 | }, router); 16 | } 17 | 18 | 19 | new Vue({ 20 | router, 21 | render: (h) => h(App), 22 | }).$mount('#app'); 23 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | // Load dependencies (for their side effects) 2 | import 'bootstrap/dist/css/bootstrap.css'; 3 | import 'bootstrap-vue/dist/bootstrap-vue.css'; 4 | import '@fortawesome/fontawesome-free/css/all.css'; 5 | import * as Sentry from '@sentry/browser'; 6 | import * as Integrations from '@sentry/integrations'; 7 | 8 | import Vue from 'vue'; 9 | import { BootstrapVue } from 'bootstrap-vue'; 10 | 11 | import VueRouter from 'vue-router'; 12 | 13 | import 'locuszoom/dist/locuszoom.css'; 14 | import '@/assets/common.css'; 15 | 16 | import About from '@/views/About.vue'; 17 | import Help from '@/views/Help.vue'; 18 | import Home from '@/views/Home.vue'; 19 | import Tutorial from '@/views/Tutorial.vue'; 20 | 21 | Vue.use(BootstrapVue); 22 | Vue.use(VueRouter); 23 | 24 | // Activate remote error monitoring (if a DSN is provided in the `.env` file that is shared by Flask and Vue) 25 | Sentry.init({ 26 | dsn: process.env.VUE_APP_SENTRY_DSN, 27 | integrations: [new Integrations.Vue({ Vue, logErrors: true, attachProps: true })], 28 | }); 29 | 30 | const routes = [ 31 | { 32 | path: '/', 33 | name: 'home', 34 | component: Home, 35 | meta: { title: 'Home' }, 36 | }, 37 | { 38 | path: '/about', 39 | name: 'about', 40 | component: About, 41 | meta: { title: 'About' }, 42 | }, 43 | { 44 | path: '/help/', 45 | name: 'help', 46 | component: Help, 47 | meta: { title: 'Help' }, 48 | }, 49 | { 50 | path: '/tutorial/', 51 | name: 'tutorial', 52 | component: Tutorial, 53 | meta: { title: 'Tutorial' }, 54 | }, 55 | { 56 | // data_type may be `eqtl` or `sqtl`. In the future, make this a prop or otherwise enumerable. 57 | path: '/variant/:display_type/:variant/', 58 | name: 'variant', 59 | meta: { title: 'Variant' }, 60 | // route level code-splitting 61 | // this generates a separate chunk (variant.[hash].js) for this route 62 | // which is lazy-loaded when the route is visited. 63 | component: () => import(/* webpackChunkName: "variant" */ '../views/Variant.vue'), 64 | }, 65 | { 66 | path: '/region/', 67 | name: 'region', 68 | meta: { title: 'Region' }, 69 | component: () => import(/* webpackChunkName: "region" */ '../views/Region.vue'), 70 | }, 71 | { 72 | path: '/error/', 73 | name: 'error', 74 | meta: { title: 'Error' }, 75 | component: () => import(/* webpackChunkName: "errors" */ '../views/Error.vue'), 76 | }, 77 | { 78 | path: '*', 79 | name: '404', 80 | meta: { title: 'Not found' }, 81 | component: () => import(/* webpackChunkName: "errors" */ '../views/NotFound.vue'), 82 | }, 83 | ]; 84 | 85 | const router = new VueRouter({ 86 | mode: 'history', 87 | base: process.env.BASE_URL, 88 | routes, 89 | }); 90 | 91 | router.beforeEach((to, from, next) => { 92 | const base = to.meta.title || 'Explore'; 93 | document.title = `${base} | FIVEx: eQTL browser`; 94 | next(); 95 | }); 96 | 97 | export default router; 98 | -------------------------------------------------------------------------------- /src/util/common.js: -------------------------------------------------------------------------------- 1 | const PORTALDEV_URL = 'https://portaldev.sph.umich.edu/api/v1/'; 2 | 3 | /** 4 | * Handles bad requests - copied from https://www.tjvantoll.com/2015/09/13/fetch-and-errors/ 5 | * @param response 6 | * @return {{ok}|*} 7 | */ 8 | function handleErrors(response) { 9 | if (!response.ok) { 10 | throw Error(response.statusText); 11 | } 12 | return response; 13 | } 14 | 15 | /** 16 | * Remove the `sourcename:` prefix from field names in the data returned by an LZ datasource 17 | * 18 | * This is a convenience method for writing external widgets (like tables) that subscribe to the 19 | * plot; typically we don't want to have to redefine the table layout every time someone selects 20 | * a different association study. 21 | * As with all convenience methods, it has limits: don't use it if the same field name is requested 22 | * from two different sources! 23 | * @param {Object} data An object representing the fields for one row of data 24 | * @param {String} [prefer] Sometimes, two sources provide a field with same name. Specify which 25 | * source will take precedence in the event of a conflict. 26 | */ 27 | export function deNamespace(data, prefer) { 28 | return Object.keys(data).reduce((acc, key) => { 29 | const new_key = key.replace(/.*?:/, ''); 30 | if (!Object.prototype.hasOwnProperty.call(acc, new_key) 31 | || (!prefer || key.startsWith(prefer))) { 32 | acc[new_key] = data[key]; 33 | } 34 | return acc; 35 | }, {}); 36 | } 37 | 38 | export { handleErrors, PORTALDEV_URL }; 39 | 40 | export function pip_fmt(cell) { 41 | const x = cell.getValue(); 42 | if (x === 0) { 43 | return '-'; 44 | } 45 | return x.toPrecision(2); 46 | } 47 | 48 | export function tabulator_tooltip_maker(cell) { 49 | // Only show tabulator table tooltips when an ellipsis ('...') is hiding part of the data. 50 | // When `element.scrollWidth` is bigger than `element.clientWidth`, that means that data is hidden. 51 | // Unfortunately the ellipsis sometimes activates when it's not needed, hiding data while `clientWidth == scrollWidth`. 52 | // Fortunately, these tooltips are just a convenience so it's fine if they fail to show. 53 | const e = cell.getElement(); 54 | if (e.clientWidth >= e.scrollWidth) { 55 | return false; // all the text is shown, so there is no '...', so tooltip is unneeded 56 | } 57 | return e.innerText; // shows what's in the HTML (from `formatter`) instead of just `cell.getValue()` 58 | } 59 | 60 | // Tabulator formatting helpers 61 | export function two_digit_fmt1(cell) { 62 | const x = cell.getValue(); 63 | const d = -Math.floor(Math.log10(Math.abs(x))); 64 | return (d < 6) ? x.toFixed(Math.max(d + 1, 2)) : x.toExponential(1); 65 | } 66 | 67 | export function two_digit_fmt2(cell) { 68 | const x = cell.getValue(); 69 | const d = -Math.floor(Math.log10(Math.abs(x))); 70 | return (d < 4) ? x.toFixed(Math.max(d + 1, 2)) : x.toExponential(1); 71 | } 72 | -------------------------------------------------------------------------------- /src/util/region-helpers.js: -------------------------------------------------------------------------------- 1 | import LocusZoom from 'locuszoom'; 2 | import { PORTALDEV_URL, pip_fmt, two_digit_fmt1, two_digit_fmt2 } from '@/util/common'; 3 | 4 | const MAX_EXTENT = 1000000; 5 | 6 | function sourceName(display_name) { 7 | return display_name.replace(/[^A-Za-z0-9_]/g, '_'); 8 | } 9 | 10 | /** 11 | * Get the datasources required for a single track 12 | * @param gene_id Full ENSG identifier (including version) 13 | * @param {string} study_name The name of the study containing the dataset. Each study has their own unique list of tissues available, and the tissue and study are typically specified together (both fields are required to identify the specific tissue data of interest) 14 | * @param {string} tissue The name of the associated tissue 15 | * @returns {Array[]} Array of configuration options for all required data sources 16 | */ 17 | export function getTrackSources(gene_id, study_name, tissue) { 18 | const geneid_short = gene_id.split('.')[0]; 19 | return [ 20 | [sourceName(`assoc_${tissue}_${study_name}_${geneid_short}`), ['AssocFIVEx', { 21 | url: '/api/data/region', 22 | params: { gene_id, tissue, study: study_name }, 23 | }]], 24 | ]; 25 | } 26 | 27 | 28 | /** 29 | * Customize the layout of a single LocusZoom association panel so that it shows a particular thing on the y-axis 30 | * @param panel_layout 31 | * @param y_field log_pvalue, beta, or pip 32 | * @private 33 | */ 34 | function _set_panel_yfield(y_field, panel_layout) { 35 | // Updates to the panel: legend (orientation), axis labels, and ticks 36 | // Update scatter plot: point shape, y-axis, and legend options 37 | // Update line of significance: threshold 38 | 39 | const scatter_layout = panel_layout.data_layers.find((d) => d.id === 'associationpvalues'); 40 | const assoc_y_options = scatter_layout.y_axis; 41 | const significance_line_layout = panel_layout.data_layers.find((d) => d.id === 'significance'); 42 | if (y_field === 'beta') { // Settings for using beta as the y-axis variable 43 | delete panel_layout.axes.y1.ticks; 44 | panel_layout.legend.orientation = 'vertical'; 45 | panel_layout.axes.y1.label = 'Normalized Effect Size (NES)'; 46 | significance_line_layout.offset = 0; // Change dotted horizontal line to y=0 47 | significance_line_layout.style = { 48 | stroke: 'gray', 49 | 'stroke-width': '1px', 50 | 'stroke-dasharray': '10px 0px', 51 | }; 52 | assoc_y_options.field = `${panel_layout.id}:beta`; 53 | delete assoc_y_options.floor; 54 | delete assoc_y_options.ceiling; 55 | assoc_y_options.min_extent = [-1, 1]; 56 | // Note: changing the shapes for displayed points is conflicting with the reshaping by LD -- need to fix this later 57 | scatter_layout.point_shape = [ 58 | { 59 | scale_function: 'effect_direction', 60 | parameters: { 61 | '+': 'triangle', 62 | '-': 'triangledown', 63 | }, 64 | }, 65 | 'circle', 66 | ]; 67 | scatter_layout.legend = [ 68 | { shape: 'diamond', color: '#9632b8', size: 40, label: 'LD Ref Var', class: 'lz-data_layer-scatter' }, 69 | { shape: 'circle', color: '#d43f3a', size: 40, label: '1.0 > r² ≥ 0.8', class: 'lz-data_layer-scatter' }, 70 | { shape: 'circle', color: '#eea236', size: 40, label: '0.8 > r² ≥ 0.6', class: 'lz-data_layer-scatter' }, 71 | { shape: 'circle', color: '#5cb85c', size: 40, label: '0.6 > r² ≥ 0.4', class: 'lz-data_layer-scatter' }, 72 | { shape: 'circle', color: '#46b8da', size: 40, label: '0.4 > r² ≥ 0.2', class: 'lz-data_layer-scatter' }, 73 | { shape: 'circle', color: '#357ebd', size: 40, label: '0.2 > r² ≥ 0.0', class: 'lz-data_layer-scatter' }, 74 | { shape: 'circle', color: '#B8B8B8', size: 40, label: 'no r² data', class: 'lz-data_layer-scatter' }, 75 | ]; 76 | panel_layout.legend.hidden = true; 77 | } else if (y_field === 'log_pvalue') { // Settings for using -log10(P-value) as the y-axis variable 78 | delete panel_layout.axes.y1.ticks; 79 | panel_layout.legend.orientation = 'vertical'; 80 | panel_layout.axes.y1.label = '-log10 p-value'; 81 | significance_line_layout.offset = 7.301; // change dotted horizontal line to genomewide significant value 5e-8 82 | significance_line_layout.style = { 83 | stroke: '#D3D3D3', 84 | 'stroke-width': '3px', 85 | 'stroke-dasharray': '10px 10px', 86 | }; 87 | assoc_y_options.field = `${panel_layout.id}:log_pvalue`; 88 | // Set minimum y value to zero when looking at -log10 p-values 89 | assoc_y_options.floor = 0; 90 | delete assoc_y_options.ceiling; 91 | assoc_y_options.lower_buffer = 0; 92 | scatter_layout.point_shape = [ 93 | { 94 | scale_function: 'effect_direction', 95 | parameters: { 96 | '+': 'triangle', 97 | '-': 'triangledown', 98 | }, 99 | }, 100 | 'circle', 101 | ]; 102 | scatter_layout.legend = [ 103 | { shape: 'diamond', color: '#9632b8', size: 40, label: 'LD Ref Var', class: 'lz-data_layer-scatter' }, 104 | { shape: 'circle', color: '#d43f3a', size: 40, label: '1.0 > r² ≥ 0.8', class: 'lz-data_layer-scatter' }, 105 | { shape: 'circle', color: '#eea236', size: 40, label: '0.8 > r² ≥ 0.6', class: 'lz-data_layer-scatter' }, 106 | { shape: 'circle', color: '#5cb85c', size: 40, label: '0.6 > r² ≥ 0.4', class: 'lz-data_layer-scatter' }, 107 | { shape: 'circle', color: '#46b8da', size: 40, label: '0.4 > r² ≥ 0.2', class: 'lz-data_layer-scatter' }, 108 | { shape: 'circle', color: '#357ebd', size: 40, label: '0.2 > r² ≥ 0.0', class: 'lz-data_layer-scatter' }, 109 | { shape: 'circle', color: '#B8B8B8', size: 40, label: 'no r² data', class: 'lz-data_layer-scatter' }, 110 | ]; 111 | panel_layout.legend.hidden = true; 112 | } else if (y_field === 'pip') { 113 | panel_layout.legend.orientation = 'horizontal'; 114 | assoc_y_options.field = `${panel_layout.id}:pip|pip_yvalue`; 115 | assoc_y_options.floor = -4.1; 116 | assoc_y_options.ceiling = 0.9; // Max log value is 0 (PIP=1); pad ceiling to ensure that legend appears above all points FIXME: redo legend 117 | assoc_y_options.upper_buffer = 0.1; 118 | panel_layout.axes.y1.label = 'Posterior Inclusion Probability (PIP)'; 119 | panel_layout.axes.y1.ticks = [ 120 | { position: 'left', text: '1', y: 0 }, 121 | { position: 'left', text: '0.1', y: -1 }, 122 | { position: 'left', text: '0.01', y: -2 }, 123 | { position: 'left', text: '1e-3', y: -3 }, 124 | { position: 'left', text: '≤1e-4', y: -4 }, 125 | 126 | ]; 127 | // Modified from using pip_cluster as the shape 128 | scatter_layout.point_shape = [{ scale_function: 'pip_cluster' }, 'circle']; 129 | scatter_layout.legend = [ 130 | { shape: 'cross', size: 40, label: 'Cluster 1', class: 'lz-data_layer-scatter' }, 131 | { shape: 'square', size: 40, label: 'Cluster 2', class: 'lz-data_layer-scatter' }, 132 | { shape: 'circle', size: 40, label: 'No cluster', class: 'lz-data_layer-scatter' }, 133 | { shape: 'diamond', color: '#9632b8', size: 40, label: 'LD Ref Var', class: 'lz-data_layer-scatter' }, 134 | { shape: 'circle', color: '#d43f3a', size: 40, label: '1.0 > r² ≥ 0.8', class: 'lz-data_layer-scatter' }, 135 | { shape: 'circle', color: '#eea236', size: 40, label: '0.8 > r² ≥ 0.6', class: 'lz-data_layer-scatter' }, 136 | { shape: 'circle', color: '#5cb85c', size: 40, label: '0.6 > r² ≥ 0.4', class: 'lz-data_layer-scatter' }, 137 | { shape: 'circle', color: '#46b8da', size: 40, label: '0.4 > r² ≥ 0.2', class: 'lz-data_layer-scatter' }, 138 | { shape: 'circle', color: '#357ebd', size: 40, label: '0.2 > r² ≥ 0.0', class: 'lz-data_layer-scatter' }, 139 | { shape: 'circle', color: '#B8B8B8', size: 40, label: 'no r² data', class: 'lz-data_layer-scatter' }, 140 | ]; 141 | significance_line_layout.offset = -1000; 142 | significance_line_layout.style = { 143 | stroke: 'gray', 144 | 'stroke-width': '1px', 145 | 'stroke-dasharray': '10px 0px', 146 | }; 147 | assoc_y_options.min_extent = [0, 1]; 148 | panel_layout.legend.hidden = false; 149 | } else { 150 | throw new Error('Unrecognized y_field option'); 151 | } 152 | return panel_layout; 153 | } 154 | 155 | 156 | /** 157 | * Get the LocusZoom layout for a single track 158 | * @param {string} gene_id 159 | * @param study_name The study name, as used in human-readable panel titles 160 | * @param {string} tissue 161 | * @param {object} state 162 | * @param {String} genesymbol 163 | * @param {String} y_field The name of the field to use for y-axis display; same as used in interactive layout mutations 164 | * @returns {[*]} 165 | */ 166 | export function getTrackLayout(gene_id, study_name, tissue, state, genesymbol, y_field) { 167 | const symbol = genesymbol || gene_id; 168 | const geneid_short = gene_id.split('.')[0]; 169 | 170 | const newscattertooltip = LocusZoom.Layouts.get('data_layer', 'association_pvalues', { unnamespaced: true }).tooltip; 171 | // FIXME: Right now, region pages are eqtl only; they don't show sQTLs. As such, the phewas page links hardcode to the eqtl view. 172 | newscattertooltip.html = `{{{{namespace[assoc]}}variant|htmlescape}}
173 | P Value: {{{{namespace[assoc]}}log_pvalue|logtoscinotation|htmlescape}}
174 | Ref. Allele: {{{{namespace[assoc]}}ref_allele|htmlescape}}
175 | Set LD Reference
179 | Go to single-variant view
180 | Study: {{{{namespace[assoc]}}study}}
181 | Gene: {{{{namespace[assoc]}}symbol}}
182 | MAF: {{{{namespace[assoc]}}maf|twosigfigs}}
183 | Effect Size: {{{{namespace[assoc]}}beta|twosigfigs}}
184 | PIP: {{{{namespace[assoc]}}pip|pip_display}}
185 | Credible set label: {{{{namespace[assoc]}}cs_index}}
186 | Credible set size: {{{{namespace[assoc]}}cs_size}}
187 | rsid: {{{{namespace[assoc]}}rsid}}
`; 188 | 189 | const namespace = { assoc: sourceName(`assoc_${tissue}_${study_name}_${geneid_short}`) }; 190 | const assoc_layer = LocusZoom.Layouts.get('data_layer', 'association_pvalues', { 191 | unnamespaced: true, 192 | fields: [ 193 | '{{namespace[assoc]}}chromosome', '{{namespace[assoc]}}position', 194 | '{{namespace[assoc]}}study', 195 | '{{namespace[assoc]}}ref_allele', 196 | '{{namespace[assoc]}}variant', '{{namespace[assoc]}}symbol', 197 | '{{namespace[assoc]}}log_pvalue', '{{namespace[assoc]}}beta', 198 | '{{namespace[assoc]}}stderr_beta', '{{namespace[assoc]}}maf', 199 | '{{namespace[assoc]}}pip', '{{namespace[assoc]}}pip|pip_yvalue', 200 | '{{namespace[assoc]}}cs_size', '{{namespace[assoc]}}cs_index', 201 | '{{namespace[assoc]}}rsid', 202 | '{{namespace[ld]}}state', '{{namespace[ld]}}isrefvar', 203 | ], 204 | tooltip: newscattertooltip, 205 | }); 206 | 207 | const layoutBase = LocusZoom.Layouts.get('panel', 'association', { 208 | id: sourceName(`assoc_${tissue}_${study_name}_${geneid_short}`), 209 | height: 275, 210 | title: { // Remove this when LocusZoom update with the fix to toolbar titles is published 211 | text: `${symbol} in ${tissue} (${study_name})`, 212 | x: 60, 213 | y: 30, 214 | }, 215 | namespace, 216 | data_layers: [ 217 | LocusZoom.Layouts.get('data_layer', 'significance', { unnamespaced: true }), 218 | LocusZoom.Layouts.get('data_layer', 'recomb_rate', { unnamespaced: true }), 219 | assoc_layer, 220 | ], 221 | }); 222 | layoutBase.axes.y1.label_offset = 36; 223 | 224 | // For now, we'll apply user-modifications to the layout at the end in one big mutation instead of in the base layout 225 | _set_panel_yfield(y_field, layoutBase); 226 | return [layoutBase]; 227 | } 228 | 229 | /** 230 | * Get the LocusZoom layout for a single-track plot, filling in options as needed 231 | * @param {object} initial_state 232 | * @param {Array[]} track_panels 233 | * @returns {Object} 234 | */ 235 | export function getBasicLayout(initial_state = {}, track_panels = []) { 236 | const newgenestooltip = LocusZoom.Layouts.get('data_layer', 'genes_filtered', { unnamespaced: true }).tooltip; 237 | const gene_track = LocusZoom.Layouts.get('data_layer', 'genes_filtered', { 238 | unnamespaced: true, 239 | tooltip: newgenestooltip, 240 | }); 241 | 242 | return LocusZoom.Layouts.get('plot', 'standard_association', { 243 | state: initial_state, 244 | max_region_scale: MAX_EXTENT, 245 | responsive_resize: true, 246 | panels: [ 247 | ...track_panels, 248 | LocusZoom.Layouts.get('panel', 'genes', { data_layers: [gene_track] }), 249 | ], 250 | }); 251 | } 252 | 253 | /** 254 | * Get the default source configurations for a plot 255 | */ 256 | export function getBasicSources(track_sources = []) { 257 | return [ 258 | ...track_sources, 259 | ['ld', ['LDLZ2', { 260 | url: 'https://portaldev.sph.umich.edu/ld/', 261 | params: { source: '1000G', population: 'ALL', build: 'GRCh38' }, 262 | }]], 263 | ['recomb', ['RecombLZ', { 264 | url: `${PORTALDEV_URL}annotation/recomb/results/`, 265 | params: { build: 'GRCh38' }, 266 | }]], 267 | ['gene', ['GeneLZ', { url: `${PORTALDEV_URL}annotation/genes/`, params: { build: 'GRCh38' } }]], 268 | ['constraint', ['GeneConstraintLZ', { 269 | url: 'https://gnomad.broadinstitute.org/api', 270 | params: { build: 'GRCh38' }, 271 | }]], 272 | ]; 273 | } 274 | 275 | /** 276 | * Add the specified data to the plot 277 | * @param {LocusZoom.Plot} plot 278 | * @param {LocusZoom.DataSources} data_sources 279 | * @param {Object[]} panel_options 280 | * @param {Object[]} source_options 281 | */ 282 | function addPanels(plot, data_sources, panel_options, source_options) { 283 | source_options.forEach((source) => { 284 | if (!data_sources.has(source[0])) { 285 | data_sources.add(...source); 286 | } 287 | }); 288 | panel_options.forEach((panel_layout) => { 289 | panel_layout.y_index = -1; // Make sure genes track is always the last one 290 | const panel = plot.addPanel(panel_layout); 291 | panel.addBasicLoader(); 292 | }); 293 | } 294 | 295 | /** 296 | * Add a single new track to the plot 297 | * @param {LocusZoom.Plot} plot 298 | * @param {LocusZoom.DataSources} datasources 299 | * @param {string} gene_id 300 | * @param {string} tissue 301 | * @param {string} study_name 302 | * @param {string} genesymbol 303 | */ 304 | export function addTrack(plot, datasources, gene_id, tissue, study_name, genesymbol, y_field) { 305 | const track_layout = getTrackLayout(gene_id, study_name, tissue, plot.state, genesymbol, y_field); 306 | const track_sources = getTrackSources(gene_id, study_name, tissue); 307 | addPanels(plot, datasources, track_layout, track_sources); 308 | } 309 | 310 | /** 311 | * Switch the options used in displaying Y axis 312 | * @param y_field Which field to use in plotting y-axis. Either 'log_pvalue', 'beta', or 'pip' 313 | * @param plot_layout 314 | */ 315 | export function switchY_region(plot_layout, y_field) { 316 | // Apply a layout mutation to all matching panels 317 | LocusZoom.Layouts.mutate_attrs(plot_layout, '$..panels[?(@.tag === "association")]', _set_panel_yfield.bind(null, y_field)); 318 | } 319 | 320 | 321 | export function get_region_table_config() { 322 | return [ 323 | { 324 | title: 'Variant', field: 'variant_id', formatter: 'link', 325 | sorter(a, b, aRow, bRow, column, dir, sorterParams) { 326 | // Sort by chromosome, then position 327 | const a_data = aRow.getData(); 328 | const b_data = bRow.getData(); 329 | return (a_data.chromosome).localeCompare(b_data.chromosome, undefined, { numeric: true }) 330 | || a_data.position - b_data.position; 331 | }, 332 | formatterParams: { 333 | url: (cell) => { 334 | const data = cell.getRow().getData(); 335 | // FIXME: Region pages only handle eqtls at present, so we hardcode a link to the eqtl version of the page 336 | return `/variant/eqtl/${data.chromosome}_${data.position}`; 337 | }, 338 | }, 339 | }, 340 | { title: 'Study', field: 'study', headerFilter: true }, 341 | { title: 'Tissue', field: 'tissue', headerFilter: true }, 342 | // TODO: Convert these gene_ids to gene symbols for ease of reading 343 | { title: 'Gene name', field: 'symbol', headerFilter: true }, 344 | { title: 'Gene ID', field: 'gene_id', headerFilter: true }, 345 | { 346 | title: '-log10(p)', 347 | field: 'log_pvalue', 348 | formatter: two_digit_fmt2, 349 | sorter: 'number', 350 | }, 351 | { title: 'Effect Size', field: 'beta', formatter: two_digit_fmt1, sorter: 'number' }, 352 | { title: 'SE (Effect Size)', field: 'stderr_beta', formatter: two_digit_fmt1 }, 353 | { title: 'PIP', field: 'pip', formatter: pip_fmt, sorter: 'number' }, 354 | { title: 'CS Label', field: 'cs_index' }, 355 | { title: 'CS Size', field: 'cs_size' }, 356 | ]; 357 | } 358 | -------------------------------------------------------------------------------- /src/views/About.vue: -------------------------------------------------------------------------------- 1 | 330 | 331 | 336 | 337 | 340 | -------------------------------------------------------------------------------- /src/views/Error.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/views/Help.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 76 | -------------------------------------------------------------------------------- /src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 65 | 66 | 71 | -------------------------------------------------------------------------------- /src/views/NotFound.vue: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /src/views/Tutorial.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 76 | 77 | 80 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is required to make pytest happy. It serves no other purpose. 3 | 4 | See also: https://en.wikipedia.org/wiki/Cargo_cult 5 | """ 6 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Shared fixtures""" 2 | 3 | import pytest # type: ignore # noqa 4 | 5 | from fivex import create_app 6 | 7 | 8 | @pytest.fixture 9 | def app(): 10 | app = create_app("fivex.settings.test") 11 | return app 12 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | """Test the REST APIs""" 2 | 3 | import pytest 4 | from flask import url_for 5 | 6 | 7 | ##### 8 | # Smoke tests: ensure that each page of the app loads. 9 | # This is a safeguard against forgetting to provide sample data. URLs reference example views, 10 | # and may need to be updated if the homepage examples change 11 | def test_loads_region(client): 12 | url = url_for( 13 | "api.region_query", 14 | chrom="1", 15 | start=108774968, 16 | end=109774968, 17 | study="GTEx", 18 | tissue="adipose_subcutaneous", 19 | ) 20 | response = client.get(url) 21 | assert response.status_code == 200 22 | 23 | 24 | def test_loads_region_bestvar(client): 25 | # TODO: Ideally, we'd test this endpoint in a region where specifying `gene_id` changed the best result 26 | url = url_for( 27 | "api.region_query_bestvar", chrom="1", start=108774968, end=109774968 28 | ) 29 | response = client.get(url) 30 | 31 | assert response.status_code == 200 32 | content = response.get_json() 33 | assert content["data"]["tissue"] == "adipose_naive" 34 | assert content["data"]["study"] == "FUSION" 35 | assert content["data"]["symbol"] == "AMIGO1" 36 | 37 | 38 | @pytest.mark.skip( 39 | "Temporarily removed this functionality (specifying a gene) from our bestvar query" 40 | ) 41 | def test_loads_region_bestvar_for_gene(client): 42 | """What is an example of the gene id altering the query for best variant in a region?""" 43 | url = url_for( 44 | "api.region_query_bestvar", 45 | chrom="1", 46 | start=108774968, 47 | end=109774968, 48 | gene_id="ENSG00000134243", 49 | ) 50 | response = client.get(url) 51 | assert response.status_code == 200 52 | content = response.get_json() 53 | assert content["data"]["tissue"] == "Liver" 54 | 55 | 56 | def test_loads_variant_eqtls_by_default(client): 57 | url = url_for("api.variant_query", chrom="1", pos=109274968) 58 | assert client.get(url).status_code == 200 59 | 60 | 61 | def test_loads_variant_sqtls_with_option(client): 62 | url = url_for( 63 | "api.variant_query", chrom="1", pos=109274968, data_type="txrev" 64 | ) 65 | assert client.get(url).status_code == 200 66 | 67 | 68 | def test_variant_api_rejects_invalid_option(client): 69 | url = url_for( 70 | "api.variant_query", 71 | chrom="1", 72 | pos=109274968, 73 | data_type="somethingsomething", 74 | ) 75 | assert client.get(url).status_code == 200 76 | -------------------------------------------------------------------------------- /tests/test_frontend.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test frontend HTML views 3 | """ 4 | from flask import url_for 5 | 6 | 7 | ##### 8 | # Smoke tests: ensure that each page of the app loads. 9 | # This is a safeguard against forgetting to provide sample data. URLs reference example views, 10 | # and may need to be updated if the homepage examples change 11 | def test_loads_region(client): 12 | url = url_for( 13 | "frontend.region_view", chrom="1", start=108774968, end=109774968 14 | ) 15 | assert client.get(url).status_code == 200 16 | 17 | 18 | def test_loads_variant(client): 19 | url = url_for("frontend.variant_view", chrom="1", pos=109274968) 20 | assert client.get(url).status_code == 200 21 | 22 | 23 | #### 24 | # Test specific, intended view behaviors 25 | def test_region_must_provide_query_params(client): 26 | url = url_for("frontend.region_view") 27 | assert client.get(url).status_code == 400 28 | -------------------------------------------------------------------------------- /tests/unit/example.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { shallowMount } from '@vue/test-utils'; 3 | import HomePage from '@/views/Home.vue'; 4 | 5 | describe('Home.vue', () => { 6 | it('renders Home page text', () => { 7 | const wrapper = shallowMount(HomePage, {/* propsData: { msg }, */}); 8 | expect(wrapper.text()).to.include('FIVEx'); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /util/README.md: -------------------------------------------------------------------------------- 1 | # Setting up FIVEx for multiple datasets (EBI) 2 | 3 | FIVEx eQTL browser for multiple studies 4 | 5 | Notes and instructions for generating supporting data 6 | 7 | --- 8 | Introduction 9 | 10 | FIVEx presents data from an extensive collection of eQTLs from the EBI eQTL 11 | Catalogue (https://www.ebi.ac.uk/eqtl/). Supporting data can be downloaded 12 | from the EBI directly via ftp (ftp://ftp.ebi.ac.uk/pub/databases/spot/eQTL/csv/ 13 | for eQTL data, ftp://ftp.ebi.ac.uk/pub/databases/spot/eQTL/credible_sets/ 14 | for credible set data). We wrote FIVEx to use the tabix-compatible bgzipped 15 | tab-delimited file format. 16 | 17 | FIVEx features two views of eQTL data: 18 | - A single variant view, which shows the effect that variant has on the 19 | expression of nearby genes (defined as genes whose transcription start 20 | sites [TSS] are within one million base pairs [bps] of that variant) 21 | in all tested tissues 22 | - A regional view, which shows the effect of variants within a region on 23 | the expression of a specific gene within a given tissue 24 | 25 | 26 | --- 27 | Supporting Data 28 | 29 | Note: all examples below use gene expression ("ge") data. The sQTL data 30 | generated with txrevise ("txrev") was processed the same way by replacing 31 | instances of "ge" in path and file names with "txrev". 32 | 33 | FIVEx's regional view uses data directly from the eQTL Catalogue. 34 | Study- and tissue-specific data need to be placed in the data directory, 35 | in the following location: 36 | 37 | {DATA_DIR}/ebi_original/{STUDY}/ge/{STUDY}_ge_{TISSUE}.all.tsv.gz 38 | 39 | where {DATA_DIR} is the base data directory for FIVEx, {STUDY} is the name 40 | of the study, and {TISSUE} is the name of the tissue. 41 | 42 | FIVEx's single-variant view requires data files containing all studies and 43 | tissues, split into 1 Mbps chunks. These combined study- and tissue-specific 44 | files can be generated using the following utility script, which takes 45 | multiple sorted study- and tissue-specific files and combined them into one 46 | sorted all-studies, all-tissues file, subsetted to a given range: 47 | 48 | util/merge.files.with.sorted.positions.py 49 | 50 | This script takes 6 arguments as input, 5 of which are mandatory: 51 | 52 | 1. indexf: a whitespace-delimited index file containing information on all 53 | study- and tissue-specific files. The file should be in the 54 | following format, with one file listed per line 55 | [dataset_name] [tissue_name] [filename] 56 | 2. chrom: chromosome to subset -- used in tabix-based data retrieval 57 | 3. beg: beginning position of the extraction range -- used in tabix-based 58 | data retrieval 59 | 4. end: ending position of the extraction range -- used in tabix-based data 60 | retrieval 61 | 5. outf: output file name for the merged file 62 | 6. (optional) posIdx: column number (0-based, so the first column is 0, 63 | second column is 1, etc.) containing genomic position. 64 | Default = 2 (corresponding to the format of the eQTL 65 | Catalogue's gene expression data). Please note that the 66 | credible_sets files have their genomic positions in the 67 | fourth column (posIdx = 3). 68 | 69 | To create the necessary supporting dataset, you will need to create an index 70 | file listing all the study- and tissue-specific files (see 1. above). Then, 71 | you will need to run the merging script on each 1Mbp chunk and save the output 72 | to the following directory: 73 | 74 | {DATA_DIR}/ebi_ge/{CHROM}/all.EBI.ge.data.chr{CHROM}.{START}-{END}.tsv.gz 75 | 76 | where {CHROM} is the chromosome name, {START} is the start position, and 77 | {END} is the end position. We require the {START} positions to be exactly 78 | (n*1000000) + 1, where n is a non-negative integer, while the corresponding 79 | {END} positions must be (n+1) * 1000000. 80 | 81 | To simplify this process, we created a supporting Python script to generate 82 | shell commands to run the merging script: 83 | 84 | util/generate.commands.to.merge.EBI.gene.expressions.py 85 | 86 | This script takes in two arguments: 87 | 88 | 1. rawDataDir: the directory containing raw data downloaded from the 89 | eQTL Catalogue. This should be located at 90 | {DATA_DIR}/ebi_original/ 91 | 2. outDir: the data directory for FIVEx. This should be set to 92 | {DATA_DIR} 93 | 94 | This script requires you to create an index file and place it at 95 | 96 | {outDir}/all_EBI_ge_data_index.tsv 97 | 98 | to function correctly. 99 | 100 | Running this script will generate the following shell script file: 101 | 102 | extract.and.tabix.gene_expressions.sh 103 | 104 | These commands will generate combined study- and tissue-specific data files 105 | to support FIVEx's single variant view. Please note that these commands can be 106 | run in parallel. 107 | 108 | 109 | --- 110 | Credible Sets data 111 | 112 | EBI credible sets data contain analysis results from the Sum of Single Effects 113 | method (SuSiE, https://stephenslab.github.io/susie-paper/index.html). Similar 114 | to the eQTL data, our single-variant and regional views use different files. 115 | Unlike the eQTL data, the raw data is unsorted and needs to be processed to be 116 | used in both single-variant and regional views. 117 | 118 | FIVEx's regional view uses credible sets data in sorted format. 119 | Single-variant view requires credible sets data to be combined across studies 120 | and tissues, sorted by chromosomal position. We then join p-values and other 121 | fields from the raw supporting data, along with short gene names, using a second 122 | script. This data will be used to support the tables at the bottom of the region 123 | view page. 124 | 125 | For step 1, since credible set files are smaller, we will combine the files 126 | into chromosome-length chunks instead of 1 Mbps chunks. We will use the same 127 | script (util/merge.files.with.sorted.positions.py) to combine the credible 128 | sets data. To simplify this, we provide a script that generates commands to 129 | both sort and combine the data: 130 | 131 | util/generate.commands.to.merge.EBI.credible_sets.py 132 | 133 | This script takes in two arguments: 134 | 135 | 1. rawDataDir: the directory holding the raw downloaded credible_sets data. 136 | Data from all different studies and tissues should be stored 137 | in this directory directly, not inside subdirectories. 138 | 2. outDir: FIVEx's data directory {DATA_DIR}. The script will generate the 139 | necessary subdirectories inside this directory. 140 | 141 | This will generate a command file that will perform the following tasks: 142 | 143 | 1. Create the directory structure necessary to hold the output 144 | 2. Sort raw files and save them as study- and tissue-specific data files 145 | to be used in our region view 146 | 3. Extract and combine sorted files, one chromosome at a time, to be used 147 | in our single variant view 148 | 149 | This command file should be run from inside the util/ directory. 150 | 151 | The output files will be found in the following location: 152 | 1. Sorted study- and tissue-specific files (final): 153 | {outDir}/credible_sets/ge/{study}/{study}.{tissue}_ge.purity_filtered.sorted.txt.gz 154 | 2. Combined chromosome-specific files (intermediate): 155 | {outDir}/credible_sets/ge/temp/chr{chrom}.ge.credible_set.tsv.gz 156 | 157 | For step 2, we will use join-spot-cred-marginal-add-genenames.py to join fields 158 | from the raw data with the intermediate combined chromosome-specific files to 159 | create the final joined files. 160 | 161 | An example command for joining data into a credible sets file: 162 | python3 join-spot-cred-marginal-add-genenames.py -a all.EBI.ge.data.chr1.1000001-2000000.tsv.gz -c chr1.ge.credible_set.tsv.gz -o ge.credible_set.joined.chr1.1000001-2000000.tsv.gz -r 1:1000001-2000000 163 | 164 | -a: source raw data file containing the target data range 165 | -c: source credible sets file 166 | -o: target output joined credible sets file 167 | -r: range, used by tabix to retrieve the desired region; usually corresponds to the range of the file specified by -a 168 | 169 | The final joined, chromosome-long credible sets files should be placed here: 170 | {outDir}/credible_sets/ge/chr{chrom}.ge.credible_set.tsv.gz 171 | 172 | --- 173 | Description of file formats 174 | 175 | Note: {datatype} is currently either ge (for gene expression eQTL files) 176 | or txrev (for txrevise sQTL files) 177 | All files ending with .gz were compressed with bgzip and indexed with tabix 178 | 179 | Raw Data: 180 | Study-specific Raw Data: 181 | Data file: data/ebi_original/{datatype}/{study}/{study}_{datatype}_{tissue}.all.tsv.gz 182 | File format: bgzipped and tabixed files, tab-separated data columns 183 | Column contents can be found in the definition for VariantParser, but without 184 | the study and tissue columns (because the file is study- and tissue-specific). 185 | Merged Raw Data: 186 | Data file: data/ebi_{datatype}/{chromosome}/all.EBI.{datatype}.data.chr{chrom}.{start}-{end}.tsv.gz 187 | File format: bgzipped and tabixed files, tab-separated data columns 188 | Column contents can be found in the definition for VariantParser, with study 189 | and tissue as the first two columns. 190 | 191 | Credible Sets Data: 192 | Study-specific data: 193 | Data file: data/credible_sets/{datatype}/{study}/{study}.{tissue}_{datatype}.purity_filtered.sorted.txt.gz 194 | Note: contains a header line starting with "#" 195 | File format: bgzipped and tabixed files, tab-separated data columns 196 | first sorted by chromosome, then by position. 197 | Column contents: phenotype_id, variant_id, chr, pos, ref, alt, cs_id, 198 | cs_index, finemapped_region, pip, z, cs_min_r2, cs_avg_r2, cs_size, 199 | posterior_mean, posterior_sd, cs_log10bf 200 | Joined and merged data: 201 | Data file: data/credible_sets/{datatype}/chr{chrom}.{datatype}.credible_set.tsv.gz 202 | File format: bgzipped and tabixed files, tab-separated data columns 203 | Column contents: begins with columns for study and tissue, then the same 204 | columns as study-specific data, then extra columns joined from raw data 205 | (can be found in the definition of CIParser): 206 | ma_samples, maf, pvalue, beta, se, type, ac, an, r2, mol_trait_obj_id, 207 | gid (geneID), median_tpm, rsid, gene_symbol (e.g. "SORT1") 208 | 209 | Supporting database files: 210 | data/gene.id.symbol.map.json.gz 211 | File format: a JSON file containing two-way mappings between common gene 212 | names and Ensembl GeneIDs. 213 | 214 | Note: Currently, when Ensembl GeneIDs are used as the key, it does not 215 | contain version numbers; when common gene names are used as the key, the 216 | target GeneID values do contain version numbers. Since none of EBI's genes 217 | contain version numbers, the version numbers can safely be removed. 218 | 219 | data/rsid.sqlite3.db 220 | File format: sqlite3 database containing a single table "rsidTable" 221 | data columns within the table are: 222 | chrom (TEXT), pos (INTEGER), ref (TEXT), alt (TEXT), rsid (TEXT) 223 | Indexed on both (chrom, pos) and (rsid) 224 | Intended for rsid lookup using chrom and pos 225 | 226 | data/credible_sets/{datatype}/pip.best.variant.summary.sorted.indexed.sqlite3.db 227 | File format: sqlite3 database containing a single table "sig" 228 | data columns within the table are: 229 | pip (REAL), study (TEXT), tissue (TEXT), gene_id (TEXT), chrom (TEXT), 230 | pos (INTEGER), ref (TEXT), alt (TEXT), cs_index (TEXT), cs_size (INTEGER) 231 | Indexed on (chrom, pos), (study, tissue), and (gene_id) 232 | Contains the "best" data point at each variant by a simple heuristic: 233 | (1) has the strongest PIP signal, and (2) if there is a tie, the point with 234 | the most significant P-value 235 | 236 | data/gencode/gencode.v30.annotation.gtf.genes.bed.gz 237 | data/gencode/gencode.v30.annotation.gtf.transcripts.bed.gz 238 | File format: bgzipped and tabixed subsets of raw gencode files 239 | The third column indicates the data type of each line: 240 | - The "genes" file contains "gene" as the 3rd field 241 | - The "transcripts" file contains "transcript" as the 3rd field 242 | Column values for genes file: 243 | chrom, data_source, information_category, 244 | start, end, strand (+ or -), geneID, gene_type, gene_name 245 | Column values for transcripts file: 246 | chrom, data_source, information_category, 247 | start, end, strand, gene_id, transcript_id, gene_type, gene_name, 248 | transcript_type, transcript_name 249 | 250 | data/gencode/tss.json.gz 251 | File format: a JSON file mapping both geneIDs and gene names to transcription 252 | start site positions. Positive values for positions indicate a forward 253 | transcription direction (+ strand), while negative values indicate a backwards 254 | transcription direction (- strand). This information is used to calculate 255 | the distance between a variant and the TSS of surrounding genes. -------------------------------------------------------------------------------- /util/create.UM.statgen.links.sh: -------------------------------------------------------------------------------- 1 | ln -f -s -t ../data /net/amd/amkwong/FIVEx/data/* -------------------------------------------------------------------------------- /util/create.index.file.for.gene.expression.EBI.data.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import os 3 | 4 | filelist = glob.glob("/net/1000g/hmkang/data/spot/*/ge/*.all.tsv.gz") 5 | outIndex = "./all_EBI_ge_data_index.tsv" 6 | 7 | with open(outIndex, "w") as w: 8 | for infile in filelist: 9 | study = infile.split("/")[-3] 10 | tissue = os.path.basename(infile).split(".")[0].split("_ge_")[-1] 11 | w.write(study + " " + tissue + " " + infile + "\n") 12 | -------------------------------------------------------------------------------- /util/create.rsid.sqlite3.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import gzip 3 | import os.path 4 | import sqlite3 5 | import subprocess 6 | 7 | # Generate shell script to make bgzipped files with chrom, pos, ref, alt, and rsnum 8 | 9 | tsvList = glob.glob("../data/ebi_ge/*/*.tsv.gz") 10 | temp = subprocess.call(["mkdir", "-p", "temp"]) 11 | outfileList = [] 12 | 13 | with open("./temp/get.rsid.tables.sh", "w") as w: 14 | for infile in tsvList: 15 | outfile = os.path.join( 16 | "./temp", 17 | os.path.basename(infile).replace(".tsv.gz", ".rsid.tsv.gz"), 18 | ) 19 | outfileList.append(outfile) 20 | w.write( 21 | f"zcat {infile} | cut -f 4-7,21 | sort | uniq | bgzip -c > {outfile}\n" 22 | ) 23 | 24 | 25 | # Run script 26 | 27 | temp = subprocess.call(["bash", "./temp/get.rsid.tables.sh"]) 28 | 29 | 30 | # create SQLite3 database of rsids 31 | 32 | conn = sqlite3.connect("../data/rsid.sqlite3.db") 33 | 34 | 35 | def parseline(line): 36 | (chrom, pos, ref, alt, rsid) = line.decode("utf-8").rstrip("\n").split() 37 | return [chrom, int(pos), ref, alt, rsid] 38 | 39 | 40 | with conn: 41 | cursor = conn.cursor() 42 | cursor.execute("DROP TABLE IF EXISTS rsidTable") 43 | cursor.execute( 44 | "CREATE TABLE rsidTable (chrom TEXT, pos INTEGER, ref TEXT, alt TEXT, rsid TEXT)" 45 | ) 46 | 47 | for filename in outfileList: 48 | with gzip.open(filename.rstrip("\n")) as f: 49 | for line in f: 50 | try: 51 | rsidList = parseline(line) 52 | cursor.execute( 53 | "INSERT INTO rsidTable VALUES (?,?,?,?,?)", 54 | tuple(rsidList), 55 | ) 56 | except ValueError: 57 | continue 58 | cursor.execute("CREATE INDEX idx_chrom_pos ON rsidTable (chrom, pos)") 59 | cursor.execute("CREATE INDEX idx_rsid ON rsidTable (rsid)") 60 | conn.commit() 61 | cursor.close() 62 | -------------------------------------------------------------------------------- /util/generate.commands.to.merge.EBI.credible_sets.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import os 3 | import sys 4 | 5 | # List of chromosomes 6 | chrList = range(1, 23) 7 | 8 | # The base directory containing EBI credible_sets files. 9 | # Please note that the raw downloaded files are not sorted by position, 10 | # and need to be sorted before they can be combined 11 | # In the UM statgen cluster, the raw data can be found here: 12 | # /net/1000g/hmkang/data/spot/credible_sets/raw/ 13 | rawDataDir = sys.argv[1] 14 | 15 | # Output base directory for data 16 | # On the UM statgen cluster, this can be found at: 17 | # /net/amd/amkwong/browseQTL/v2_data/credible_sets/ 18 | # Generally, you will want this to be set to FIVEx's base data directory, 19 | # {FIVEX_DATA_DIR} 20 | # typically located at {FIVEX_BASE_DIR}/credible_sets/ 21 | # The sorted data should be located at "{outDir}/credible_sets/" 22 | # Study- and tissue-specific files should be located inside their own 23 | # directories inside credible_sets, while the combined data should be 24 | # directly in credible_sets 25 | outDir = sys.argv[2] 26 | 27 | # These are our default locations for supporting files and the default target data location 28 | # The script should be located at {FIVEX_BASE_DIR}/util/merge.files.with.sorted.positions.py 29 | scriptPath = "./merge.files.with.sorted.positions.py" 30 | indexFile = os.path.join(outDir, "all_EBI_credible_sets_data_index.tsv") 31 | csDirectory = os.path.join(outDir, "credible_sets") 32 | 33 | # This is the shell command file to sort, merge, and tabix the data files 34 | outputCommandFile = os.path.join( 35 | outDir, "sort.extract.and.tabix.credible_sets.sh" 36 | ) 37 | 38 | # Create index file for our merge.files.with.sorted.positions.py script 39 | fileList = glob.glob(os.path.join(rawDataDir, "*_ge.purity_filtered.txt.gz")) 40 | 41 | # raw file names are in the format {study}.{tissue}_ge.purity_filtered.txt.gz 42 | sortedFilelist = list() 43 | with open(outputCommandFile, "w") as w, open(indexFile, "w") as wi: 44 | for filepath in fileList: 45 | dataset = os.path.basename(filepath).split(".")[0] 46 | studydir = os.path.join(csDirectory, dataset) 47 | outfile = os.path.join( 48 | studydir, 49 | os.path.basename(filepath).replace( 50 | "purity_filtered.txt.gz", "purity_filtered.sorted.txt.gz" 51 | ), 52 | ) 53 | sortedFilelist.append(outfile) 54 | w.write( 55 | f"mkdir -p {studydir}\n( ( echo -n '#' ; zcat {filepath} | head -n 1 ) ; zcat {filepath} | tail -n +2 | sort -k3,3V -k4,4n ) | bgzip -c > {outfile}\ntabix -s 3 -b 4 -e 4 {outfile}\n" 56 | ) 57 | 58 | for sortedFile in sortedFilelist: 59 | tempSplit = ( 60 | os.path.basename(sortedFile) 61 | .replace("_ge.purity_filtered.sorted.txt.gz", "") 62 | .split(".") 63 | ) 64 | dataset = tempSplit[0] 65 | tissue = tempSplit[1] 66 | wi.write(f"{dataset}\t{tissue}\t{sortedFile}\n") 67 | 68 | # Generate commands to run merge.files.with.sorted.positions.py script, then tabix the results 69 | with open(outputCommandFile, "a") as w: 70 | for chrom in chrList: 71 | w.write( 72 | f"python3 {scriptPath} {indexFile} {chrom} 1 ' ' {csDirectory}/chr{chrom}.ge.credible_set.tsv.gz 3\n" 73 | ) 74 | w.write( 75 | f"tabix -s 5 -b 6 -e 6 {csDirectory}/chr{chrom}.ge.credible_set.tsv.gz\n" 76 | ) 77 | -------------------------------------------------------------------------------- /util/generate.commands.to.merge.EBI.gene.expressions.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import os 3 | import subprocess 4 | import sys 5 | 6 | # Indicates the number of megabases each chromosome has -- used for making merged files into 1MB chunks 7 | chrDict = { 8 | "1": 249, 9 | "2": 243, 10 | "3": 199, 11 | "4": 191, 12 | "5": 182, 13 | "6": 171, 14 | "7": 160, 15 | "8": 146, 16 | "9": 139, 17 | "10": 134, 18 | "11": 136, 19 | "12": 134, 20 | "13": 115, 21 | "14": 108, 22 | "15": 102, 23 | "16": 91, 24 | "17": 84, 25 | "18": 81, 26 | "19": 59, 27 | "20": 65, 28 | "21": 47, 29 | "22": 51, 30 | } 31 | 32 | # omitted: 'X':157} 33 | 34 | # The base directory containing EBI gene expression files. 35 | # In the UM statgen cluster, the raw data can be found at "/net/1000g/hmkang/data/spot/" 36 | # Generally, you will want to store the raw data at "{outDir}/ebi_original/" 37 | rawDataDir = sys.argv[1] 38 | 39 | # Output base directory for data 40 | # On the UM statgen cluster, this can be found at "/net/amd/amkwong/browseQTL/v2_data/" 41 | # Generally, you will want this to be set to FIVEx's base data directory, {FIVEX_DATA_DIR} 42 | # typically located at {FIVEX_BASE_DIR}/data/ 43 | outDir = sys.argv[2] 44 | 45 | # These are our default locations for supporting files and the default target data location 46 | # The script should be located at {FIVEX_BASE_DIR}/util/merge.files.with.sorted.positions.py 47 | scriptPath = "./merge.files.with.sorted.positions.py" 48 | indexFile = os.path.join(outDir, "all_EBI_ge_data_index.tsv") 49 | geDirectory = os.path.join(outDir, "ebi_ge") 50 | 51 | # This is the shell command file to merge the data files 52 | outputCommandFile = os.path.join( 53 | outDir, "extract.and.tabix.gene_expression.sh" 54 | ) 55 | 56 | # Create index file for our merge.files.with.sorted.positions.py script 57 | fileList = glob.glob(rawDataDir + "/*/ge/*.all.tsv.gz") 58 | with open(indexFile, "w") as w: 59 | for line in fileList: 60 | filepath = line.rstrip("\n") 61 | tempSplit = os.path.basename(filepath).split(".")[0].split("_ge_") 62 | dataset = tempSplit[0] 63 | tissue = tempSplit[1] 64 | w.write(f"{dataset}\t{tissue}\t{filepath}\n") 65 | 66 | # Generate commands to run merge.files.with.sorted.positions.py script, then tabix the results 67 | with open(outputCommandFile, "w") as w: 68 | for chrom in chrDict: 69 | temp = subprocess.run( 70 | ["mkdir", "-p", os.path.join(geDirectory, chrom)] 71 | ) 72 | for i in range(chrDict[chrom]): 73 | end = (i + 1) * 1000000 74 | start = (i * 1000000) + 1 75 | w.write( 76 | f"python3 {scriptPath} {indexFile} {chrom} {start} {end} {geDirectory}/{chrom}/all.EBI.ge.data.chr{chrom}.{start}-{end}.tsv.gz\n" 77 | ) 78 | for chrom in chrDict: 79 | for i in range(chrDict[chrom]): 80 | end = (i + 1) * 1000000 81 | start = (i * 1000000) + 1 82 | w.write( 83 | f"tabix -s 4 -b 5 -e 5 {geDirectory}/{chrom}/all.EBI.ge.data.chr{chrom}.{start}-{end}.tsv.gz\n" 84 | ) 85 | -------------------------------------------------------------------------------- /util/join-spot-cred-marginal-add-genenames.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import gzip 3 | import json 4 | import subprocess as sp 5 | import sys 6 | 7 | # Gene ID translation file. Example usage: 8 | # geneIDMap['CFH'] = 'ENSG00000000971.15' 9 | # geneIDMap['ENSG0000000097'] = 'CFH' 10 | geneIDMapFile = ( 11 | "/net/amd/amkwong/browseQTL/v2_data/data/gene.id.symbol.map.json.gz" 12 | ) 13 | with gzip.open(geneIDMapFile, "rt") as f: 14 | geneIDMap = json.load(f) 15 | 16 | parser = argparse.ArgumentParser( 17 | description="Join credible sets with marginal association" 18 | ) 19 | parser.add_argument( 20 | "-a", 21 | "--all", 22 | type=str, 23 | required=True, 24 | help="File containing all cis associations (must be tabixed)", 25 | ) 26 | parser.add_argument( 27 | "-c", 28 | "--cred", 29 | type=str, 30 | required=True, 31 | help="File containing credible set (must be tabixed)", 32 | ) 33 | parser.add_argument( 34 | "-o", 35 | "--out", 36 | type=str, 37 | required=True, 38 | help="Output file name (to be bgzipped)", 39 | ) 40 | parser.add_argument( 41 | "-r", "--region", type=str, required=True, help="Genomic region to query" 42 | ) 43 | parser.add_argument( 44 | "--tabix", 45 | type=str, 46 | required=False, 47 | default="tabix", 48 | help="Path to binary tabix", 49 | ) 50 | parser.add_argument( 51 | "--bgzip", 52 | type=str, 53 | required=False, 54 | default="bgzip", 55 | help="Path to binary bgzip", 56 | ) 57 | 58 | args = parser.parse_args() 59 | 60 | # cisf = "/net/amd/amkwong/browseQTL/v2_data/ebi_ge/1/all.EBI.ge.data.chr1.29000001-30000000.tsv.gz" 61 | # credf = "/net/amd/amkwong/browseQTL/v2_data/credible_sets/ge/chr1.ge.credible_set.tsv.gz" 62 | # outf = "/net/1000g/hmkang/data/spot/credible_sets/joined/ge/1/ge.credible_set.joinged.chr1.29000001-30000000.tsv.gz" 63 | # chrom = "1" 64 | # beg = 29000001 65 | # end = 30000000 66 | # tabix = "tabix" 67 | # bgzip = "bgzip" 68 | 69 | ## Load credible set per each megabase bin 70 | vid2trait2cred = {} # type: ignore 71 | creds = [] 72 | with sp.Popen( 73 | "{args.tabix} {args.cred} {args.region}".format(**locals()), 74 | shell=True, 75 | encoding="utf-8", 76 | stdout=sp.PIPE, 77 | ).stdout as fh: 78 | for line in fh: 79 | toks = line.rstrip().split("\t") 80 | ( 81 | dataset, 82 | tissue, 83 | trait, 84 | vid, 85 | vchr, 86 | vpos, 87 | vref, 88 | valt, 89 | cs_id, 90 | cs_index, 91 | region, 92 | pip, 93 | z, 94 | cs_min_r2, 95 | cs_avg_r2, 96 | cs_size, 97 | posterior_mean, 98 | posterior_sd, 99 | cs_log10bf, 100 | ) = toks 101 | ## manual change needed here for BLUEPRINT to fix inconsistencies between credible set and all cis 102 | if (dataset == "BLUEPRINT_SE") or (dataset == "BLUEPRINT_PE"): 103 | dataset = "BLUEPRINT" 104 | toks[0] = "BLUEPRINT" 105 | ## manual change needed for van_de_Bunt_2015 to fix inconsistencies between credible set and all cis 106 | # if ( dataset == "van_de_Bunt_2015" ): 107 | # dataset = "van_de_Bunt_2018" 108 | # toks[0] = "van_de_Bunt_2018" 109 | ## manual change needed for esophagus_gej to fix inconsistencies between credible set and all cis 110 | # if ( ( dataset == "GTEx" ) and ( tissue.startswith("esophagus_gej") or tissue.startswith("esophagusj") ) ): 111 | # tissue = "esophagus_gej" 112 | # toks[1] = "esophagus_gej" 113 | creds.append(toks) 114 | traitID = ":".join([dataset, tissue, trait]) 115 | if vid not in vid2trait2cred: 116 | vid2trait2cred[vid] = {} 117 | if traitID in vid2trait2cred[vid]: 118 | raise ValueError("Duplicate {vid} {traitID}".format(**locals())) 119 | # print("Register {vid} {traitID}".format(**locals()), file=sys.stderr) 120 | vid2trait2cred[vid][traitID] = len(creds) - 1 121 | 122 | ## Read all cis associations and identify the lines matching to the credible set 123 | vid2trait2cis = {} # type: ignore 124 | with sp.Popen( 125 | "{args.tabix} {args.all} {args.region}".format(**locals()), 126 | shell=True, 127 | encoding="utf-8", 128 | stdout=sp.PIPE, 129 | ).stdout as fh: 130 | for line in fh: 131 | toks = line.rstrip().split("\t") 132 | (dataset, tissue, trait, vchr, vpos, vref, valt, vid) = toks[0:8] 133 | traitID = ":".join([dataset, tissue, trait]) 134 | toks.append(geneIDMap.get(trait.split(".")[0], "Unknown_gene")) 135 | # FYI - order of toks : (dataset, tissue, trait, vchr, vpos, vref, valt, vid, ma_samples, maf, pvalue, beta, se, vtype, ac, an, r2, mol_trait_obj_id, gid, median_tpm, rsid) 136 | if (vid in vid2trait2cred) and (traitID in vid2trait2cred[vid]): 137 | if vid not in vid2trait2cis: 138 | vid2trait2cis[vid] = {} 139 | ## ignore the errors of seeing the sample SNP twice 140 | # if ( traitID in vid2trait2cis[vid] ): 141 | # print(vid2trait2cis[vid],file=sys.stderr) 142 | # print(toks,file=sys.stderr) 143 | # raise ValueError("Duplicate cis {vid} {traitID}".format(**locals())) 144 | vid2trait2cis[vid][traitID] = toks 145 | 146 | ## write joined 147 | with sp.Popen( 148 | "{args.bgzip} -c > {args.out}".format(**locals()), 149 | shell=True, 150 | encoding="utf-8", 151 | stdin=sp.PIPE, 152 | ).stdin as wh: 153 | for i in range(len(creds)): 154 | cred = creds[i] 155 | (dataset, tissue, trait, vid) = cred[0:4] 156 | traitID = ":".join([dataset, tissue, trait]) 157 | if (vid not in vid2trait2cis) or (traitID not in vid2trait2cis[vid]): 158 | print( 159 | "WARNING: Could not find match for {vid} and {traitID}".format( 160 | **locals() 161 | ), 162 | file=sys.stderr, 163 | ) 164 | continue 165 | cis = vid2trait2cis[vid][traitID] 166 | if ( 167 | (cred[0] != cis[0]) 168 | or (cred[1] != cis[1]) 169 | or (cred[2] != cis[2]) 170 | or (cred[3] != cis[7]) 171 | ): 172 | print(cred, file=sys.stderr) 173 | print(cis, file=sys.stderr) 174 | raise ValueError("ERROR: Incompatible lines") 175 | wh.write("\t".join(cred)) 176 | wh.write("\t") 177 | wh.write("\t".join(cis[8:])) 178 | wh.write("\n") 179 | -------------------------------------------------------------------------------- /util/merge.files.with.sorted.positions.py: -------------------------------------------------------------------------------- 1 | import subprocess as sp 2 | import sys 3 | 4 | 5 | def parse_a_line(s): 6 | if (len(s) == 0) or (s == "\n"): 7 | return ["DUMMY_VAR"] * posIdx + [1000000000] 8 | else: 9 | return s.rstrip().split("\t") 10 | 11 | 12 | ## assumption : Input file looks like this: 13 | ## [dataset_name] [tissue_name] [filename] 14 | 15 | indexf = sys.argv[1] 16 | chrom = sys.argv[2] 17 | beg = sys.argv[3] 18 | end = sys.argv[4] 19 | outf = sys.argv[5] 20 | posIdx = sys.argv[6] # 2 for EBI expression data, 3 for credible_set data 21 | 22 | 23 | # If no posIdx provided, default to 2 for EBI expression data file format 24 | if posIdx == None: 25 | posIdx = 2 26 | else: 27 | posIdx = int(posIdx) 28 | 29 | 30 | ## read index file 31 | index_list = [] 32 | with open(indexf, "rt", encoding="utf-8") as fh: 33 | for line in fh: 34 | (dataset, tissue, filename) = line.rstrip().split() 35 | index_list.append([dataset, tissue, filename]) 36 | 37 | 38 | ## open all files, and read the first line 39 | fhs = [] 40 | lines = [] 41 | poss = [] 42 | MAXPOS = 1000000000 43 | minpos = MAXPOS 44 | for i in range(len(index_list)): 45 | (dataset, tissue, filename) = index_list[i] 46 | ## you can change this part by using pysam 47 | fh = sp.Popen( 48 | "tabix {filename} {chrom}:{beg}-{end}".format(**locals()), 49 | shell=True, 50 | encoding="utf-8", 51 | stdout=sp.PIPE, 52 | ).stdout 53 | fhs.append(fh) 54 | toks = parse_a_line(fh.readline()) 55 | lines.append(toks) 56 | bp = int(toks[posIdx]) 57 | if bp < minpos: 58 | minpos = bp 59 | poss.append(bp) 60 | 61 | ## now process each position at a time 62 | with sp.Popen( 63 | "bgzip -c > {outf}".format(**locals()), 64 | shell=True, 65 | encoding="utf-8", 66 | stdin=sp.PIPE, 67 | ).stdin as wh: 68 | while minpos < MAXPOS: 69 | new_minpos = MAXPOS 70 | for i in range(len(index_list)): 71 | while poss[i] == minpos: ## then I need to print this 72 | ## print the stored line 73 | wh.write("%s\t%s\t" % (index_list[i][0], index_list[i][1])) 74 | wh.write("\t".join(lines[i])) 75 | wh.write("\n") 76 | ## parse the next line 77 | toks = parse_a_line(fhs[i].readline()) 78 | ## update lines and poss 79 | poss[i] = int(toks[posIdx]) 80 | lines[i] = toks 81 | if poss[i] < new_minpos: 82 | new_minpos = poss[i] 83 | minpos = new_minpos 84 | -------------------------------------------------------------------------------- /util/summarize.highest.pip.for.each.variant.sql.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import gzip 3 | import os 4 | import sqlite3 5 | import sys 6 | 7 | # Input arguments: 8 | # csdir: directory containing merged confidence interval data 9 | # Note: we will now use confidence interval data which has been joined with the raw QTL points data to add p-values 10 | # datatype: type of data ("ge", "txrev", etc.) - in case of mixed data type in the source directory 11 | # outfile: output sqlite3 database file - default name should be: 12 | # pip.best.variant.summary.sorted.indexed.sqlite3.db 13 | csdir = sys.argv[1] 14 | datatype = sys.argv[2] 15 | outfile = sys.argv[3] 16 | 17 | # Sorting function for file names that start with chr* 18 | # Usage: 19 | # y = sorted(x, key=chrnum) 20 | def chrnum(txt): 21 | additionalChr = {"x": 23, "y": 24, "mt": 25} 22 | chrom = os.path.basename(txt).split(".")[0].replace("chr", "") 23 | if chrom.lower() in additionalChr: 24 | chrom = additionalChr[chrom.lower()] 25 | return int(chrom) 26 | 27 | 28 | filelist = sorted( 29 | glob.glob(os.path.join(csdir, f"chr*.{datatype}.credible_set.tsv.gz")), 30 | key=chrnum, 31 | ) 32 | 33 | 34 | def parseline(line): 35 | try: 36 | ( 37 | study, 38 | tissue, 39 | phenotype_id, 40 | variant_id, 41 | chrom, 42 | pos, 43 | ref, 44 | alt, 45 | cs_id, 46 | cs_index, 47 | finemapped_region, 48 | pip, 49 | z, 50 | cs_min_r2, 51 | cs_avg_r2, 52 | cs_size, 53 | posterior_mean, 54 | posterior_sd, 55 | cs_log10bf, 56 | # additional data joined in from full QTL files 57 | ma_samples, 58 | maf, 59 | pvalue, 60 | beta, 61 | se, 62 | vtype, 63 | ac, 64 | an, 65 | r2, 66 | mol_trait_obj_id, 67 | gid, 68 | median_tpm, 69 | rsid, 70 | genesymbol, 71 | ) = line.rstrip("\n").split() 72 | return [ 73 | float(pip), 74 | study, 75 | tissue, 76 | phenotype_id, 77 | chrom, 78 | int(pos), 79 | ref, 80 | alt, 81 | cs_index, 82 | int(cs_size), 83 | # additional data joined in from full QTL files 84 | float(pvalue), 85 | ] 86 | except ValueError: 87 | return [ 88 | 0.0, 89 | "NO_STUDY", 90 | "NO_TISSUE", 91 | "NO_GENE", 92 | "NO_CHR", 93 | 0, 94 | "NO_REF", 95 | "NO_ALT", 96 | "L0", 97 | 0, 98 | 1.0, 99 | ] 100 | 101 | 102 | # We will create an SQLite3 database to hold searchable best variant database 103 | # Column values are: PIP (float), study_name, tissue_name, gene_ID, chromosome, position (int), ref, alt, cluster_name (L1 or L2), cluster_size (int) 104 | # We want to index by chromosome, position, study_name, tissue_name, and gene_ID 105 | 106 | conn = sqlite3.connect(outfile) 107 | with conn: 108 | cursor = conn.cursor() 109 | cursor.execute("DROP TABLE IF EXISTS sig") 110 | cursor.execute( 111 | "CREATE TABLE sig (pip REAL, study TEXT, tissue TEXT, gene_id TEXT, chrom TEXT, pos INTEGER, ref TEXT, alt TEXT, cs_index TEXT, cs_size INTEGER, pvalue REAL)" 112 | ) 113 | for infile in filelist: 114 | with gzip.open(infile, "rt") as f: 115 | # Parse the first line and use it as the current best data point 116 | line = f.readline() 117 | bestList = parseline(line) 118 | # Parse subsequent lines and compare PIPs, and if there is a tie, P-values 119 | for line in f: 120 | currentList = parseline(line) 121 | # If chr:pos:ref:alt is the same as the current point, compare and update best point if needed 122 | if bestList[4:8] == currentList[4:8]: 123 | # If the current PIP is higher than the best PIP value so far, then use the new point 124 | if bestList[0] < currentList[0]: 125 | bestList = currentList 126 | # If the PIP ties the best so far, compare P-values as a tie-break and choose the more significant one 127 | elif bestList[0] == currentList[0]: 128 | if bestList[10] > currentList[10]: 129 | bestList = currentList 130 | # If chr:pos:ref:alt is different, store the best value for the previous variant and set the new variant as the current one 131 | else: 132 | cursor.execute( 133 | "INSERT INTO sig VALUES (?,?,?,?,?,?,?,?,?,?,?)", 134 | tuple(bestList), 135 | ) 136 | bestList = currentList 137 | # End of file - store the final best variant 138 | cursor.execute( 139 | "INSERT INTO sig VALUES (?,?,?,?,?,?,?,?,?,?,?)", 140 | tuple(bestList), 141 | ) 142 | cursor.execute("CREATE INDEX idx_chrom_pos ON sig (chrom, pos)") 143 | cursor.execute("CREATE INDEX idx_study_tissue ON sig(study, tissue)") 144 | cursor.execute("CREATE INDEX idx_gene on sig(gene_id)") 145 | conn.commit() 146 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | chainWebpack: (config) => { 3 | config.module 4 | .rule('source-map-loader') 5 | .test(/\.js$/) 6 | .enforce('pre') 7 | .use('source-map-loader') 8 | .loader('source-map-loader') 9 | .end(); 10 | }, 11 | devServer: { 12 | proxy: { 13 | // Development settings: fetches data from the flask web server via a proxy 14 | // In production, apache Reverse Proxy handles this (we don't use the vue dev server to deploy, 15 | // because the Vue part is just static assets- they are built once and don't change) 16 | '/api': { 17 | target: 'http://localhost:5000', 18 | ws: false, 19 | changeOrigin: true, 20 | onProxyRes(proxyRes, req, res) { 21 | // When flask redirects internally, it includes its own host and port, and we get CORS 22 | // errors as suddenly the proxied request is going to a different "server". 23 | // We fix that by rewriting the location header manually. 24 | // TODO: This is an ugly hack related to limitations of webpack dev server; see: 25 | // https://github.com/chimurai/http-proxy-middleware/issues/140#issuecomment-275270924 26 | // (note this rewrite is crude and will break locally if flask is run on a port other than 5000) 27 | if ([301, 302].includes(proxyRes.statusCode) && proxyRes.headers.location) { 28 | let redirect = proxyRes.headers.location; 29 | redirect = redirect.replace('http://localhost:5000', '/api'); 30 | proxyRes.headers.location = redirect; 31 | } 32 | }, 33 | pathRewrite: { '^/api': '' }, 34 | }, 35 | }, 36 | }, 37 | configureWebpack: { 38 | // Ensure that (decent) source maps are used, even in development 39 | devtool: (process.env.NODE_ENV === 'development') ? 'eval-source-map' : 'source-map', 40 | }, 41 | }; 42 | --------------------------------------------------------------------------------