diff --git a/.dockerignore b/.dockerignore index 831e9ae1..fa449530 100644 --- a/.dockerignore +++ b/.dockerignore @@ -32,6 +32,7 @@ docker-compose.yml # volumes volumes/ +dump/ # other *.aed diff --git a/.eslintrc.yml b/.eslintrc.yml index 61121ddd..f719d15c 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -3,6 +3,8 @@ env: es2021: true extends: - standard + - "eslint:recommended" + - "prettier" parserOptions: ecmaVersion: 12 sourceType: module diff --git a/.github/workflows/docker_push.yml b/.github/workflows/docker_push.yml new file mode 100644 index 00000000..f22ef682 --- /dev/null +++ b/.github/workflows/docker_push.yml @@ -0,0 +1,36 @@ +name: Publish to Docker + +on: + release: + types: + - created + +jobs: + docker-push: + name: Create docker image + runs-on: ubuntu-latest + steps: + - name: Check out git repository + uses: actions/checkout@v4 + + - name: Get branch name + id: branch-name + uses: tj-actions/branch-names@v7 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: ./ + file: ./Dockerfile + push: true + tags: "clinicalgenomics/gens:${{ github.event.release.tag_name }}, clinicalgenomics/gens:latest" diff --git a/.github/workflows/keep_a_changelog.yml b/.github/workflows/keep_a_changelog.yml index 99e3bdc3..e13f9372 100644 --- a/.github/workflows/keep_a_changelog.yml +++ b/.github/workflows/keep_a_changelog.yml @@ -8,8 +8,8 @@ jobs: changelog: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: dangoslen/changelog-enforcer@v1.4.0 + - uses: actions/checkout@v4 + - uses: dangoslen/changelog-enforcer@v3 with: changeLogPath: 'CHANGELOG.md' - skipLabel: 'Skip-Changelog' + skipLabels: 'Skip-Changelog' diff --git a/.github/workflows/preproc_docker_push.yml b/.github/workflows/preproc_docker_push.yml new file mode 100644 index 00000000..6fddd14c --- /dev/null +++ b/.github/workflows/preproc_docker_push.yml @@ -0,0 +1,36 @@ +name: Publish preproc to Docker + +on: + release: + types: + - created + +jobs: + docker-preproc-push: + name: Create preproc Docker image + runs-on: ubuntu-latest + steps: + - name: Check out git repository + uses: actions/checkout@v4 + + - name: Get branch name + id: branch-name + uses: tj-actions/branch-names@v7 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: ./utils/ + file: ./utils/Dockerfile + push: true + tags: "clinicalgenomics/gens-preproc:${{ github.event.release.tag_name }}, clinicalgenomics/gens-preproc:latest" diff --git a/.github/workflows/preproc_stage_docker_push.yml b/.github/workflows/preproc_stage_docker_push.yml new file mode 100644 index 00000000..c316a3fd --- /dev/null +++ b/.github/workflows/preproc_stage_docker_push.yml @@ -0,0 +1,37 @@ +name: Publish preproc to Docker stage + +on: + pull_request: + branches: + - master + +jobs: + docker-preproc-stage-push: + name: Create preproc stage Docker image + runs-on: ubuntu-latest + steps: + - name: Check out git repository + uses: actions/checkout@v4 + + - name: Get branch name + id: branch-name + uses: tj-actions/branch-names@v7 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + if: steps.branch-name.outputs.is_default == 'false' + uses: docker/build-push-action@v5 + with: + context: ./utils/ + file: ./utils/Dockerfile + push: true + tags: "clinicalgenomics/gens-preproc-stage:${{steps.branch-name.outputs.current_branch}}, clinicalgenomics/gens-preproc-stage:latest" diff --git a/.github/workflows/stage_docker_push.yml b/.github/workflows/stage_docker_push.yml new file mode 100644 index 00000000..8ac55dc7 --- /dev/null +++ b/.github/workflows/stage_docker_push.yml @@ -0,0 +1,39 @@ +name: Publish to Docker stage + +on: + pull_request: + branches: + - master + push: + branches: + - master + +jobs: + docker-stage-push: + name: Create staging docker image + runs-on: ubuntu-latest + steps: + - name: Check out git repository + uses: actions/checkout@v4 + + - name: Get branch name + id: branch-name + uses: tj-actions/branch-names@v7 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: ./ + file: ./Dockerfile + push: true + tags: "clinicalgenomics/gens-stage:${{steps.branch-name.outputs.current_branch}}, clinicalgenomics/gens-stage:latest" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index b671fc09..168b8731 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/stale@v3 + - uses: actions/stale@v9 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-message: 'Stale issue message' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ba4008ae..aa109e59 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,17 +9,17 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7] - mongodb-version: ["3.6"] + python-version: [3.8] + mongodb-version: ["7"] steps: # Check out Scout code - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 # Set up python - name: Set up Python ${{ matrix.python-version}} - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version}} @@ -49,9 +49,9 @@ jobs: # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - run: npm ci diff --git a/.gitignore b/.gitignore index 21bbffc9..0e02d154 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ tags coverage *.aed +dump/ +frontend/ +docker-compose.override.yml # python *.pyc @@ -16,6 +19,7 @@ utils/hg38_annotations/ venv/ *.egg-info .eggs +.idea # node node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f6a246f..93c1fe4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,69 @@ This project adheres to [Semantic Versioning](http://semver.org/) About changelog [here](https://keepachangelog.com/en/1.0.0/) -## [x.x.x] +## 3.0.0 - Merging Solnas and Lunds changes +### Added + - `--force` flag to `gens loads sample` for overwriting any existing sample in case of key conflict. + - `--force` flag prints a warning to stderr when overwriting an existing sample. + - `gens delete sample` command + - Height ordering for variants track. +### Fixed + - Pan able to exit chrosome when using genome build 17 + - `--force` flag `update_one` call not being called properly + - Incorrect total sample count on home page. + - Some typos and documentation. + - Labels often not being visible on larger variants. + +### Merged for Solna from Lund 2.1.2 +#### Changed + - Changed cached method from simple to file system as it would be thread safe +#### Fixed + - Fixed cache issue that could result in chromosome information not being updated + - Fixed max arg error when searching for some genes + - Fixed bug that prevented updating annotation tracks +## [2.3 (Solna)] ### Added + - Link out to Scout: introduce config variable for base URL + - Link out to Scout: case links on home sample list + - Link out to Scout: click variant to open Scout page ### Changed + - Archive prod docker image with release tag name. Update action versions. ### Fixed - - Fixed bug that prevented updating annotation tracks + - Error image background static path + - GitHub action DockerHub push on release + +## [2.2 (Solna)] +### Added + - Document track processing and loading + - OAuth authentication +### Changed + - Use sample id instead of display name for variant retrieval + - Hide balanced variants + - Keyboard pan speed increased + - Don't shrink pan window when attemting to pan over start + +## [2.1.1b (Solna)] +### Added +### Changed + - Changes the main view's page title to be `sample_name` and adds `sample_name` and `case_id` to the header title + - Updated external images used in GitHub actions, including tj-actions/branch-names to v7 (fixes a security issue) + - Updated Python and MongoDB version used in tests workflow to 3.8 and 7 respectively + +## [2.1.1 (Solna)] +### Added +### Changed + - Updated flask and pinned connexion to v2 + - Updated node version of github action to 17.x +### Fixed + - Fixed annotation tracks being hidden behind other elements + - Use sample id as individual id to link out from Gens home sample list + - Some fixes from MHKC CG-Lund, e.g. status codes and a JSON error + - Removes some leading `/` that were breaking links + - Increased contrast of region selector + - Chromosome bands are displayed properly -## [2.1.2] +## [2.1.2 (Lund)] ### Added ### Changed - Changed cached method from simple to file system as it would be thread safe @@ -20,7 +75,7 @@ About changelog [here](https://keepachangelog.com/en/1.0.0/) - Fixed cache issue that could result in chromosome information not being updated - Fixed max arg error when searching for some genes -## [2.1.1] +## [2.1.1 (Lund)] ### Added ### Changed - Updated flask and pinned connexion to v2 @@ -75,6 +130,7 @@ About changelog [here](https://keepachangelog.com/en/1.0.0/) - Reinstated tooltips to display additional information on genetic elements ### Changed - Use popper for positioning tooltips + - Prettier for code formatting ### Fixed ## [1.2.0] diff --git a/Dockerfile b/Dockerfile index 733de435..ad543fff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ # BUILDER PYTHON # ################## -FROM python:3.8.1-slim as python-builder +FROM python:3.8.1-slim AS python-builder # Set build variables ENV PYTHONDONTWRITEBYTECODE=1 @@ -24,7 +24,7 @@ RUN apt-get update && \ # BUILDER NODE # ################ -FROM node:20.8.1-alpine as node-builder +FROM node:20.8.1-alpine AS node-builder WORKDIR /usr/src/app COPY package.json package-lock.json webpack.config.js gulpfile.js ./ COPY assets assets @@ -58,10 +58,29 @@ COPY utils utils # copy compiled web assetes COPY --from=node-builder /usr/src/app/build/css/error.min.css gens/static/css/ -COPY --from=node-builder /usr/src/app/build/css/home.min.css /usr/src/app/build/css/about.min.css gens/blueprints/home/static/ +COPY --from=node-builder /usr/src/app/build/css/home.min.css /usr/src/app/build/css/landing.min.css /usr/src/app/build/css/about.min.css gens/blueprints/home/static/ COPY --from=node-builder /usr/src/app/build/*/gens.min.* gens/blueprints/gens/static/ # make mountpoints and change ownership of app RUN mkdir -p /access /fs1/results /fs1/results_dev && chown -R app:app /home/app/app /access /fs1 /fs1/results_dev # Change the user to app USER app + +ENV GUNICORN_WORKERS=1 +ENV GUNICORN_THREADS=1 +ENV GUNICORN_BIND="0.0.0.0:5000" +ENV GUNICORN_TIMEOUT=400 + +CMD gunicorn \ + --workers=$GUNICORN_WORKERS \ + --bind=$GUNICORN_BIND \ + --threads=$GUNICORN_THREADS \ + --timeout=$GUNICORN_TIMEOUT \ + --chdir /home/app/app/ \ + --proxy-protocol \ + --forwarded-allow-ips="10.0.2.100,127.0.0.1" \ + --log-syslog \ + --access-logfile - \ + --error-logfile - \ + --log-level="debug" \ + gens.wsgi:app diff --git a/README.md b/README.md index 97bec1e6..433227e7 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ You also need to build the javacript and css files and put them into the directo # install build dependancies and build web assets. npm install && npm run build # copy built assets gens/static -cp -r build/{js,css} gens/static/ +cp build/css/error.min.css gens/static/css/; cp build/css/home.min.css build/css/about.min.css build/css/landing.min.css gens/blueprints/home/static/; cp build/*/gens.min.* gens/blueprints/gens/static/ ``` Start the application using: @@ -202,6 +202,42 @@ The **o** resolution is used only for the whole genome overview plot. The number We're using all SNPs in gnomAD with an total allele frequency > 5%, which in gnomAD 2.1 is approximately 7.5 million SNPs. +### Loading reference tracks + +Gens allows adding multiple tracks, most easily provided in one directory. As an illustration, here is how to format a UCSC DGV bb track for Gens display. + +Download the DGV bb track from [UCSC](https://genome.ucsc.edu/cgi-bin/hgTables?db=hg19&hgta_group=varRep&hgta_track=dgvPlus&hgta_table=dgvMerged&hgta_doSchema=describe+table+schema). +Convert bigBed to Bed, cut relevant columns and name them according to Gens standard. +``` +./bigBedToBed /home/proj/stage/rare-disease/gens-tracks/dgvMerged.bb dgvMerged.bed +cut -f1,2,3,4,9 dgvMerged.bed > dgvMerged.fivecol.bed +cat > header +Chromosome Start Stop Name Color +cat header dgvMerged.fivecol.bed > /home/proj/stage/rare-disease/gens-tracks/DGV_UCSC_2023-03-09.bed +``` + +``` +us +conda activate S_gens +gens load annotations -b 37 -f /home/proj/stage/rare-disease/gens-tracks +``` + +This should result in something like: +``` +[2023-12-15 14:45:06,959] INFO in app: Using default Gens configuration +[2023-12-15 14:45:06,959] INFO in db: Initialize db connection +[2023-12-15 14:45:07,111] INFO in load: Processing files +[2023-12-15 14:45:07,112] INFO in load: Processing /home/proj/stage/rare-disease/gens-tracks/Final_common_CNV_clusters_0.bed +[2023-12-15 14:45:07,144] INFO in load: Remove old entry in the database +[2023-12-15 14:45:07,230] INFO in load: Load annoatations in the database +[2023-12-15 14:45:07,309] INFO in load: Update height order +[2023-12-15 14:45:10,792] INFO in load: Processing /home/proj/stage/rare-disease/gens-tracks/DGV_UCSC_2023-03-09.bed +[2023-12-15 14:45:16,170] INFO in load: Remove old entry in the database +[2023-12-15 14:45:16,173] INFO in load: Load annoatations in the database +[2023-12-15 14:45:41,873] INFO in load: Update height order +Finished loading annotations ✔ +``` + ## Limitations - Currently no efforts have been made to make it work for non-human organisms. Chromosome names are currently hardcoded to 1-23,X,Y. diff --git a/assets/__mocks__/fileMock.js b/assets/__mocks__/fileMock.js index c6c394f7..ea1367c1 100644 --- a/assets/__mocks__/fileMock.js +++ b/assets/__mocks__/fileMock.js @@ -1,3 +1,3 @@ // __mocks__/fileMock.js -module.exports = 'test-file-stub'; \ No newline at end of file +module.exports = "test-file-stub"; diff --git a/assets/__mocks__/styleMock.js b/assets/__mocks__/styleMock.js index eb092ce4..d988e23b 100644 --- a/assets/__mocks__/styleMock.js +++ b/assets/__mocks__/styleMock.js @@ -1,3 +1,3 @@ // __mocks__/styleMock.js -module.exports = {}; \ No newline at end of file +module.exports = {}; diff --git a/assets/css/about.scss b/assets/css/about.scss index 28168050..249b0b8f 100644 --- a/assets/css/about.scss +++ b/assets/css/about.scss @@ -1,4 +1,4 @@ -@import 'defaults', 'navbar'; +@import "defaults", "navbar"; $tablet-width: 768px; $desktop-width: 1024px; @@ -15,96 +15,101 @@ $desktop-width: 1024px; } .content { - width: auto; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - + width: auto; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; } #logo { - img { - width: 500px; - } + img { + width: 500px; + } - .version { - font-style: italic; - } + .version { + font-style: italic; + } } .card-panel { - .row { - display: flex; - flex-wrap: wrap; - flex-direction: row; - justify-content: center; - } + .row { + display: flex; + flex-wrap: wrap; + flex-direction: row; + justify-content: center; + } + .card { + margin: 10px; + padding: 15px 20px 20px 20px; + border-radius: 3px; + box-shadow: + 0 2px 4px 0 rgba(0, 0, 0, 0.2), + 0 3px 10px 0 rgba(0, 0, 0, 0.19); + } + @include tablet { .card { - margin: 10px; - padding: 15px 20px 20px 20px; - border-radius: 3px; - box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 3px 10px 0 rgba(0, 0, 0, 0.19); - } - @include tablet { - .card { - max-width: 100%; - flex: 100%; - } + max-width: 100%; + flex: 100%; } + } } #loaded-db-data { + h2, + h3, + h4 { + margin: 0; + padding-top: 0; + padding-bottom: 5px; - h2, h3, h4 { - margin: 0; - padding-top: 0; - padding-bottom: 5px; - - .year { - font-size: 0.85em; - } + .year { + font-size: 0.85em; } + } - h2 { - font-size: 1.4em; - } + h2 { + font-size: 1.4em; + } - ul { - margin: 0; - padding-top: 0; - } - - .year { - font-weight: lighter; - font-style: italic; - } + ul { + margin: 0; + padding-top: 0; + } + + .year { + font-weight: lighter; + font-style: italic; + } } #config { - @extend #loaded-db-data; - - h3 { padding-top: 10px; } - h4 { - padding-top: 5px; - padding-bottom: 0; - } + @extend #loaded-db-data; - .color-group { - width: 150px; - padding-top: 5px; - margin-left: 20px; - display: flex; - justify-content: space-between; - align-items: center; - } - .title { margin: 0; } + h3 { + padding-top: 10px; + } + h4 { + padding-top: 5px; + padding-bottom: 0; + } - .color-box { - width: 30px; - height: 20px; - margin-left: 20px; - } + .color-group { + width: 150px; + padding-top: 5px; + margin-left: 20px; + display: flex; + justify-content: space-between; + align-items: center; + } + .title { + margin: 0; + } + .color-box { + width: 30px; + height: 20px; + margin-left: 20px; + } } diff --git a/assets/css/defaults.scss b/assets/css/defaults.scss index a92fe314..b5649759 100644 --- a/assets/css/defaults.scss +++ b/assets/css/defaults.scss @@ -1,17 +1,17 @@ - // Default colors +// Default colors $default-font-color: #202231; -$default-bg-color: #F7F9F9; +$default-bg-color: #f7f9f9; // font $default-font: Arial, Helvetica, sans-serif; $default-font-print: "Times New Roman", Times, serif; html { - font-family: $default-font; - color: $default-font-color; - background-color: $default-bg-color; + font-family: $default-font; + color: $default-font-color; + background-color: $default-bg-color; } body { - margin: 0; - padding: 0; + margin: 0; + padding: 0; } diff --git a/assets/css/error.scss b/assets/css/error.scss index 3d042fcb..bcfd1292 100644 --- a/assets/css/error.scss +++ b/assets/css/error.scss @@ -1,57 +1,57 @@ #broken-dna { - position: absolute; - transform: rotate(-80deg); - width: 800px; - top: -130px; - left: 30%; - z-index: -1; + position: absolute; + transform: rotate(-80deg); + width: 800px; + top: -130px; + left: 30%; + z-index: -1; } .content { - margin: auto; - max-width: 60%; - margin-top: 50px; - padding: 20px 15px; + margin: auto; + max-width: 60%; + margin-top: 50px; + padding: 20px 15px; - #error-code { - z-index: 1; - position: relative; - left: -150px; - top: 50px; - margin: 0; - padding: 0; - // font - font-size: 150px; - font-weight: bolder; - letter-spacing: 10px; - transform: rotate(-25deg); - color: #4d5663; - } + #error-code { + z-index: 1; + position: relative; + left: -150px; + top: 50px; + margin: 0; + padding: 0; + // font + font-size: 150px; + font-weight: bolder; + letter-spacing: 10px; + transform: rotate(-25deg); + color: #4d5663; + } - .headline { - font-size: 48px; - text-align: center; - font-style: italic; - margin-top: 0; - } + .headline { + font-size: 48px; + text-align: center; + font-style: italic; + margin-top: 0; + } - p { - text-align: center; - padding-top: 15px; - } + p { + text-align: center; + padding-top: 15px; + } - .error-msg-container { - position: absolute; - top: 500px; - width: 40%; - border-radius: 4px; - padding: 20px; - // center in parent - left: 50%; - transform: translateX(-50%); - } + .error-msg-container { + position: absolute; + top: 500px; + width: 40%; + border-radius: 4px; + padding: 20px; + // center in parent + left: 50%; + transform: translateX(-50%); + } } .bold { - font-weight: bold; + font-weight: bold; } diff --git a/assets/css/gens.scss b/assets/css/gens.scss index c73d45e7..4bc06373 100644 --- a/assets/css/gens.scss +++ b/assets/css/gens.scss @@ -1,20 +1,21 @@ -@import 'defaults', 'navbar'; +@import "defaults", "navbar"; // region marker $marker-color: rgba(#dcd16f, 0.3); // buttons -$btn-zoom-color: #8FBCBB; -$btn-navigate-color: #6DB2C5; - -$btn-submit-color: #6DB2C5; -html, body { - max-width: 100%; - min-height: 100%; - width: 100%; - overflow-x: hidden; - margin: 0; - display: grid; - grid-template-columns: auto; - grid-template-rows: auto; +$btn-zoom-color: #8fbcbb; +$btn-navigate-color: #6db2c5; + +$btn-submit-color: #6db2c5; +html, +body { + max-width: 100%; + min-height: 100%; + width: 100%; + overflow-x: hidden; + margin: 0; + display: grid; + grid-template-columns: auto; + grid-template-rows: auto; } .tooltip { @@ -37,554 +38,560 @@ html, body { } .tooltip[data-show] { - display: block + display: block; } -.tooltip[data-popper-placement^='top'] > .arrow { +.tooltip[data-popper-placement^="top"] > .arrow { bottom: -4px; } -.tooltip[data-popper-placement^='bottom'] > .arrow { +.tooltip[data-popper-placement^="bottom"] > .arrow { top: -4px; } -.tooltip[data-popper-placement^='left'] > .arrow { +.tooltip[data-popper-placement^="left"] > .arrow { right: -4px; } -.tooltip[data-popper-placement^='right'] > .arrow { +.tooltip[data-popper-placement^="right"] > .arrow { left: -4px; } #loading-div { - display:none; - position:absolute; - font-family:sans-serif; - font-size:9pt; - font-weight:bold; - color:#567; - text-align: center; - -moz-box-sizing: border-box; - -webkit-box-sizing: border-box; - box-sizing: border-box; - padding-top: 210px; - z-index: 10; - background-color: $default-bg-color; + display: none; + position: absolute; + font-family: sans-serif; + font-size: 9pt; + font-weight: bold; + color: #567; + text-align: center; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + box-sizing: border-box; + padding-top: 210px; + z-index: 10; + background-color: $default-bg-color; } .help-popup { - // dimensions and color - display: none; - transition: opacity 2s linear 0.5s; - color: $default-font-color; - background: $default-bg-color; - border: 1px solid $default-font-color; - width: 300px; - padding: 0px 10px 10px 10px; - // positioning - position: absolute; - top: 32px; - right: 25px; - z-index: 2; - // text properties - font-size: small; - text-align: left; - - h3, ul, p { - margin-bottom: 4px; - } + // dimensions and color + display: none; + transition: opacity 2s linear 0.5s; + color: $default-font-color; + background: $default-bg-color; + border: 1px solid $default-font-color; + width: 300px; + padding: 0px 10px 10px 10px; + // positioning + position: absolute; + top: 32px; + right: 25px; + z-index: 2; + // text properties + font-size: small; + text-align: left; + + h3, + ul, + p { + margin-bottom: 4px; + } - ul, p { - margin-top: 4px; - } + ul, + p { + margin-top: 4px; + } } .info:hover + .help-popup { - display: block; + display: block; } .header { - font-size: 22px; - padding: 15px 20px; - display: grid; - grid-template-columns: 30% 40% 30%; - grid-template-rows: auto; - grid-template-areas: 'header header header'; - - @media only screen { - font-family: $default-font; - background: $menubar-bg-color; - color: $menubar-font-color; - } - - @media only print { - font-family: $default-font-print; - background: $menubar-bg-color-print; - color: $menubar-font-color-print; + font-size: 22px; + padding: 15px 20px; + display: grid; + grid-template-columns: 30% 40% 30%; + grid-template-rows: auto; + grid-template-areas: "header header header"; + + @media only screen { + font-family: $default-font; + background: $menubar-bg-color; + color: $menubar-font-color; + } - } + @media only print { + font-family: $default-font-print; + background: $menubar-bg-color-print; + color: $menubar-font-color-print; + } - #left-group { - grid-row: 1; - grid-column: 1; - text-align: left; - } + #left-group { + grid-row: 1; + grid-column: 1; + text-align: left; + } - #center-group { - grid-row: 1; - grid-column: 2; - text-align: center; - } + #center-group { + grid-row: 1; + grid-column: 2; + text-align: center; + } - #right-group { - grid-row: 1; - grid-column: 3; - text-align: right; - } + #right-group { + grid-row: 1; + grid-column: 3; + text-align: right; + } - .header-icon { - background-size: contain; - display: inline-block; - width: 20px; - height: 20px; - filter: invert(88%) sepia(97%) saturate(3909%) hue-rotate(179deg) brightness(111%) contrast(105%); - } + .header-icon { + background-size: contain; + display: inline-block; + width: 20px; + height: 20px; + filter: invert(88%) sepia(97%) saturate(3909%) hue-rotate(179deg) + brightness(111%) contrast(105%); + } - .print-icon { - background-size: contain; - position: fixed; - top: 40%; - left: 50%; - width: 100px; - height: 100px; - filter: invert(70%); - visibility: hidden; - } + .print-icon { + background-size: contain; + position: fixed; + top: 40%; + left: 50%; + width: 100px; + height: 100px; + filter: invert(70%); + visibility: hidden; + } - .info-icon { - position: absolute; - } + .info-icon { + position: absolute; + } - .bold { - font-weight: bold; - } + .bold { + font-weight: bold; + } - #home-link { - text-decoration: none; - } + #home-link { + text-decoration: none; + } - #logo-container { - width: 50px; - height: 50px; - border-radius: 50%; - display: inline-block; - background: $menubar-font-color; - } + #logo-container { + width: 50px; + height: 50px; + border-radius: 50%; + display: inline-block; + background: $menubar-font-color; + } - .logo { - background: url(./svg/gens-logo-only.svg) no-repeat top left; - background-size: contain; - width: 35px; - height: 35px; - display: inherit; - // center in parent - position: relative; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - } + .logo { + background: url(/gens/static/svg/gens-logo-only.svg) no-repeat top left; + background-size: contain; + width: 35px; + height: 35px; + display: inherit; + // center in parent + position: relative; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } - .version { - font-size: 12px; - } + .version { + font-size: 12px; + } - .date { - font-size: 18px; - margin-left: 15px; - } + .date { + font-size: 18px; + margin-left: 15px; + } } #main-container { - grid-row: 1; - grid-column: 1; + grid-row: 1; + grid-column: 1; } #visualization-container { - height: min-content; - display: none; - grid-row: 1; - grid-column: 1; - + height: min-content; + display: none; + grid-row: 1; + grid-column: 1; } #button-container { - text-align: center; - grid-row: 7; - grid-column: 1; - margin-top: 5px; + text-align: center; + grid-row: 7; + grid-column: 1; + margin-top: 5px; - @media only print { - page-break-after: always; - } + @media only print { + page-break-after: always; + } - select { - margin-right: 15px; - } + select { + margin-right: 15px; + } - form { - margin-left: 15px; - display: inline; - } + form { + margin-left: 15px; + display: inline; + } + + form.error:disabled { + outline: 5px dotted red; + background-color: red; + } + + .button { + border: 0px; + box-shadow: 1px 2px 2px rgba(0, 0, 0, 0.3); + padding: 5px 15px; - form.error:disabled { - outline: 5px dotted red; - background-color: red; + &--zoom-in { + @extend .button; + background: $btn-zoom-color; + margin-left: 5px; + margin-right: 2px; } - .button { - border: 0px; - box-shadow: 1px 2px 2px rgba(0, 0, 0, 0.3); - padding: 5px 15px; - - &--zoom-in { - @extend .button; - background: $btn-zoom-color; - margin-left: 5px; - margin-right: 2px; - } - - &--zoom-out { - @extend .button; - background: $btn-zoom-color; - margin-right: 5px; - } - - &--pan { - @extend .button; - background: $btn-navigate-color; - - } - &--submit { - @extend .button; - background: $btn-submit-color; - } + &--zoom-out { + @extend .button; + background: $btn-zoom-color; + margin-right: 5px; } - .icon { - background-size: contain; - display: inline-block; - width: 15px; - height: 15px; + &--pan { + @extend .button; + background: $btn-navigate-color; + } + &--submit { + @extend .button; + background: $btn-submit-color; } + } + + .icon { + background-size: contain; + display: inline-block; + width: 15px; + height: 15px; + } } #chromosome-title { - display: grid; - grid-row: 1; - grid-column: 1; + display: grid; + grid-row: 1; + grid-column: 1; } #cytogenetic-ideogram { - display: grid; - grid-row: 2; - grid-column: 1; - - .marker { - border-style: solid; - border-color: red; - border-left-width: 2px; - border-right-width: 2px; - } + display: grid; + grid-row: 2; + grid-column: 1; + + .marker { + border-style: solid; + border-color: red; + border-left-width: 2px; + border-right-width: 2px; + } } #interactive-container { - display: grid; - grid-row: 3; - grid-column: 1; + display: grid; + grid-row: 3; + grid-column: 1; - #interactive-static { - z-index: 1; - pointer-events: none; - grid-row: 4; - grid-column: 1; - } + #interactive-static { + z-index: 1; + pointer-events: none; + grid-row: 4; + grid-column: 1; + } - #interactive-content { - z-index: 1; - grid-row: 4; - grid-column: 1; - } + #interactive-content { + z-index: 1; + grid-row: 4; + grid-column: 1; + } - #interactive-marker { - position: absolute; - z-index: 1; - } + #interactive-marker { + position: absolute; + z-index: 1; + } } #interactive-options { - text-align: center; - width: 100%; - height: 18px; + text-align: center; + width: 100%; + height: 18px; } - #source-list { - visibility: hidden; - height: 100%; - height: 21px; - width: 200px; + visibility: hidden; + height: 100%; + height: 21px; + width: 200px; } #times { - background: url(./svg/times-solid.svg) no-repeat top left; + background: url(./svg/times-solid.svg) no-repeat top left; } #trash-alt { - background: url(./svg/trash-alt-regular.svg) no-repeat top left; + background: url(./svg/trash-alt-regular.svg) no-repeat top left; } .marker { - background-color: $marker-color; - border: dashed 1px #7c7c7c; - border-top-width: 0; - border-bottom-width: 0; - pointer-events:none; - position: relative; + background-color: $marker-color; + border: dashed 1px #7c7c7c; + border-top-width: 0; + border-bottom-width: 0; + pointer-events: none; + position: relative; } -.info-container[data-state=nodata] { - height: 0; - border: none; +.info-container[data-state="nodata"] { + height: 0; + border: none; } -.info-container[data-state=collapsed] { - padding-right: 0; - border: 1px solid gray; - overflow-y: hidden; +.info-container[data-state="collapsed"] { + padding-right: 0; + border: 1px solid gray; + overflow-y: hidden; } -.info-container[data-state=expanded] { - padding-right: 6px; - border: 1px solid gray; +.info-container[data-state="expanded"] { + padding-right: 6px; + border: 1px solid gray; } -.track-container[data-state=data] { - visibility: visible; +.track-container[data-state="data"] { + visibility: visible; } -.track-container[data-state=nodata] { - visibility: collapse; - height: 0; +.track-container[data-state="nodata"] { + visibility: collapse; + height: 0; } #variant-container { - grid-row: 4; + grid-row: 4; } #transcript-container { - grid-row: 5; + grid-row: 5; } #annotation-container { - padding-top: 10px; - grid-row: 6; + padding-top: 10px; + grid-row: 6; } .track-xlabel { - // position - position: relative; - top: 25px; - width: 80px; - z-index: 10; - margin: 0; - padding: 0; - // text properties - transform: rotate(270deg); - text-align: center; - font-size: x-small; - font-weight: bold; + // position + position: relative; + top: 25px; + width: 80px; + z-index: 10; + margin: 0; + padding: 0; + // text properties + transform: rotate(270deg); + text-align: center; + font-size: x-small; + font-weight: bold; } .info-container { - display: grid; + display: grid; + grid-column: 1; + overflow-y: auto; + overflow-x: hidden; + height: 100px; + + .info-canvas { + top: 0; + left: 0; + z-index: 0; + grid-row: 1; grid-column: 1; - overflow-y: auto; - overflow-x: hidden; - height: 100px; - - .info-canvas { - top: 0; - left: 0; - z-index: 0; - grid-row: 1; - grid-column: 1; - } + } - .info-titles { - top: 0; - left: 0; - z-index: 1; - grid-row: 1; - grid-column: 1; - position: relative; - } - .offscreen { - visibility: hidden; - } + .info-titles { + top: 0; + left: 0; + z-index: 1; + grid-row: 1; + grid-column: 1; + position: relative; + } + .offscreen { + visibility: hidden; + } } .info-container::-webkit-scrollbar { - width: 6px; - border-left: 1px solid black; + width: 6px; + border-left: 1px solid black; } .info-container::-webkit-scrollbar-track { - border-radius: 10px; + border-radius: 10px; } .info-container::-webkit-scrollbar-thumb { - background: rgba(50, 50, 50, 0.5); + background: rgba(50, 50, 50, 0.5); } - .info { - background: url(./svg/info-circle-fill.svg) no-repeat top left; + background: url(./svg/info-circle-fill.svg) no-repeat top left; } .print { - background: url(./svg/printer-fill.svg) no-repeat top left; + background: url(./svg/printer-fill.svg) no-repeat top left; } .permalink { - background: url(./svg/link-45deg.svg) no-repeat top left; + background: url(./svg/link-45deg.svg) no-repeat top left; } .arrow-left { - background: url(./svg/arrow-left.svg) no-repeat top left; + background: url(./svg/arrow-left.svg) no-repeat top left; } .arrow-right { - background: url(./svg/arrow-right.svg) no-repeat top left; + background: url(./svg/arrow-right.svg) no-repeat top left; } .search-plus { - background: url(./svg/zoom-in.svg) no-repeat top left; + background: url(./svg/zoom-in.svg) no-repeat top left; } .search-minus { - background: url(./svg/zoom-out.svg) no-repeat top left; + background: url(./svg/zoom-out.svg) no-repeat top left; } .loading-view { - // pos - position: absolute; - z-index: 99; - width: 100%; - height: 100%; - margin: 0; - // style - $black: #000; - background-color: $default-bg-color; - - .loading-container { - position: relative; - top: 30%; - margin: auto; - display: flex; - width: 190px; - padding: 10px; - justify-content: center; - align-items: center; - } - .message { - font: italic 1.2em sans-serif; - padding-right: 12px; - color: $default-font-color; - flex: 1; - } - .spinner { - border-top: 3px solid rgba($menubar-bg-color, 0.5); - border-right: 3px solid transparent; - border-radius: 50%; - width: 30px; - height: 30px; - animation: spin .8s linear infinite; - } + // pos + position: absolute; + z-index: 99; + width: 100%; + height: 100%; + margin: 0; + // style + $black: #000; + background-color: $default-bg-color; + + .loading-container { + position: relative; + top: 30%; + margin: auto; + display: flex; + width: 190px; + padding: 10px; + justify-content: center; + align-items: center; + } + .message { + font: italic 1.2em sans-serif; + padding-right: 12px; + color: $default-font-color; + flex: 1; + } + .spinner { + border-top: 3px solid rgba($menubar-bg-color, 0.5); + border-right: 3px solid transparent; + border-radius: 50%; + width: 30px; + height: 30px; + animation: spin 0.8s linear infinite; + } } @keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } } .content { - font-family: sans-serif; - padding-left: 10px; - h1 { - margin: 2px; - font-size: 1.5em; - } + font-family: sans-serif; + padding-left: 10px; + h1 { + margin: 2px; + font-size: 1.5em; + } - p { margin: 2px 2px 10px 0;} + p { + margin: 2px 2px 10px 0; + } - .version { font-weight: bold;} + .version { + font-weight: bold; + } } - #overview-container { - display: grid; - grid-row: 8; + display: grid; + grid-row: 8; + grid-column: 1; + + #overview-static { + top: 0; + left: 0; + z-index: 0; + grid-row: 1; grid-column: 1; + } - #overview-static { - top: 0; - left: 0; - z-index: 0; - grid-row: 1; - grid-column: 1; - } - - #staticCanvas { - top: 0; - left: 0; - z-index: 0; - pointer-events: none; - grid-row: 1; - grid-column: 1; - } + #staticCanvas { + top: 0; + left: 0; + z-index: 0; + pointer-events: none; + grid-row: 1; + grid-column: 1; + } - #dataCanvas { - top: 0; - left: 0; - z-index: -1; - grid-row: 1; - grid-column: 1; - } + #dataCanvas { + top: 0; + left: 0; + z-index: -1; + grid-row: 1; + grid-column: 1; + } - #drawCanvas { - top: 0; - left: 0; - visibility: hidden; - } + #drawCanvas { + top: 0; + left: 0; + visibility: hidden; + } - #staticOverviewCanvas { - top: 0; - left: 0; - z-index: 0; - visibility: hidden; - } + #staticOverviewCanvas { + top: 0; + left: 0; + z-index: 0; + visibility: hidden; + } - #drawOverviewCanvas { - top: 0; - left: 0; - visibility: hidden; - } + #drawOverviewCanvas { + top: 0; + left: 0; + visibility: hidden; + } - #overview-marker { - position:relative; - } + #overview-marker { + position: relative; + } - .marker { - border-color: darkred; - } + .marker { + border-color: darkred; + } } diff --git a/assets/css/home.scss b/assets/css/home.scss index a50b83e6..277cd066 100644 --- a/assets/css/home.scss +++ b/assets/css/home.scss @@ -3,115 +3,123 @@ $table-border-color: #ddd; $selected-color: adjust-color($menubar-bg-color, $lightness: +30%); - -.align-right { text-align: right; } +.align-right { + text-align: right; +} .content { - width: auto; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - - .display-info { - margin-bottom: 4px; + width: auto; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + .display-info { + margin-bottom: 4px; + } + + table { + border-collapse: collapse; + tr { + border: 1px solid $table-border-color; + } + td { + padding: 8px 12px; + border: 1px solid $table-border-color; } - table { - border-collapse: collapse; - tr { - border: 1px solid $table-border-color; - } - td { - padding: 8px 12px; - border: 1px solid $table-border-color; - } + thead { + background-color: $menubar-bg-color; + color: $menubar-font-color; + font-weight: bold; + } - thead { - background-color: $menubar-bg-color; - color: $menubar-font-color; - font-weight: bold; + tbody { + // hover and coloring of table rows + tr:nth-child(even) { + background-color: #f2f2f2; } - - tbody { - // hover and coloring of table rows - tr:nth-child(even) { background-color: #f2f2f2;} - tr:hover { background-color: $table-border-color;} - // other styling + tr:hover { + background-color: $table-border-color; } + // other styling } + } } .pagination { - float: right; - margin-top: 10px; - padding-bottom: 50px; - - - ul { - margin: 0; - padding: 0; - list-style: none; - } - - li:hover { background-color: $table-border-color;} - - li { - float: left; - border-style: solid solid solid hidden; - border-width: 1px; - border-color: grey; - } - - li:first-child { - border-style: solid; - border-top-left-radius: 5px; - border-bottom-left-radius: 5px; - } - - li:last-child { - border-style: solid solid solid hidden; - border-top-right-radius: 5px; - border-bottom-right-radius: 5px; - } - - a { - display: block; - padding: 8px; - text-decoration: none; - color: $default-font-color; - } + float: right; + margin-top: 10px; + padding-bottom: 50px; + + ul { + margin: 0; + padding: 0; + list-style: none; + } + + li:hover { + background-color: $table-border-color; + } + + li { + float: left; + border-style: solid solid solid hidden; + border-width: 1px; + border-color: grey; + } + + li:first-child { + border-style: solid; + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + } + + li:last-child { + border-style: solid solid solid hidden; + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; + } + + a { + display: block; + padding: 8px; + text-decoration: none; + color: $default-font-color; + } } .disabled { - pointer-events: none; - a { - color: dimgray; - } + pointer-events: none; + a { + color: dimgray; + } } .selected { - background-color: $selected-color; + background-color: $selected-color; - a { - color: $menubar-font-color; - } + a { + color: $menubar-font-color; + } } // icons .icon { - display: inline-block; - width: 15px; - height: 15px; - transform: scale(1.4); + display: inline-block; + width: 15px; + height: 15px; + transform: scale(1.4); } -.icon-color-red {filter: invert(27%) sepia(51%) saturate(2878%) hue-rotate(346deg) brightness(104%) contrast(97%);} +.icon-color-red { + filter: invert(27%) sepia(51%) saturate(2878%) hue-rotate(346deg) + brightness(104%) contrast(97%); +} .icon-check { - background: url(./svg/check.svg) no-repeat top left; + background: url(./svg/check.svg) no-repeat top left; } .icon-x { - background: url(./svg/x.svg) no-repeat top left; + background: url(./svg/x.svg) no-repeat top left; } - diff --git a/assets/css/landing.scss b/assets/css/landing.scss new file mode 100644 index 00000000..e6be13b8 --- /dev/null +++ b/assets/css/landing.scss @@ -0,0 +1,83 @@ +@import "defaults", "navbar"; + +$table-border-color: #ddd; +$selected-color: adjust-color($menubar-bg-color, $lightness: +30%); + +.align-right { + text-align: right; +} +.content { + width: auto; + display: flex; + flex-direction: column; + justify-content: left; + align-items: center; + + .display-info { + margin-bottom: 4px; + } + + form { + float: right; + margin-top: 10px; + padding-bottom: 50px; + } + ul { + margin: 0; + padding: 0; + list-style: none; + } + + li { + float: left; + border-style: solid; + border-radius: 5px; + border-width: 1px; + border-color: grey; + } + + a { + padding: 8px; + } + + p { + width: 600px; + } + + input { + font-family: $default-font; + font-size: 1.5em; + } +} + +.btn { + font-family: $default-font; + font-size: 1.5em; + border-radius: 3px; + color: $menubar-font-color; + background-color: $menubar-bg-color; + box-shadow: + 0 2px 4px 0 rgba(0, 0, 0, 0.2), + 0 3px 10px 0 rgba(0, 0, 0, 0.19); +} + +.mainlogo { + img { + width: 500px; + } +} + +.disabled { + pointer-events: none; + a { + color: dimgray; + } +} + +.selected { + background-color: $selected-color; + + a { + color: $menubar-font-color; + } +} diff --git a/assets/css/navbar.scss b/assets/css/navbar.scss index f94f7450..c56d257b 100644 --- a/assets/css/navbar.scss +++ b/assets/css/navbar.scss @@ -1,77 +1,85 @@ @use "sass:color"; - // Define menuebar colors -$menubar-font-color: #F4FAFF; -$menubar-bg-color: #4C6D94; +// Define menuebar colors +$menubar-font-color: #f4faff; +$menubar-bg-color: #4c6d94; $menubar-bg-color-dark: adjust-color($menubar-bg-color, $lightness: -10%); $menubar-bg-color-print: darkgrey; $menubar-font-color-print: white; // size .navbar { - font-family: $default-font; - font-size: 1.5em; - background: $menubar-bg-color; - color: $menubar-font-color; - // alignment of items in navbar - display: flex; - align-items: center; - padding-left: 10px; - // drop shadow - box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 3px 10px 0 rgba(0, 0, 0, 0.19); + font-family: $default-font; + font-size: 1.5em; + background: $menubar-bg-color; + color: $menubar-font-color; + // alignment of items in navbar + display: flex; + align-items: center; + padding-left: 10px; + // drop shadow + box-shadow: + 0 2px 4px 0 rgba(0, 0, 0, 0.2), + 0 3px 10px 0 rgba(0, 0, 0, 0.19); - #home-link { - text-decoration: none; - } + #home-link { + text-decoration: none; + } - .logo-container { - width: 50px; - height: 50px; - border-radius: 50%; - display: inline-block; - margin: 5px 0; - background: $menubar-font-color; - } + .logo-container { + width: 50px; + height: 50px; + border-radius: 50%; + display: inline-block; + margin: 5px 0; + background: $menubar-font-color; + } - .active { background-color: $menubar-bg-color-dark} + .active { + background-color: $menubar-bg-color-dark; + } - .version { - font-size: 0.6em; - padding: 0 25px 0 8px; - } + .version { + font-size: 0.6em; + padding: 0 25px 0 8px; + } - .logo { - background: url(./svg/gens-logo-only.svg) no-repeat top left; - background-size: contain; - width: 35px; - height: 35px; - display: inherit; - // center in parent - position: relative; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - } + .logo { + background: url(/gens/static/svg/gens-logo-only.svg) no-repeat top left; + background-size: contain; + width: 35px; + height: 35px; + display: inherit; + // center in parent + position: relative; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } - ul { - list-style-type: none; - margin: 0; - padding: 0; - overflow: hidden; - } - - li { - float: left; - - a { - display: block; - color: inherit; - text-align: center; - padding: 16px 18px; - text-decoration: none; - } + ul { + list-style-type: none; + margin: 0; + padding: 0; + overflow: hidden; + } + + li { + float: left; + + a { + display: block; + color: inherit; + text-align: center; + padding: 16px 18px; + text-decoration: none; } - a:hover { - background-color: $menubar-bg-color-dark + .logout { + float: right; } + } + + a:hover { + background-color: $menubar-bg-color-dark; + } } diff --git a/assets/js/draw.js b/assets/js/draw.js index 4a16ee07..c871ef38 100644 --- a/assets/js/draw.js +++ b/assets/js/draw.js @@ -1,4 +1,16 @@ // entrypoint for drawing functions -export { drawVerticalTicks, createGraph, drawGraphLines } from './draw/graph.js' -export { drawRotatedText, drawPoints, drawText, drawLine, drawRect, drawArrow, drawWaveLine } from './draw/shapes.js' +export { + drawVerticalTicks, + createGraph, + drawGraphLines, +} from "./draw/graph.js"; +export { + drawRotatedText, + drawPoints, + drawText, + drawLine, + drawRect, + drawArrow, + drawWaveLine, +} from "./draw/shapes.js"; diff --git a/assets/js/draw/graph.js b/assets/js/draw/graph.js index aac694f9..07d8871e 100644 --- a/assets/js/draw/graph.js +++ b/assets/js/draw/graph.js @@ -1,39 +1,53 @@ // graph related objects -import { drawRect, drawLine, drawRotatedText, drawText } from './shapes.js' +import { drawRect, drawLine, drawRotatedText, drawText } from "./shapes.js"; // Draws vertical tick marks for selected values between // xStart and xEnd with step length. // The amplitude scales the values to drawing size -export function drawVerticalTicks ({ - ctx, renderX, y, xStart, xEnd, - xoStart, xoEnd, - width, yMargin, titleColor +export function drawVerticalTicks({ + ctx, + renderX, + y, + xStart, + xEnd, + xoStart, + xoEnd, + width, + yMargin, + titleColor, }) { - const lineThickness = 1 - const lineWidth = 5 - const regionSize = xEnd - xStart - const scale = width / regionSize - const maxNumTicks = 30 + const lineThickness = 1; + const lineWidth = 5; + const regionSize = xEnd - xStart; + const scale = width / regionSize; + const maxNumTicks = 30; // Create a step size which is an even power of ten (10, 100, 1000 etc) - let stepLength = 10 ** Math.floor(Math.log10(regionSize) - 1) + let stepLength = 10 ** Math.floor(Math.log10(regionSize) - 1); // Change to "half-steps" (50, 500, 5000) if too many ticks if (regionSize / stepLength > maxNumTicks) { - stepLength *= 5 + stepLength *= 5; } // Get starting position for the first tick - const xFirstTick = Math.ceil(xoStart / stepLength) * stepLength + const xFirstTick = Math.ceil(xoStart / stepLength) * stepLength; // Draw the ticks for (let step = xFirstTick; step <= xoEnd; step += stepLength) { - const xStep = Math.round(scale * (step - xoStart)) - const value = numberWithCommas(step.toFixed(0)) + const xStep = Math.round(scale * (step - xoStart)); + const value = numberWithCommas(step.toFixed(0)); // Draw text and ticks only for the leftmost box - drawRotatedText(ctx, value, 10, renderX + xStep + 8, - y - value.length - 3 * yMargin, -Math.PI / 4, titleColor) + drawRotatedText( + ctx, + value, + 10, + renderX + xStep + 8, + y - value.length - 3 * yMargin, + -Math.PI / 4, + titleColor, + ); // Draw tick line drawLine({ @@ -43,52 +57,95 @@ export function drawVerticalTicks ({ x2: renderX + xStep, y2: y, lineWidth: lineThickness, - color: '#777' - }) + color: "#777", + }); } } // Draws horizontal lines for selected values between // yStart and yEnd with step length. // The amplitude scales the values to drawing size -export function drawGraphLines ({ ctx, x, y, yStart, yEnd, stepLength, yMargin, width, height }) { - const ampl = (height - 2 * yMargin) / (yStart - yEnd) // Amplitude for scaling y-axis to fill whole height - const lineThickness = 1 +export function drawGraphLines({ + ctx, + x, + y, + yStart, + yEnd, + stepLength, + yMargin, + width, + height, +}) { + const ampl = (height - 2 * yMargin) / (yStart - yEnd); // Amplitude for scaling y-axis to fill whole height + const lineThickness = 1; for (let step = yStart; step >= yEnd; step -= stepLength) { // Draw horizontal line - const yPos = y + yMargin + (yStart - step) * ampl + const yPos = y + yMargin + (yStart - step) * ampl; drawLine({ ctx, x, y: yPos, x2: x + width - 2 * lineThickness, y2: yPos, - color: '#e5e5e5' - }) + color: "#e5e5e5", + }); } } // Creates a graph for one chromosome data type -export function createGraph (ctx, x, y, width, height, yMargin, yStart, - yEnd, step, addTicks, color = 'black', open) { +export function createGraph( + ctx, + x, + y, + width, + height, + yMargin, + yStart, + yEnd, + step, + addTicks, + color = "black", + open, +) { // Draw tick marks if (addTicks) { - drawTicks(ctx, x, y + yMargin, yStart, yEnd, step, yMargin, width, - height, color) + drawTicks( + ctx, + x, + y + yMargin, + yStart, + yEnd, + step, + yMargin, + width, + height, + color, + ); } // Draw surrounding coordinate box - drawRect({ ctx, x, y, width, height, lineWidth: 1, color, open }) + drawRect({ ctx, x, y, width, height, lineWidth: 1, color, open }); } // Draws tick marks for selected values between // yStart and yEnd with step length. // The amplitude scales the values to drawing size -function drawTicks (ctx, x, y, yStart, yEnd, stepLength, yMargin, width, height, color = 'black') { - const ampl = (height - 2 * yMargin) / (yStart - yEnd) // Amplitude for scaling y-axis to fill whole height - const lineThickness = 2 - const lineWidth = 4 +function drawTicks( + ctx, + x, + y, + yStart, + yEnd, + stepLength, + yMargin, + width, + height, + color = "black", +) { + const ampl = (height - 2 * yMargin) / (yStart - yEnd); // Amplitude for scaling y-axis to fill whole height + const lineThickness = 2; + const lineWidth = 4; for (let step = yStart; step >= yEnd; step -= stepLength) { drawText({ @@ -97,8 +154,8 @@ function drawTicks (ctx, x, y, yStart, yEnd, stepLength, yMargin, width, height, y: y + (yStart - step) * ampl + 2.2, text: step.toFixed(1), textSize: 10, - align: 'right' - }) + align: "right", + }); // Draw tick line drawLine({ @@ -108,12 +165,12 @@ function drawTicks (ctx, x, y, yStart, yEnd, stepLength, yMargin, width, height, x2: x, y2: y + (yStart - step) * ampl, lineWidth: lineThickness, - color - }) + color, + }); } } // Makes large numbers more readable with commas -function numberWithCommas (x) { - return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') +function numberWithCommas(x) { + return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); } diff --git a/assets/js/draw/shapes.js b/assets/js/draw/shapes.js index 2448d84e..59c26643 100644 --- a/assets/js/draw/shapes.js +++ b/assets/js/draw/shapes.js @@ -1,145 +1,187 @@ // draw basic objects and shapes // Draw data points -export function drawPoints ({ ctx, data, color = 'black' }) { - ctx.fillStyle = '#000' +export function drawPoints({ ctx, data, color = "black" }) { + ctx.fillStyle = "#000"; for (let i = 0; i < data.length; i += 2) { - if (data[i + 1] > 0) { // FIXME: Why are some values < 0? + if (data[i + 1] > 0) { + // FIXME: Why are some values < 0? ctx.fillRect( data[i], // x data[i + 1], // y 2, // width - 2 // height - ) + 2, // height + ); } } } // Draw 90 degree rotated text -export function drawRotatedText (ctx, text, textSize = '', posx, posy, rot, color = 'black') { - ctx.save() - ctx.fillStyle = color - ctx.font = ''.concat(textSize, 'px Arial') - ctx.translate(posx, posy) // Position for text - ctx.rotate(rot) // Rotate rot degrees - ctx.textAlign = 'center' - ctx.fillText(text, 0, 9) - ctx.restore() +export function drawRotatedText( + ctx, + text, + textSize = "", + posx, + posy, + rot, + color = "black", +) { + ctx.save(); + ctx.fillStyle = color; + ctx.font = "".concat(textSize, "px Arial"); + ctx.translate(posx, posy); // Position for text + ctx.rotate(rot); // Rotate rot degrees + ctx.textAlign = "center"; + ctx.fillText(text, 0, 9); + ctx.restore(); } // Draw aligned text at (x, y) -export function drawText ({ ctx, x, y, text, fontProp = '', align = 'center' }) { - ctx.save() - ctx.font = ''.concat(fontProp, 'px Arial') - ctx.textAlign = align - ctx.textBaseline = 'middle' - ctx.fillStyle = 'black' - ctx.fillText(text, x, y) - const textBbox = ctx.measureText(text) - ctx.restore() +export function drawText({ ctx, x, y, text, fontProp = "", align = "center" }) { + ctx.save(); + ctx.font = "".concat(fontProp, "px Arial"); + ctx.textAlign = align; + ctx.textBaseline = "middle"; + ctx.fillStyle = "black"; + ctx.fillText(text, x, y); + const textBbox = ctx.measureText(text); + ctx.restore(); return { x: x - textBbox.width / 2, y: y - textBbox.actualBoundingBoxAscent, width: textBbox.width, - height: textBbox.actualBoundingBoxAscent + textBbox.actualBoundingBoxDescent - } + height: + textBbox.actualBoundingBoxAscent + textBbox.actualBoundingBoxDescent, + }; } // Draws a line between point (x, y) and (x2, y2) -export function drawLine ({ ctx, x, y, x2, y2, lineWidth = 1, color = 'black' }) { +export function drawLine({ + ctx, + x, + y, + x2, + y2, + lineWidth = 1, + color = "black", +}) { // transpose coordinates .5 px to become sharper - x = Math.floor(x) + 0.5 - x2 = Math.floor(x2) + 0.5 - y = Math.floor(y) + 0.5 - y2 = Math.floor(y2) + 0.5 + x = Math.floor(x) + 0.5; + x2 = Math.floor(x2) + 0.5; + y = Math.floor(y) + 0.5; + y2 = Math.floor(y2) + 0.5; // draw path - ctx.strokeStyle = color - ctx.lineWidth = lineWidth - ctx.beginPath() - ctx.moveTo(x, y) - ctx.lineTo(x2, y2) - ctx.stroke() - ctx.restore() + ctx.strokeStyle = color; + ctx.lineWidth = lineWidth; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x2, y2); + ctx.stroke(); + ctx.restore(); } // Draws a box from top left corner with a top and bottom margin -export function drawRect ({ - ctx, x, y, width, height, lineWidth, color = null, - fillColor = null, open = false, debug = false +export function drawRect({ + ctx, + x, + y, + width, + height, + lineWidth, + color = null, + fillColor = null, + open = false, + debug = false, }) { - x = Math.floor(x) + 0.5 - y = Math.floor(y) + 0.5 - width = Math.floor(width) + x = Math.floor(x) + 0.5; + y = Math.floor(y) + 0.5; + width = Math.floor(width); - if (color !== null) ctx.strokeStyle = color - ctx.lineWidth = lineWidth + if (color !== null) ctx.strokeStyle = color; + ctx.lineWidth = lineWidth; // define path to draw - const path = new Path2D() + const path = new Path2D(); // Draw box without left part, to allow stacking boxes // horizontally without getting double lines between them. if (open === true) { - path.moveTo(x, y) - path.lineTo(x + width, y) - path.lineTo(x + width, y + height) - path.lineTo(x, y + height) - // Draw normal 4-sided box + path.moveTo(x, y); + path.lineTo(x + width, y); + path.lineTo(x + width, y + height); + path.lineTo(x, y + height); + // Draw normal 4-sided box } else { - path.rect(x, y, width, height) + path.rect(x, y, width, height); } - ctx.stroke(path) - if (fillColor !== null) { - ctx.fillStyle = fillColor - ctx.fill(path) + ctx.stroke(path); + if (fillColor !== null) { + ctx.fillStyle = fillColor; + ctx.fill(path); } - return path + return path; } // Draw an arrow in desired direction // Forward arrow: direction = 1 // Reverse arrow: direction = -1 -export async function drawArrow ({ ctx, x, y, dir, height, lineWidth = 2, color = 'black' }) { - const width = dir * lineWidth - ctx.save() - ctx.strokeStyle = color - ctx.lineWidth = lineWidth - ctx.beginPath() - ctx.moveTo(x - width / 2, y - height / 2) - ctx.lineTo(x + width / 2, y) - ctx.moveTo(x + width / 2, y) - ctx.lineTo(x - width / 2, y + height / 2) - ctx.stroke() - ctx.restore() +export async function drawArrow({ + ctx, + x, + y, + dir, + height, + lineWidth = 2, + color = "black", +}) { + const width = dir * lineWidth; + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = lineWidth; + ctx.beginPath(); + ctx.moveTo(x - width / 2, y - height / 2); + ctx.lineTo(x + width / 2, y); + ctx.moveTo(x + width / 2, y); + ctx.lineTo(x - width / 2, y + height / 2); + ctx.stroke(); + ctx.restore(); } // Draw a wave line from xStart to xStop at yPos where yPos is top left of the line. // Pattern is drawn by incrementing pointer by a half wave length and plot either // upward (/) or downward (\) line. // if the end is trunctated a partial wave is plotted. -export function drawWaveLine ({ ctx, x, y, x2, height, color = 'black', lineWidth = 2 }) { - ctx.save() - ctx.strokeStyle = color - ctx.lineWidth = lineWidth - ctx.beginPath() - ctx.moveTo(x, y) // begin at bottom left - const waveLength = 2 * (height / Math.tan(45)) - const lineLength = x2 - x + 1 +export function drawWaveLine({ + ctx, + x, + y, + x2, + height, + color = "black", + lineWidth = 2, +}) { + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = lineWidth; + ctx.beginPath(); + ctx.moveTo(x, y); // begin at bottom left + const waveLength = 2 * (height / Math.tan(45)); + const lineLength = x2 - x + 1; // plot whole wave pattern - const midline = y - height / 2 // middle of line - let lastXpos = x + const midline = y - height / 2; // middle of line + let lastXpos = x; for (let i = 0; i < Math.floor(lineLength / (waveLength / 2)); i++) { - lastXpos += waveLength / 2 - height *= -1 // reverse sign - ctx.lineTo(lastXpos, midline + height / 2) // move up + lastXpos += waveLength / 2; + height *= -1; // reverse sign + ctx.lineTo(lastXpos, midline + height / 2); // move up } // plot partial wave patterns - const partialWaveLength = lineLength % (waveLength / 2) + const partialWaveLength = lineLength % (waveLength / 2); if (partialWaveLength !== 0) { - height *= -1 // reverse sign - const partialWaveHeight = partialWaveLength * Math.tan(45) - ctx.lineTo(x2, y - Math.sign(height) * partialWaveHeight) + height *= -1; // reverse sign + const partialWaveHeight = partialWaveLength * Math.tan(45); + ctx.lineTo(x2, y - Math.sign(height) * partialWaveHeight); } - ctx.stroke() - ctx.restore() + ctx.stroke(); + ctx.restore(); } diff --git a/assets/js/fetch.js b/assets/js/fetch.js index e0f87fcb..fbdb3c21 100644 --- a/assets/js/fetch.js +++ b/assets/js/fetch.js @@ -1,61 +1,66 @@ // Fetch.js // functions for making api requests to Gens +/* global _apiHost */ -async function request (url, params, method = 'GET') { +async function request(url, params, method = "GET") { // options passed to hte fetch request const options = { method, headers: { - 'Content-Type': 'application/json' - } - } + "Content-Type": "application/json", + }, + }; // handle params if (params) { - if (method === 'GET') { - url += '?' + objectToQueryString(params) + if (method === "GET") { + url += "?" + objectToQueryString(params); } else { - options.body = JSON.stringify(params) + options.body = JSON.stringify(params); } } // fetch returns a promise - const response = await fetch(_apiHost + url, options) + const response = await fetch(_apiHost + url, options); if (response.status !== 200) { - return generateErrorResponse('The server responded with an unexpected status.') + return generateErrorResponse( + "The server responded with an unexpected status.", + ); } - const result = await response.json() + const result = await response.json(); // returns a single Promise object - return result + return result; } // converts an object into a query string // ex {region: 8:12-55} --> ®ion=8:12-55 -export function objectToQueryString (obj) { - return Object.keys(obj).map(key => key + '=' + obj[key]).join('&') +export function objectToQueryString(obj) { + return Object.keys(obj) + .map((key) => key + "=" + obj[key]) + .join("&"); } // A generic error handler that just returns an object with status=error and message -function generateErrorResponse (message) { +function generateErrorResponse(message) { return new Error({ - status: 'error', - message - }) + status: "error", + message, + }); } -export function get (url, params) { - return request(url, params) +export function get(url, params) { + return request(url, params); } -export function create (url, params) { - return request(url, params, 'POST') +export function create(url, params) { + return request(url, params, "POST"); } -export function update (url, params) { - return request(url, params, 'PUT') +export function update(url, params) { + return request(url, params, "PUT"); } -export function remove (url, params) { - return request(url, params, 'DELETE') +export function remove(url, params) { + return request(url, params, "DELETE"); } diff --git a/assets/js/fetch.test.js b/assets/js/fetch.test.js index d17038f0..a08eb018 100644 --- a/assets/js/fetch.test.js +++ b/assets/js/fetch.test.js @@ -1,20 +1,22 @@ -import { objectToQueryString } from './fetch.js' +import { objectToQueryString } from "./fetch.js"; -describe('Test objectToQueryString', () => { - test('test objectToQueryString single args', () => { - const paramString = objectToQueryString({region: '1:1-10'}) - expect(paramString).toBe('region=1:1-10') - }) +describe("Test objectToQueryString", () => { + test("test objectToQueryString single args", () => { + const paramString = objectToQueryString({ region: "1:1-10" }); + expect(paramString).toBe("region=1:1-10"); + }); - test('test objectToQueryString multiple args', () => { - const paramString = objectToQueryString({region: '1:1-10', page: 1}) - expect(paramString).toBe('region=1:1-10&page=1') - }) + test("test objectToQueryString multiple args", () => { + const paramString = objectToQueryString({ region: "1:1-10", page: 1 }); + expect(paramString).toBe("region=1:1-10&page=1"); + }); - test('test objectToQueryString multiple args multiple types', () => { - const paramString = objectToQueryString( - {region: '1:1-10', page: 1, print: true} - ) - expect(paramString).toBe('region=1:1-10&page=1&print=true') - }) -}) + test("test objectToQueryString multiple args multiple types", () => { + const paramString = objectToQueryString({ + region: "1:1-10", + page: 1, + print: true, + }); + expect(paramString).toBe("region=1:1-10&page=1&print=true"); + }); +}); diff --git a/assets/js/gens.js b/assets/js/gens.js index 33938711..f27ee2e6 100644 --- a/assets/js/gens.js +++ b/assets/js/gens.js @@ -1,38 +1,102 @@ // GENS module -import { InteractiveCanvas } from './interactive.js' -import { OverviewCanvas } from './overview.js' -import { VariantTrack, AnnotationTrack, TranscriptTrack, CytogeneticIdeogram } from './track.js' +import { InteractiveCanvas } from "./interactive.js"; +import { OverviewCanvas } from "./overview.js"; +import { + VariantTrack, + AnnotationTrack, + TranscriptTrack, + CytogeneticIdeogram, +} from "./track.js"; export { - setupDrawEventManager, drawTrack, previousChromosome, nextChromosome, - panTracks, zoomIn, zoomOut, parseRegionDesignation, queryRegionOrGene -} from './navigation.js' + setupDrawEventManager, + drawTrack, + previousChromosome, + nextChromosome, + panTracks, + zoomIn, + zoomOut, + parseRegionDesignation, + queryRegionOrGene, +} from "./navigation.js"; -export function initCanvases({ sampleName, genomeBuild, hgFileDir, uiColors, selectedVariant, annotationFile }) { +export function initCanvases({ + sampleName, + caseId, + genomeBuild, + hgFileDir, + uiColors, + scoutBaseURL, + selectedVariant, + annotationFile, +}) { // initialize and return the different canvases // WEBGL values - const near = 0.1 - const far = 100 - const lineMargin = 2 // Margin for line thickness + const near = 0.1; + const far = 100; + const lineMargin = 2; // Margin for line thickness // Listener values - const inputField = document.getElementById('region-field') + const inputField = document.getElementById("region-field"); // Initiate interactive canvas - const ic = new InteractiveCanvas(inputField, lineMargin, near, far, sampleName, genomeBuild, hgFileDir) + const ic = new InteractiveCanvas( + inputField, + lineMargin, + near, + far, + caseId, + sampleName, + genomeBuild, + hgFileDir, + ); // Initiate variant, annotation and transcript canvases - const vc = new VariantTrack(ic.x, ic.plotWidth, near, far, genomeBuild, uiColors.variants, selectedVariant) - const tc = new TranscriptTrack(ic.x, ic.plotWidth, near, far, genomeBuild, uiColors.transcripts) - const ac = new AnnotationTrack(ic.x, ic.plotWidth, near, far, genomeBuild, annotationFile) + const vc = new VariantTrack( + ic.x, + ic.plotWidth, + near, + far, + caseId, + genomeBuild, + uiColors.variants, + scoutBaseURL, + selectedVariant, + ); + const tc = new TranscriptTrack( + ic.x, + ic.plotWidth, + near, + far, + genomeBuild, + uiColors.transcripts, + ); + const ac = new AnnotationTrack( + ic.x, + ic.plotWidth, + near, + far, + genomeBuild, + annotationFile, + ); // Initiate and draw overview canvas - const oc = new OverviewCanvas(ic.x, ic.plotWidth, lineMargin, near, far, sampleName, genomeBuild, hgFileDir) + const oc = new OverviewCanvas( + ic.x, + ic.plotWidth, + lineMargin, + near, + far, + caseId, + sampleName, + genomeBuild, + hgFileDir, + ); // Draw cytogenetic ideogram figure const cg = new CytogeneticIdeogram({ - targetId: 'cytogenetic-ideogram', + targetId: "cytogenetic-ideogram", genomeBuild, x: ic.x, y: ic.y, width: ic.plotWidth, - height: 40 - }) + height: 40, + }); return { ic: ic, vc: vc, @@ -40,36 +104,47 @@ export function initCanvases({ sampleName, genomeBuild, hgFileDir, uiColors, sel ac: ac, oc: oc, cg: cg, - } + }; } // Make hard link and copy link to clipboard export function copyPermalink(genomeBuild, region) { // create element and add url to it - const tempElement = document.createElement('input') - const loc = window.location - tempElement.value = `${loc.host}${loc.pathname}?genome_build=${genomeBuild}®ion=${region}` + const tempElement = document.createElement("input"); + const loc = window.location; + tempElement.value = `${loc.host}${loc.pathname}?genome_build=${genomeBuild}®ion=${region}`; // add element to DOM - document.body.append(tempElement) - tempElement.select() - document.execCommand('copy') - tempElement.remove() // remove temp node + document.body.append(tempElement); + tempElement.select(); + document.execCommand("copy"); + tempElement.remove(); // remove temp node } // Reloads page to printable size export function loadPrintPage(region) { - let location = window.location.href.replace(/region=.*&/, `region=${region}&`) - location = location.includes('?') ? `${location}&print_page=true` : `${location}?print_page=true` - window.location.replace(location) + let location = window.location.href.replace( + /region=.*&/, + `region=${region}&`, + ); + location = location.includes("?") + ? `${location}&print_page=true` + : `${location}?print_page=true`; + window.location.replace(location); } // Show print prompt and reloads page after print export function printPage() { - document.querySelector('.no-print').toggleAttribute('hidden') - window.addEventListener('afterprint', () => { - window.location.replace(window.location.href.replace('&print_page=true', '')) - }, { once: true }) - print() + document.querySelector(".no-print").toggleAttribute("hidden"); + window.addEventListener( + "afterprint", + () => { + window.location.replace( + window.location.href.replace("&print_page=true", ""), + ); + }, + { once: true }, + ); + print(); } -export { CHROMOSOMES, setupGenericEventManager } from './track.js' \ No newline at end of file +export { CHROMOSOMES, setupGenericEventManager } from "./track.js"; diff --git a/assets/js/gens.test.js b/assets/js/gens.test.js index 048338d0..55d2a2fc 100644 --- a/assets/js/gens.test.js +++ b/assets/js/gens.test.js @@ -1,18 +1,20 @@ // Test functions for drawing the genecanvas -import { copyPermalink } from './gens.js' +import { copyPermalink } from "./gens.js"; -test('Test copyPermalink', () => { +test("Test copyPermalink", () => { // setup mocks - document.execCommand = jest.fn() - delete window.location - const inputElem = document.createElement('input') - document.createElement = jest.fn().mockReturnValueOnce(inputElem) - delete window.createElement - window.location = new URL('https://www.example.com/sampleId?foo=bar&doo=moo') + document.execCommand = jest.fn(); + delete window.location; + const inputElem = document.createElement("input"); + document.createElement = jest.fn().mockReturnValueOnce(inputElem); + delete window.createElement; + window.location = new URL("https://www.example.com/sampleId?foo=bar&doo=moo"); // run function - copyPermalink('38', '1:10-100') + copyPermalink("38", "1:10-100"); // expect copy to clipboard has been called - expect(document.execCommand).toHaveBeenCalledWith('copy') + expect(document.execCommand).toHaveBeenCalledWith("copy"); // assert the content copied - expect(inputElem.value).toEqual('www.example.com/sampleId?genome_build=38®ion=1:10-100') -}) + expect(inputElem.value).toEqual( + "www.example.com/sampleId?genome_build=38®ion=1:10-100", + ); +}); diff --git a/assets/js/helper.js b/assets/js/helper.js index 9f20c4a3..541ac20e 100644 --- a/assets/js/helper.js +++ b/assets/js/helper.js @@ -1,25 +1,24 @@ // Various helper functions -import { get } from './fetch.js' +import { get } from "./fetch.js"; -function cacheChromSizes (genomeBuild = '38') { - const cache = {} - return async genomeBuild => { +function cacheChromSizes(genomeBuild = "38") { + const cache = {}; + return async (genomeBuild) => { if (!cache[genomeBuild]) { - const result = await get('get-overview-chrom-dim', - { - genome_build: genomeBuild, - x_pos: 1, - y_pos: 1, - plot_width: 1 - }) - const sizes = {} + const result = await get("get-overview-chrom-dim", { + genome_build: genomeBuild, + x_pos: 1, + y_pos: 1, + plot_width: 1, + }); + const sizes = {}; for (const chrom in result.chrom_dims) { - sizes[chrom] = result.chrom_dims[chrom].size + sizes[chrom] = result.chrom_dims[chrom].size; } - cache[genomeBuild] = sizes + cache[genomeBuild] = sizes; } - return cache[genomeBuild] - } + return cache[genomeBuild]; + }; } -export const chromSizes = cacheChromSizes() +export const chromSizes = cacheChromSizes(); diff --git a/assets/js/interactive.js b/assets/js/interactive.js index 880f492b..3b283600 100644 --- a/assets/js/interactive.js +++ b/assets/js/interactive.js @@ -1,235 +1,342 @@ // Functions for rendering the interactive canvas -import { drawRotatedText, drawPoints, drawText, createGraph, drawVerticalTicks, drawGraphLines } from './draw.js' -import { drawTrack, zoomIn, zoomOut, keyLogger, limitRegionToChromosome, readInputField } from './navigation.js' -import { get } from './fetch.js' -import { BaseScatterTrack } from './track.js' +import { + drawRotatedText, + drawPoints, + drawText, + createGraph, + drawVerticalTicks, + drawGraphLines, +} from "./draw.js"; +import { + drawTrack, + zoomIn, + zoomOut, + keyLogger, + limitRegionToChromosome, + readInputField, +} from "./navigation.js"; +import { get } from "./fetch.js"; +import { BaseScatterTrack } from "./track.js"; export class InteractiveCanvas extends BaseScatterTrack { - constructor (inputField, lineMargin, near, far, sampleName, genomeBuild, hgFileDir) { - super({ sampleName, genomeBuild, hgFileDir }) + constructor( + inputField, + lineMargin, + near, + far, + caseId, + sampleName, + genomeBuild, + hgFileDir, + ) { + super({ caseId, sampleName, genomeBuild, hgFileDir }); // The canvas input field to display and fetch chromosome range from - this.inputField = inputField + this.inputField = inputField; // Plot variable - this.titleMargin = 80 // Margin between plot and title - this.legendMargin = 45 // Margin between legend and plot - this.leftRightPadding = 2 // Padding for left and right in graph - this.topBottomPadding = 8 // margin for top and bottom in graph - this.plotWidth = Math.min(1500, 0.9 * document.body.clientWidth - this.legendMargin) // Width of one plot - this.extraWidth = this.plotWidth / 1.5 // Width for loading in extra edge data - this.plotHeight = 180 // Height of one plot - this.x = document.body.clientWidth / 2 - this.plotWidth / 2 // X-position for first plot - this.y = 10 + 2 * lineMargin + this.titleMargin // Y-position for first plot - this.titleYPos = null - this.titleBbox = null - this.canvasHeight = 2 + this.y + 2 * (this.leftRightPadding + this.plotHeight) // Height for whole canvas + this.titleMargin = 80; // Margin between plot and title + this.legendMargin = 45; // Margin between legend and plot + this.leftRightPadding = 2; // Padding for left and right in graph + this.topBottomPadding = 8; // margin for top and bottom in graph + this.plotWidth = Math.min( + 1500, + 0.9 * document.body.clientWidth - this.legendMargin, + ); // Width of one plot + this.extraWidth = this.plotWidth / 1.5; // Width for loading in extra edge data + this.plotHeight = 180; // Height of one plot + this.x = document.body.clientWidth / 2 - this.plotWidth / 2; // X-position for first plot + this.y = 10 + 2 * lineMargin + this.titleMargin; // Y-position for first plot + this.titleYPos = null; + this.titleBbox = null; + this.canvasHeight = + 2 + this.y + 2 * (this.leftRightPadding + this.plotHeight); // Height for whole canvas // setup objects for tracking the positions of draw and content canvases - this.offscreenPosition = { start: null, end: null, scale: null } - this.onscreenPosition = { start: null, end: null } + this.offscreenPosition = { start: null, end: null, scale: null }; + this.onscreenPosition = { start: null, end: null }; // BAF values this.baf = { yStart: 1.0, // Start value for y axis yEnd: 0.0, // End value for y axis step: 0.2, // Step value for drawing ticks along y-axis - color: '#000000' // Viz color - } + color: "#000000", // Viz color + }; // Log2 ratio values this.log2 = { yStart: 4.0, // Start value for y axis yEnd: -4.0, // End value for y axis step: 1.0, // Step value for drawing ticks along y-axis - color: '#000000' // Viz color - } + color: "#000000", // Viz color + }; // Setup draw canvas - this.drawWidth = Math.max(this.plotWidth + 2 * this.extraWidth, document.body.clientWidth) // Draw-canvas width - this.drawCanvas.width = parseInt(this.drawWidth) - this.drawCanvas.height = parseInt(this.canvasHeight) + this.drawWidth = Math.max( + this.plotWidth + 2 * this.extraWidth, + document.body.clientWidth, + ); // Draw-canvas width + this.drawCanvas.width = parseInt(this.drawWidth); + this.drawCanvas.height = parseInt(this.canvasHeight); // Setup visible canvases - this.contentCanvas = document.getElementById('interactive-content') - this.staticCanvas = document.getElementById('interactive-static') - this.staticCanvas.width = this.contentCanvas.width = document.body.clientWidth - this.staticCanvas.height = this.contentCanvas.height = this.canvasHeight + this.contentCanvas = document.getElementById("interactive-content"); + this.staticCanvas = document.getElementById("interactive-static"); + this.staticCanvas.width = this.contentCanvas.width = + document.body.clientWidth; + this.staticCanvas.height = this.contentCanvas.height = this.canvasHeight; // this.drawCanvas = this.contentCanvas // Setup loading div dimensions - this.loadingDiv = document.getElementById('loading-div') - this.loadingDiv.style.width = `${this.plotWidth - 2.5}px` - this.loadingDiv.style.left = `${this.x + 2}px` - this.loadingDiv.style.top = `${this.y + 82}px` - this.loadingDiv.style.height = `${this.plotHeight * 2 - 2.5}px` + this.loadingDiv = document.getElementById("loading-div"); + this.loadingDiv.style.width = `${this.plotWidth - 2.5}px`; + this.loadingDiv.style.left = `${this.x + 2}px`; + this.loadingDiv.style.top = `${this.y + 82}px`; + this.loadingDiv.style.height = `${this.plotHeight * 2 - 2.5}px`; // Initialize marker div - this.markerElem = document.getElementById('interactive-marker') - this.markerElem.style.height = `${this.plotHeight * 2}px` - this.markerElem.style.top = `${this.y + 131}px` + this.markerElem = document.getElementById("interactive-marker"); + this.markerElem.style.height = `${this.plotHeight * 2}px`; + this.markerElem.style.top = `${this.y + 131}px`; // State values - this.allowDraw = true + this.allowDraw = true; // Listener values - this.markingRegion = false - this.drag = false - this.dragStart = {} - this.dragEnd = {} + this.markingRegion = false; + this.drag = false; + this.dragStart = {}; + this.dragEnd = {}; - this.scale = this.calcScale() + this.scale = this.calcScale(); // Setup listeners // update chromosome title event - this.contentCanvas.addEventListener('update-title', event => { - console.log(`interactive got an ${event.type} event`) - const len = event.detail.bands.length - if (len > 0) { - const bandIds = len === 1 ? event.detail.bands[0].id : `${event.detail.bands[0].id}-${event.detail.bands[len-1].id}` - this.drawCanvas.getContext('2d').clearRect(this.titleBbox.x, this.titleBbox.y, this.titleBbox.width, this.titleBbox.height) - this.titleBbox = this.drawTitle(`Chromosome ${event.detail.chrom}; ${bandIds}`) - this.blitChromName({textPosition: this.titleBbox}) + this.contentCanvas.addEventListener("update-title", (event) => { + console.log(`interactive got an ${event.type} event`); + const len = event.detail.bands.length; + if (len > 0) { + const bandIds = + len === 1 + ? event.detail.bands[0].id + : `${event.detail.bands[0].id}-${event.detail.bands[len - 1].id}`; + this.drawCanvas + .getContext("2d") + .clearRect( + this.titleBbox.x, + this.titleBbox.y, + this.titleBbox.width, + this.titleBbox.height, + ); + this.titleBbox = this.drawTitle( + `Chromosome ${event.detail.chrom}; ${bandIds}`, + ); + this.blitChromName({ textPosition: this.titleBbox }); } - }) + }); // redraw events - this.contentCanvas.parentElement.addEventListener('draw', event => { - console.log('interactive got draw event') - this.drawInteractiveContent({ ...event.detail.region, ...event.detail }) - }) + this.contentCanvas.parentElement.addEventListener("draw", (event) => { + console.log("interactive got draw event"); + this.drawInteractiveContent({ ...event.detail.region, ...event.detail }); + }); // navigation events - this.contentCanvas.addEventListener('mousedown', (event) => { - event.stopPropagation() + this.contentCanvas.addEventListener("mousedown", (event) => { + event.stopPropagation(); if (this.allowDraw && !this.drag) { if (keyLogger.heldKeys.Control) { - zoomOut() - } else { // Related to dragging + zoomOut(); + } else { + // Related to dragging // If region should be marked if (keyLogger.heldKeys.Shift) { - this.markingRegion = true + this.markingRegion = true; } // Make sure scale factor is updated - this.scale = this.calcScale() + this.scale = this.calcScale(); // store coordinates this.dragStart = { x: event.x, - y: event.y - } + y: event.y, + }; this.dragEnd = { x: event.x, - y: event.y - } - this.drag = true + y: event.y, + }; + this.drag = true; } } - }) + }); // When in active dragging of the canvas - this.contentCanvas.addEventListener('mousemove', (event) => { - event.preventDefault() - event.stopPropagation() + this.contentCanvas.addEventListener("mousemove", (event) => { + event.preventDefault(); + event.stopPropagation(); // If region should be marked if (keyLogger.heldKeys.Shift && this.allowDraw) { - this.markingRegion = true + this.markingRegion = true; } if (this.drag) { this.dragEnd = { x: event.x, - y: event.y - } + y: event.y, + }; if (this.markingRegion) { - this.markRegion({ start: this.dragStart.x, end: this.dragEnd.x }) + this.markRegion({ start: this.dragStart.x, end: this.dragEnd.x }); } else { // pan content canvas - this.panContent(this.dragEnd.x - this.dragStart.x) + this.panContent(this.dragEnd.x - this.dragStart.x); } } - }) + }); // When stop dragging - this.contentCanvas.addEventListener('mouseup', (event) => { - event.preventDefault() - event.stopPropagation() + this.contentCanvas.addEventListener("mouseup", (event) => { + event.preventDefault(); + event.stopPropagation(); // reset marking region if (this.markingRegion) { - this.markingRegion = false - this.resetRegionMarker() - const scale = this.calcScale() - const rawStart = this.onscreenPosition.start + Math.round((this.dragStart.x - this.x) / scale) - const rawEnd = rawStart + Math.round((this.dragEnd.x - this.dragStart.x) / scale) + this.markingRegion = false; + this.resetRegionMarker(); + const scale = this.calcScale(); + const rawStart = + this.onscreenPosition.start + + Math.round((this.dragStart.x - this.x) / scale); + const rawEnd = + rawStart + Math.round((this.dragEnd.x - this.dragStart.x) / scale); // sort positions so lowest number is allways start - const [start, end] = [rawStart, rawEnd].sort((a, b) => a - b) + const [start, end] = [rawStart, rawEnd].sort((a, b) => a - b); // if shift - click, zoom in a region 10 - if ((end - start) < 10) { - zoomIn() + if (end - start < 10) { + zoomIn(); } else { drawTrack({ chrom: this.chromosome, start: start, end: end, - exclude: ['cytogenetic-ideogram'], + exclude: ["cytogenetic-ideogram"], force: true, - drawTitle: false - }) + drawTitle: false, + }); } } else if (this.drag) { // reload window when stop draging - drawTrack({ ...readInputField(), force: true, displayLoading: false, drawTitle: false }) + drawTrack({ + ...readInputField(), + force: true, + displayLoading: false, + drawTitle: false, + }); } // reset dragging behaviour - this.markingRegion = false - this.drag = false - }) + this.markingRegion = false; + this.drag = false; + }); } // Draw static content for interactive canvas - async drawStaticContent () { - const linePadding = 2 - const staticContext = this.staticCanvas.getContext('2d') + async drawStaticContent() { + const linePadding = 2; + const staticContext = this.staticCanvas.getContext("2d"); // Fill background colour - staticContext.fillStyle = '#F7F9F9' - staticContext.fillRect(0, 0, this.staticCanvas.width, this.staticCanvas.height) + staticContext.fillStyle = "#F7F9F9"; + staticContext.fillRect( + 0, + 0, + this.staticCanvas.width, + this.staticCanvas.height, + ); // Make content area visible // content window - staticContext.clearRect(this.x + linePadding, this.y + linePadding, - this.plotWidth, this.staticCanvas.height) + staticContext.clearRect( + this.x + linePadding, + this.y + linePadding, + this.plotWidth, + this.staticCanvas.height, + ); // area for ticks above content area - staticContext.clearRect(0, 0, this.staticCanvas.width, this.y + linePadding) + staticContext.clearRect( + 0, + 0, + this.staticCanvas.width, + this.y + linePadding, + ); // Draw rotated y-axis legends - drawRotatedText(staticContext, 'B Allele Freq', 18, this.x - this.legendMargin, - this.y + this.plotHeight / 2, -Math.PI / 2, this.titleColor) - drawRotatedText(staticContext, 'Log2 Ratio', 18, this.x - this.legendMargin, - this.y + 1.5 * this.plotHeight, -Math.PI / 2, this.titleColor) + drawRotatedText( + staticContext, + "B Allele Freq", + 18, + this.x - this.legendMargin, + this.y + this.plotHeight / 2, + -Math.PI / 2, + this.titleColor, + ); + drawRotatedText( + staticContext, + "Log2 Ratio", + 18, + this.x - this.legendMargin, + this.y + 1.5 * this.plotHeight, + -Math.PI / 2, + this.titleColor, + ); // Draw BAF - createGraph(staticContext, this.x, this.y, this.plotWidth, - this.plotHeight, this.topBottomPadding, this.baf.yStart, this.baf.yEnd, - this.baf.step, true, this.borderColor) + createGraph( + staticContext, + this.x, + this.y, + this.plotWidth, + this.plotHeight, + this.topBottomPadding, + this.baf.yStart, + this.baf.yEnd, + this.baf.step, + true, + this.borderColor, + ); // Draw Log 2 ratio - createGraph(staticContext, this.x, this.y + this.plotHeight, - this.plotWidth, this.plotHeight, this.topBottomPadding, this.log2.yStart, - this.log2.yEnd, this.log2.step, true, this.borderColor) + createGraph( + staticContext, + this.x, + this.y + this.plotHeight, + this.plotWidth, + this.plotHeight, + this.topBottomPadding, + this.log2.yStart, + this.log2.yEnd, + this.log2.step, + true, + this.borderColor, + ); // Transfer image to visible canvas - staticContext.drawImage(this.drawCanvas, 0, 0) + staticContext.drawImage(this.drawCanvas, 0, 0); } // Draw values for interactive canvas - async drawInteractiveContent ({ chrom, start, end, displayLoading = true, drawTitle = true }) { - console.log('drawing interactive canvas', chrom, start, end) + async drawInteractiveContent({ + chrom, + start, + end, + displayLoading = true, + drawTitle = true, + }) { + console.log("drawing interactive canvas", chrom, start, end); if (displayLoading) { - this.loadingDiv.style.display = 'block' + this.loadingDiv.style.display = "block"; } else { - document.getElementsByTagName('body')[0].style.cursor = 'wait' + document.getElementsByTagName("body")[0].style.cursor = "wait"; } - console.time('getcoverage') - get('get-coverage', { + console.time("getcoverage"); + get("get-coverage", { region: `${chrom}:${start}-${end}`, + case_id: this.caseId, sample_id: this.sampleName, genome_build: this.genomeBuild, hg_filedir: this.hgFileDir, @@ -243,173 +350,186 @@ export class InteractiveCanvas extends BaseScatterTrack { baf_y_end: this.baf.yEnd, log2_y_start: this.log2.yStart, log2_y_end: this.log2.yEnd, - reduce_data: 1 - }).then(result => { - console.timeEnd('getcoverage') - if (result.status === 'error') { - throw new Error(result) - } - // store new start and end values - this.offscreenPosition = { - start: parseInt(result.padded_start), - end: parseInt(result.padded_end) - } - this.offscreenPosition.scale = this.drawWidth / (this.offscreenPosition.end - this.offscreenPosition.start) - this.chromosome = chrom - // clear draw and content canvas - const ctx = this.drawCanvas.getContext('2d') - ctx.clearRect( - 0, 0, this.drawCanvas.width, this.drawCanvas.height - ) - - drawVerticalTicks({ - ctx, - renderX: 0, - y: this.y, - xStart: start, - xEnd: end, - xoStart: this.offscreenPosition.start, - xoEnd: this.offscreenPosition.end, - width: this.plotWidth, - yMargin: this.topBottomPadding, - titleColor: this.titleColor - }) - - // Draw horizontal lines for BAF and Log 2 ratio - drawGraphLines({ - ctx, - x: 0, - y: result.y_pos, - yStart: this.baf.yStart, - yEnd: this.baf.yEnd, - stepLength: this.baf.step, - yMargin: this.topBottomPadding, - width: this.drawWidth, - height: this.plotHeight - }) - drawGraphLines({ - ctx, - x: 0, - y: result.y_pos + this.plotHeight, - yStart: this.log2.yStart, - yEnd: this.log2.yEnd, - stepLength: this.log2.step, - yMargin: this.topBottomPadding, - width: this.drawWidth, - height: this.plotHeight - }) + reduce_data: 1, + }) + .then((result) => { + console.timeEnd("getcoverage"); + if (result.status === "error") { + throw new Error(result); + } + // store new start and end values + this.offscreenPosition = { + start: parseInt(result.padded_start), + end: parseInt(result.padded_end), + }; + this.offscreenPosition.scale = + this.drawWidth / + (this.offscreenPosition.end - this.offscreenPosition.start); + this.chromosome = chrom; + // clear draw and content canvas + const ctx = this.drawCanvas.getContext("2d"); + ctx.clearRect(0, 0, this.drawCanvas.width, this.drawCanvas.height); + + drawVerticalTicks({ + ctx, + renderX: 0, + y: this.y, + xStart: start, + xEnd: end, + xoStart: this.offscreenPosition.start, + xoEnd: this.offscreenPosition.end, + width: this.plotWidth, + yMargin: this.topBottomPadding, + titleColor: this.titleColor, + }); + + // Draw horizontal lines for BAF and Log 2 ratio + drawGraphLines({ + ctx, + x: 0, + y: result.y_pos, + yStart: this.baf.yStart, + yEnd: this.baf.yEnd, + stepLength: this.baf.step, + yMargin: this.topBottomPadding, + width: this.drawWidth, + height: this.plotHeight, + }); + drawGraphLines({ + ctx, + x: 0, + y: result.y_pos + this.plotHeight, + yStart: this.log2.yStart, + yEnd: this.log2.yEnd, + stepLength: this.log2.step, + yMargin: this.topBottomPadding, + width: this.drawWidth, + height: this.plotHeight, + }); + + // Plot scatter data + drawPoints({ + ctx, + data: result.baf, + color: this.baf.color, + }); + drawPoints({ + ctx, + data: result.data, + color: this.log2.color, + }); + + // Transfer image to visible canvas + this.blitInteractiveCanvas({ start, end }); + // Draw chromosome title on the content canvas as a blitting + // work around + this.titleYPos = result.y_pos - this.titleMargin; + if (drawTitle) { + this.titleBbox !== null && + this.blitChromName({ + textPosition: this.titleBbox, + clearOnly: true, + }); + this.titleBbox = this.drawTitle(`Chromosome ${result.chrom}`); + this.blitChromName({ textPosition: this.titleBbox }); + } - // Plot scatter data - drawPoints({ - ctx, data: result.baf, color: this.baf.color + return result; }) - drawPoints({ - ctx, data: result.data, color: this.log2.color + .then((result) => { + if (displayLoading) { + this.loadingDiv.style.display = "none"; + } else { + document.getElementsByTagName("body")[0].style.cursor = "auto"; + } + this.allowDraw = true; }) + .catch((error) => { + this.allowDraw = true; - // Transfer image to visible canvas - this.blitInteractiveCanvas({ start, end }) - // Draw chromosome title on the content canvas as a blitting - // work around - this.titleYPos = result.y_pos - this.titleMargin - if ( drawTitle ) { - this.titleBbox !== null && this.blitChromName( - {textPosition: this.titleBbox, clearOnly: true - }) - this.titleBbox = this.drawTitle(`Chromosome ${result.chrom}`) - this.blitChromName({textPosition: this.titleBbox}) - } - - return result - }).then((result) => { - if (displayLoading) { - this.loadingDiv.style.display = 'none' - } else { - document.getElementsByTagName('body')[0].style.cursor = 'auto' - } - this.allowDraw = true - }).catch(error => { - this.allowDraw = true - - this.inputField.dispatchEvent( - new CustomEvent('error', { detail: { error: error } }) - ) - }) + this.inputField.dispatchEvent( + new CustomEvent("error", { detail: { error: error } }), + ); + }); } drawTitle(title) { - const ctx = this.drawCanvas.getContext('2d') + const ctx = this.drawCanvas.getContext("2d"); return drawText({ ctx, x: Math.round(document.body.clientWidth / 2), y: this.titleYPos, text: title, - fontProp: 'bold 15', - align: 'center' - }) + fontProp: "bold 15", + align: "center", + }); } - calcScale () { - return this.plotWidth / (this.onscreenPosition.end - this.onscreenPosition.start) + calcScale() { + return ( + this.plotWidth / (this.onscreenPosition.end - this.onscreenPosition.start) + ); } // Function for highlighting region - markRegion ({ start, end }) { + markRegion({ start, end }) { // Update the dom element - this.markerElem.style.left = start < end ? `${start}px` : `${end}px` - this.markerElem.style.width = `${Math.abs(end - start) + 1}px` - this.markerElem.hidden = false + this.markerElem.style.left = start < end ? `${start}px` : `${end}px`; + this.markerElem.style.width = `${Math.abs(end - start) + 1}px`; + this.markerElem.hidden = false; } - resetRegionMarker () { - this.markerElem.style.left = '0px' - this.markerElem.style.width = '0px' - this.markerElem.hidden = true + resetRegionMarker() { + this.markerElem.style.left = "0px"; + this.markerElem.style.width = "0px"; + this.markerElem.hidden = true; } - blitChromName ({textPosition, clearOnly=false}) { - const ctx = this.contentCanvas.getContext('2d') - const padding = 20 + blitChromName({ textPosition, clearOnly = false }) { + const ctx = this.contentCanvas.getContext("2d"); + const padding = 20; // clear area on contentCanvas ctx.clearRect( textPosition.x - padding / 2, textPosition.y, textPosition.width + padding, - textPosition.height - ) + textPosition.height, + ); // transfer from draw canvas - !clearOnly && ctx.drawImage( - this.drawCanvas, // source - textPosition.x - padding / 2, // sX - textPosition.y, // sY - textPosition.width + padding, // sWidth - textPosition.height, // sHeight - textPosition.x - padding / 2, // dX - textPosition.y, // dY - textPosition.width + padding, // dWidth - textPosition.height // dHeight - ) + !clearOnly && + ctx.drawImage( + this.drawCanvas, // source + textPosition.x - padding / 2, // sX + textPosition.y, // sY + textPosition.width + padding, // sWidth + textPosition.height, // sHeight + textPosition.x - padding / 2, // dX + textPosition.y, // dY + textPosition.width + padding, // dWidth + textPosition.height, // dHeight + ); } - blitInteractiveCanvas ({ start, end, updateCoord = true }) { + blitInteractiveCanvas({ start, end, updateCoord = true }) { // blit areas from the drawCanvas to content canvas. // start, end are onscreen position - const width = end - start + const width = end - start; // store onscreen coords - if (updateCoord) this.onscreenPosition = { start: start, end: end } + if (updateCoord) this.onscreenPosition = { start: start, end: end }; const offscreenOffset = Math.round( - (start - this.offscreenPosition.start) * this.offscreenPosition.scale) - const offscSegmentWidth = Math.round(width * this.offscreenPosition.scale) - const onscSegmentWidth = width * this.calcScale() + (start - this.offscreenPosition.start) * this.offscreenPosition.scale, + ); + const offscSegmentWidth = Math.round(width * this.offscreenPosition.scale); + const onscSegmentWidth = width * this.calcScale(); // clear current canvas - const ctx = this.contentCanvas.getContext('2d') + const ctx = this.contentCanvas.getContext("2d"); ctx.clearRect( 0, this.titleMargin / 2 - 2, this.contentCanvas.width, - this.contentCanvas.height - ) + this.contentCanvas.height, + ); // normalize the genomic coordinates to screen coordinates ctx.drawImage( this.drawCanvas, // source image @@ -420,27 +540,34 @@ export class InteractiveCanvas extends BaseScatterTrack { this.x, // dX this.titleMargin / 2, // dY onscSegmentWidth, // dWidth - this.contentCanvas.height // dHeight - ) + this.contentCanvas.height, // dHeight + ); } // Move track x distance - async panContent (distance) { + async panContent(distance) { // calculate the chromosome positions - const scale = this.calcScale() - const dist = distance / scale + const scale = this.calcScale(); + const dist = distance / scale; const region = await limitRegionToChromosome({ chrom: this.chromosome, start: this.onscreenPosition.start - dist, - end: this.onscreenPosition.end - dist - }) + end: this.onscreenPosition.end - dist, + }); // Copy draw image to content Canvas - this.blitInteractiveCanvas({ start: region.start, end: region.end, updateCoord: false }) + this.blitInteractiveCanvas({ + start: region.start, + end: region.end, + updateCoord: false, + }); drawTrack({ ...region, - exclude: [`${this.contentCanvas.parentElement.id}`, 'cytogenetic-ideogram'], - displayLoading: false - }) + exclude: [ + `${this.contentCanvas.parentElement.id}`, + "cytogenetic-ideogram", + ], + displayLoading: false, + }); } } diff --git a/assets/js/navigation.js b/assets/js/navigation.js index ed284f3d..d502b8bf 100644 --- a/assets/js/navigation.js +++ b/assets/js/navigation.js @@ -1,52 +1,52 @@ -import { get } from './fetch.js' -import { CHROMOSOMES } from './track.js' -import { chromSizes } from './helper.js' +import { get } from "./fetch.js"; +import { CHROMOSOMES } from "./track.js"; +import { chromSizes } from "./helper.js"; function redrawEvent({ region, exclude = [], ...kwargs }) { - return new CustomEvent( - 'draw', { detail: { region: region, exclude: exclude, ...kwargs } } - ) + return new CustomEvent("draw", { + detail: { region: region, exclude: exclude, ...kwargs }, + }); } function drawEventManager({ target, throttleTime }) { const tracks = [ - ...target.querySelectorAll('.track-container'), - target.querySelector('#cytogenetic-ideogram') - ] - let lastEventTime = 0 + ...target.querySelectorAll(".track-container"), + target.querySelector("#cytogenetic-ideogram"), + ]; + let lastEventTime = 0; return (event) => { - const now = Date.now() - console.log(`Test event times ${lastEventTime} ? ${now}, diff: ${now - lastEventTime}`) - if (throttleTime < Date.now() - lastEventTime || - event.detail.force - ) { - lastEventTime = Date.now() + const now = Date.now(); + console.log( + `Test event times ${lastEventTime} ? ${now}, diff: ${now - lastEventTime}`, + ); + if (throttleTime < Date.now() - lastEventTime || event.detail.force) { + lastEventTime = Date.now(); for (const track of tracks) { if (!event.detail.exclude.includes(track.id)) { - track.dispatchEvent(redrawEvent({ ...event.detail })) + track.dispatchEvent(redrawEvent({ ...event.detail })); } } } - } + }; } export function setupDrawEventManager({ target, throttleTime = 20 }) { - const manager = drawEventManager({ target, throttleTime }) - target.addEventListener('draw', (event) => { - manager(event) - }) + const manager = drawEventManager({ target, throttleTime }); + target.addEventListener("draw", (event) => { + manager(event); + }); } export function readInputField() { - const field = document.getElementById('region-field') - return parseRegionDesignation(field.value) + const field = document.getElementById("region-field"); + return parseRegionDesignation(field.value); } function updateInputField({ chrom, start, end }) { - const field = document.getElementById('region-field') - field.value = `${chrom}:${start}-${end}` - field.placeholder = field.value - field.blur() + const field = document.getElementById("region-field"); + field.value = `${chrom}:${start}-${end}`; + field.placeholder = field.value; + field.blur(); } // parse chromosomal region designation string @@ -55,130 +55,170 @@ function updateInputField({ chrom, start, end }) { // 1: --> 1, null, null // 1 --> 1, null, null export function parseRegionDesignation(regionString) { - if (regionString.includes(':')) { - const [chromosome, position] = regionString.split(':') + if (regionString.includes(":")) { + const [chromosome, position] = regionString.split(":"); // verify chromosome if (!CHROMOSOMES.includes(chromosome)) { - throw new Error(`${chromosome} is not a valid chromosome`) + throw new Error(`${chromosome} is not a valid chromosome`); } - let [start, end] = position.split('-') - start = parseInt(start) - end = parseInt(end) - return { chrom: chromosome, start: start, end: end } + let [start, end] = position.split("-"); + start = parseInt(start); + end = parseInt(end); + return { chrom: chromosome, start: start, end: end }; } } -export async function limitRegionToChromosome({ chrom, start, end, genomeBuild = '38' }) { +export async function limitRegionToChromosome({ + chrom, + start, + end, + genomeBuild = "38", +}) { // assert that start/stop are within start and end of chromosome - const sizes = await chromSizes(genomeBuild) - const chromSize = sizes[chrom] - start = start === null ? 1 : start - end = end === null ? chromSize : end + const sizes = await chromSizes(genomeBuild); + const chromSize = sizes[chrom]; + start = start === null ? 1 : start; + end = end === null ? chromSize : end; // ensure the window size stay the same - const windowSize = end - start + 1 >= chromSize ? chromSize : end - start - let updStart, updEnd + const windowSize = end - start + 1 >= chromSize ? chromSize : end - start; + let updStart, updEnd; if (windowSize >= chromSize) { - updStart = 1 - updEnd = chromSize + updStart = 1; + updEnd = chromSize; } else if (start < 1) { - updStart = 1 - updEnd = windowSize + updStart = 1; + updEnd = windowSize; } else if (end > chromSize) { - updStart = chromSize - windowSize - updEnd = chromSize + updStart = chromSize - windowSize; + updEnd = chromSize; } else { - updStart = start - updEnd = end + updStart = start; + updEnd = end; } - return { chrom: chrom, start: Math.round(updStart), end: Math.round(updEnd) } + return { chrom: chrom, start: Math.round(updStart), end: Math.round(updEnd) }; } export async function drawTrack({ - chrom, start, end, genomeBuild = '38', - exclude = [], force = false, ...kwargs + chrom, + start, + end, + genomeBuild = "38", + exclude = [], + force = false, + ...kwargs }) { // update input field - const region = await limitRegionToChromosome({ chrom, start, end }) - updateInputField({ ...region }) - const trackContainer = document.getElementById('visualization-container') + const region = await limitRegionToChromosome({ + chrom, + start, + end, + genomeBuild, + }); + updateInputField({ ...region }); + const trackContainer = document.getElementById("visualization-container"); trackContainer.dispatchEvent( - redrawEvent({ region, exclude, force, ...kwargs }) - ) + redrawEvent({ region, exclude, force, ...kwargs }), + ); // make overview update its region marking - const markRegionEvent = new CustomEvent('mark-region', { detail: { region: region } }) - document.getElementById('overview-container').dispatchEvent(markRegionEvent) - document.getElementById('cytogenetic-ideogram').dispatchEvent(markRegionEvent) + const markRegionEvent = new CustomEvent("mark-region", { + detail: { region: region }, + }); + document.getElementById("overview-container").dispatchEvent(markRegionEvent); + document + .getElementById("cytogenetic-ideogram") + .dispatchEvent(markRegionEvent); } // If query is a regionString draw the relevant region // If input is a chromosome display entire chromosome // Else query api for genes with that name and draw that region export function queryRegionOrGene(query, genomeBuild = 38) { - if (query.includes(':')) { - drawTrack(parseRegionDesignation(query)) + if (query.includes(":")) { + drawTrack(parseRegionDesignation(query)); } else if (CHROMOSOMES.includes(query)) { - drawTrack({ chrom: query, start: 1, end: null }) + drawTrack({ chrom: query, start: 1, end: null }); } else { - get('search-annotation', { query: query, genome_build: genomeBuild }) - .then(result => { + get("search-annotation", { query: query, genome_build: genomeBuild }).then( + (result) => { if (result.status === 200) { drawTrack({ chrom: result.chromosome, start: result.start_pos, - end: result.end_pos - }) + end: result.end_pos, + }); } - }) + }, + ); } } // goto the next chromosome export function nextChromosome() { - const position = readInputField() - const chrom = CHROMOSOMES[CHROMOSOMES.indexOf(position.chrom) + 1] - drawTrack({ chrom: chrom, start: 1, end: null }) + const position = readInputField(); + const chrom = CHROMOSOMES[CHROMOSOMES.indexOf(position.chrom) + 1]; + drawTrack({ chrom: chrom, start: 1, end: null }); } // goto the previous chromosome export function previousChromosome() { - const position = readInputField() - const chrom = CHROMOSOMES[CHROMOSOMES.indexOf(position.chrom) - 1] - drawTrack({ chrom: chrom, start: 1, end: null }) + const position = readInputField(); + const chrom = CHROMOSOMES[CHROMOSOMES.indexOf(position.chrom) - 1]; + drawTrack({ chrom: chrom, start: 1, end: null }); } // Pan whole canvas and tracks to the left -export function panTracks(direction = 'left') { - const pos = readInputField() - let distance = Math.floor(0.1 * (pos.end - pos.start)) - // Don't allow negative values - distance = (pos.start < distance) ? distance + (pos.start - distance) : distance - // todo keep distance constant - if (direction === 'left') { - pos.start -= distance - pos.end -= distance +export function panTracks(direction = "left", speed = 0.1) { + const pos = readInputField(); + const distance = Math.abs(Math.floor(speed * (pos.end - pos.start))); + if (direction === "left") { + pos.start -= distance; + pos.end -= distance; } else { - pos.start += distance - pos.end += distance + pos.start += distance; + pos.end += distance; + } + // drawTrack will correct the window eventually, but let us not go negative at least + if (pos.start < 0) { + pos.end = pos.end - pos.start; + pos.start = 1; } - drawTrack({ chrom: pos.chrom, start: pos.start, end: pos.end, drawTitle: false, exclude: ['cytogenetic-ideogram'] }) + drawTrack({ + chrom: pos.chrom, + start: pos.start, + end: pos.end, + drawTitle: false, + exclude: ["cytogenetic-ideogram"], + }); } // Handle zoom in button click export function zoomIn() { - const pos = readInputField() - const factor = Math.floor((pos.end - pos.start) * 0.2) - pos.start += factor - pos.end -= factor - drawTrack({ chrom: pos.chrom, start: pos.start, end: pos.end, exclude: ['cytogenetic-ideogram'], drawTitle: false }) + const pos = readInputField(); + const factor = Math.floor((pos.end - pos.start) * 0.2); + pos.start += factor; + pos.end -= factor; + drawTrack({ + chrom: pos.chrom, + start: pos.start, + end: pos.end, + exclude: ["cytogenetic-ideogram"], + drawTitle: false, + }); } // Handle zoom out button click export function zoomOut() { - const pos = readInputField() - const factor = Math.floor((pos.end - pos.start) / 3) - pos.start = (pos.start - factor) < 1 ? 1 : pos.start - factor - pos.end += factor - drawTrack({ chrom: pos.chrom, start: pos.start, end: pos.end, exclude: ['cytogenetic-ideogram'], drawTitle: false }) + const pos = readInputField(); + const factor = Math.floor((pos.end - pos.start) / 3); + pos.start = pos.start - factor < 1 ? 1 : pos.start - factor; + pos.end += factor; + drawTrack({ + chrom: pos.chrom, + start: pos.start, + end: pos.end, + exclude: ["cytogenetic-ideogram"], + drawTitle: false, + }); } // Dispatch dispatch an event to draw a given region @@ -187,95 +227,100 @@ class KeyLogger { // Records keypress combinations constructor(bufferSize = 10) { // Setup variables - this.bufferSize = bufferSize - this.lastKeyTime = Date.now() - this.heldKeys = {} // store held keys - this.keyBuffer = [] // store recent keys + this.bufferSize = bufferSize; + this.lastKeyTime = Date.now(); + this.heldKeys = {}; // store held keys + this.keyBuffer = []; // store recent keys // Setup event listending functions - document.addEventListener('keydown', event => { + document.addEventListener("keydown", (event) => { // store event const eventData = { key: event.key, target: window.event.target.nodeName, - time: Date.now() - } - const keyEvent = new CustomEvent('keyevent', { detail: eventData }) - this.heldKeys[event.key] = true // recored pressed keys - this.keyBuffer.push(eventData) + time: Date.now(), + }; + const keyEvent = new CustomEvent("keyevent", { detail: eventData }); + this.heldKeys[event.key] = true; // recored pressed keys + this.keyBuffer.push(eventData); // empty buffer - while (this.keyBuffer.length > this.bufferSize) { this.keyBuffer.shift() } - document.dispatchEvent(keyEvent) // event information - }) - document.addEventListener('keyup', event => { - delete this.heldKeys[event.key] - }) + while (this.keyBuffer.length > this.bufferSize) { + this.keyBuffer.shift(); + } + document.dispatchEvent(keyEvent); // event information + }); + document.addEventListener("keyup", (event) => { + delete this.heldKeys[event.key]; + }); } recentKeys(timeWindow) { // get keys pressed within a window of time. - const currentTime = Date.now() - return this.keyBuffer.filter(keyEvent => - timeWindow > currentTime - keyEvent.time) + const currentTime = Date.now(); + return this.keyBuffer.filter( + (keyEvent) => timeWindow > currentTime - keyEvent.time, + ); } lastKeypressTime() { - return this.keyBuffer[this.keyBuffer.length - 1] - Date.now() + return this.keyBuffer[this.keyBuffer.length - 1] - Date.now(); } } -export const keyLogger = new KeyLogger() +export const keyLogger = new KeyLogger(); // Setup handling of keydown events -const keystrokeDelay = 2000 -document.addEventListener('keyevent', event => { - const key = event.detail.key +const keystrokeDelay = 2000; +document.addEventListener("keyevent", (event) => { + const key = event.detail.key; // dont act on key presses in input fields - const excludeFileds = ['input', 'select', 'textarea'] + const excludeFileds = ["input", "select", "textarea"]; if (!excludeFileds.includes(event.detail.target.toLowerCase())) { - if (key === 'Enter') { + if (key === "Enter") { // Enter was pressed, process previous key presses. - const recentKeys = keyLogger.recentKeys(keystrokeDelay) - recentKeys.pop() // skip Enter key - const lastKey = recentKeys[recentKeys.length - 1] - const numKeys = parseInt((recentKeys - .slice(lastKey.length - 2) - .filter(val => parseInt(val.key)) - .map(val => val.key) - .join(''))) + const recentKeys = keyLogger.recentKeys(keystrokeDelay); + recentKeys.pop(); // skip Enter key + const lastKey = recentKeys[recentKeys.length - 1]; + const numKeys = parseInt( + recentKeys + .slice(lastKey.length - 2) + .filter((val) => parseInt(val.key)) + .map((val) => val.key) + .join(""), + ); // process keys - if (lastKey.key === 'x' || lastKey.key === 'y') { - drawTrack({ region: lastKey.key }) + if (lastKey.key === "x" || lastKey.key === "y") { + drawTrack({ region: lastKey.key }); } else if (numKeys && numKeys > 0 < 23) { - drawTrack({ region: numKeys }) + drawTrack({ region: numKeys }); } else { - return + return; } } switch (key) { - case 'ArrowLeft': - previousChromosome() - break - case 'ArrowRight': - nextChromosome() - break - case 'a': - panTracks('left') - break - case 'd': - panTracks('right') - break - case 'ArrowUp': - case 'w': - case '+': - zoomIn() - break - case 'ArrowDown': - case 's': - case '-': - zoomOut() - break + case "ArrowLeft": + previousChromosome(); + break; + case "ArrowRight": + nextChromosome(); + break; + case "a": + panTracks("left", 0.7); + break; + case "d": + panTracks("right", 0.7); + break; + case "ArrowUp": + case "w": + case "+": + zoomIn(); + break; + case "ArrowDown": + case "s": + case "-": + zoomOut(); + break; default: } } -}) +}); diff --git a/assets/js/navigation.test.js b/assets/js/navigation.test.js index 1b66ffaa..7ab1b16a 100644 --- a/assets/js/navigation.test.js +++ b/assets/js/navigation.test.js @@ -1,129 +1,146 @@ -import { parseRegionDesignation, limitRegionToChromosome, readInputField } from './navigation.js' -import * as helper from './helper.js' +import { + parseRegionDesignation, + limitRegionToChromosome, + readInputField, +} from "./navigation.js"; +import * as helper from "./helper.js"; // Test Track class -describe('Test parseRegionDesignation', () => { - test('Parse :-', () => { - let region = parseRegionDesignation('12:11-100') - expect(region).toEqual({chrom: '12', start: 11, end: 100}) - - region = parseRegionDesignation('X:11-100') - expect(region).toEqual({chrom: 'X', start: 11, end: 100}) - }) - - test('Parse :-None', () => { - const region = parseRegionDesignation('12:11-None') - expect(region).toEqual({chrom: '12', start: 11, end: NaN}) - }) - - test('Parse :', () => { - const region = parseRegionDesignation('12:11') - expect(region).toEqual({chrom: '12', start: 11, end: NaN}) - }) - - test('Parse :', () => { - const region = parseRegionDesignation('12:') - expect(region).toEqual({chrom: '12', start: NaN, end: NaN}) - }) - - test('Input only the chromosome returns null', () => { - const region = parseRegionDesignation('12') - expect(region).toBeFalsy() - }) - - test('Invalid chromosome throws an error', () => { - expect(() => parseRegionDesignation('30:')).toThrow() - expect(() => parseRegionDesignation('Z:')).toThrow() - }) -}) - -describe('test limitRegionToChromosome ', () => { +describe("Test parseRegionDesignation", () => { + test("Parse :-", () => { + let region = parseRegionDesignation("12:11-100"); + expect(region).toEqual({ chrom: "12", start: 11, end: 100 }); + + region = parseRegionDesignation("X:11-100"); + expect(region).toEqual({ chrom: "X", start: 11, end: 100 }); + }); + + test("Parse :-None", () => { + const region = parseRegionDesignation("12:11-None"); + expect(region).toEqual({ chrom: "12", start: 11, end: NaN }); + }); + + test("Parse :", () => { + const region = parseRegionDesignation("12:11"); + expect(region).toEqual({ chrom: "12", start: 11, end: NaN }); + }); + + test("Parse :", () => { + const region = parseRegionDesignation("12:"); + expect(region).toEqual({ chrom: "12", start: NaN, end: NaN }); + }); + + test("Input only the chromosome returns null", () => { + const region = parseRegionDesignation("12"); + expect(region).toBeFalsy(); + }); + + test("Invalid chromosome throws an error", () => { + expect(() => parseRegionDesignation("30:")).toThrow(); + expect(() => parseRegionDesignation("Z:")).toThrow(); + }); +}); + +describe("test limitRegionToChromosome ", () => { // setup mocks - const mockRes = {1: 5000, 2: 10000} - - test('test position within chromosome', () => { - const chromSizeMock = jest.spyOn(helper, 'chromSizes') - .mockReturnValueOnce(mockRes) - .mockName('chromSizes') - - limitRegionToChromosome({chrom: 1, start: 1000, end: 2000}) - .then( region => { - expect(region).toEqual({chrom: 1, start: 1000, end: 2000}) - }) - expect(chromSizeMock.mock.calls.length).toBe(1) // assert mock works - }) - - test('test end pos outside chromosome', () => { - const chromSizeMock = jest.spyOn(helper, 'chromSizes') - .mockReturnValueOnce(mockRes) - .mockName('chromSizes') - - limitRegionToChromosome({chrom: 1, start: 4000, end: 6000}) - .then( region => { - expect(region).toEqual({chrom: 1, start: 3000, end: 5000}) - }) - }) - - test('test start pos outside chromosome', () => { - const chromSizeMock = jest.spyOn(helper, 'chromSizes') - .mockReturnValueOnce(mockRes) - .mockName('chromSizes') - - limitRegionToChromosome({chrom: 1, start: -1000, end: 2000}) - .then( region => { - expect(region).toEqual({chrom: 1, start: 1, end: 3000}) - }) - }) - - - test('test start pos is null', () => { - const chromSizeMock = jest.spyOn(helper, 'chromSizes') - .mockReturnValueOnce(mockRes) - .mockName('chromSizes') - - limitRegionToChromosome({chrom: 1, start: null, end: 2000}) - .then( region => { - expect(region).toEqual({chrom: 1, start: 1, end: 2000}) - }) - }) - - test('test end pos is null', () => { - const chromSizeMock = jest.spyOn(helper, 'chromSizes') - .mockReturnValueOnce(mockRes) - .mockName('chromSizes') - - limitRegionToChromosome({chrom: 1, start: 1000, end: null}) - .then( region => { - expect(region).toEqual({chrom: 1, start: 1000, end: 5000}) - }) - }) - - test('test start and end pos is outsize chrom', () => { - const chromSizeMock = jest.spyOn(helper, 'chromSizes') - .mockReturnValueOnce(mockRes) - .mockName('chromSizes') - - limitRegionToChromosome({chrom: 1, start: -2000, end: 10000}) - .then( region => { - expect(region).toEqual({chrom: 1, start: 1, end: 5000}) - }) - }) - - test('test if start and end are retained', () => { - const chromSizeMock = jest.spyOn(helper, 'chromSizes') - .mockReturnValueOnce(mockRes) - .mockName('chromSizes') - - limitRegionToChromosome({chrom: 1, start: 1, end: 5000}) - .then( region => { - expect(region).toEqual({chrom: 1, start: 1, end: 5000}) - }) - }) -}) - -test('test readInputField', () => { + const mockRes = { 1: 5000, 2: 10000 }; + + test("test position within chromosome", () => { + const chromSizeMock = jest + .spyOn(helper, "chromSizes") + .mockReturnValueOnce(mockRes) + .mockName("chromSizes"); + + limitRegionToChromosome({ chrom: 1, start: 1000, end: 2000 }).then( + (region) => { + expect(region).toEqual({ chrom: 1, start: 1000, end: 2000 }); + }, + ); + expect(chromSizeMock.mock.calls.length).toBe(1); // assert mock works + }); + + test("test end pos outside chromosome", () => { + const chromSizeMock = jest + .spyOn(helper, "chromSizes") + .mockReturnValueOnce(mockRes) + .mockName("chromSizes"); + + limitRegionToChromosome({ chrom: 1, start: 4000, end: 6000 }).then( + (region) => { + expect(region).toEqual({ chrom: 1, start: 3000, end: 5000 }); + }, + ); + }); + + test("test start pos outside chromosome", () => { + const chromSizeMock = jest + .spyOn(helper, "chromSizes") + .mockReturnValueOnce(mockRes) + .mockName("chromSizes"); + + limitRegionToChromosome({ chrom: 1, start: -1000, end: 2000 }).then( + (region) => { + expect(region).toEqual({ chrom: 1, start: 1, end: 3000 }); + }, + ); + }); + + test("test start pos is null", () => { + const chromSizeMock = jest + .spyOn(helper, "chromSizes") + .mockReturnValueOnce(mockRes) + .mockName("chromSizes"); + + limitRegionToChromosome({ chrom: 1, start: null, end: 2000 }).then( + (region) => { + expect(region).toEqual({ chrom: 1, start: 1, end: 2000 }); + }, + ); + }); + + test("test end pos is null", () => { + const chromSizeMock = jest + .spyOn(helper, "chromSizes") + .mockReturnValueOnce(mockRes) + .mockName("chromSizes"); + + limitRegionToChromosome({ chrom: 1, start: 1000, end: null }).then( + (region) => { + expect(region).toEqual({ chrom: 1, start: 1000, end: 5000 }); + }, + ); + }); + + test("test start and end pos is outsize chrom", () => { + const chromSizeMock = jest + .spyOn(helper, "chromSizes") + .mockReturnValueOnce(mockRes) + .mockName("chromSizes"); + + limitRegionToChromosome({ chrom: 1, start: -2000, end: 10000 }).then( + (region) => { + expect(region).toEqual({ chrom: 1, start: 1, end: 5000 }); + }, + ); + }); + + test("test if start and end are retained", () => { + const chromSizeMock = jest + .spyOn(helper, "chromSizes") + .mockReturnValueOnce(mockRes) + .mockName("chromSizes"); + + limitRegionToChromosome({ chrom: 1, start: 1, end: 5000 }).then( + (region) => { + expect(region).toEqual({ chrom: 1, start: 1, end: 5000 }); + }, + ); + }); +}); + +test("test readInputField", () => { document.body.innerHTML = ` -` - const region = readInputField() - expect(region).toEqual({chrom: '1', start: 100, end: 1000}) -}) +`; + const region = readInputField(); + expect(region).toEqual({ chrom: "1", start: 100, end: 1000 }); +}); diff --git a/assets/js/overview.js b/assets/js/overview.js index 18db17af..88757e36 100644 --- a/assets/js/overview.js +++ b/assets/js/overview.js @@ -1,175 +1,217 @@ // Overview canvas definition -import { BaseScatterTrack, CHROMOSOMES } from './track.js' -import { create, get } from './fetch.js' -import { createGraph, drawPoints, drawGraphLines, drawText, drawRotatedText } from './draw.js' +import { BaseScatterTrack, CHROMOSOMES } from "./track.js"; +import { create, get } from "./fetch.js"; +import { + createGraph, + drawPoints, + drawGraphLines, + drawText, + drawRotatedText, +} from "./draw.js"; -import { drawTrack } from './navigation.js' +import { drawTrack } from "./navigation.js"; export class OverviewCanvas extends BaseScatterTrack { - constructor (xPos, fullPlotWidth, lineMargin, near, far, sampleName, - genomeBuild, hgFileDir) { - super({ sampleName, genomeBuild, hgFileDir }) + constructor( + xPos, + fullPlotWidth, + lineMargin, + near, + far, + caseId, + sampleName, + genomeBuild, + hgFileDir, + ) { + super({ caseId, sampleName, genomeBuild, hgFileDir }); // Plot variables - this.fullPlotWidth = fullPlotWidth // Width for all chromosomes to fit in - this.plotHeight = 180 // Height of one plot - this.titleMargin = 10 // Margin between plot and title - this.legendMargin = 45 // Margin between legend and plot - this.x = xPos // Starting x-position for plot - this.y = 20 + this.titleMargin + 2 * lineMargin // Starting y-position for plot - this.leftRightPadding = 2 // Padding for left and right in graph - this.topBottomPadding = 8 // Padding for top and bottom in graph - this.leftmostPoint = this.x + 10 // Draw y-values for graph left of this point + this.fullPlotWidth = fullPlotWidth; // Width for all chromosomes to fit in + this.plotHeight = 180; // Height of one plot + this.titleMargin = 10; // Margin between plot and title + this.legendMargin = 45; // Margin between legend and plot + this.x = xPos; // Starting x-position for plot + this.y = 20 + this.titleMargin + 2 * lineMargin; // Starting y-position for plot + this.leftRightPadding = 2; // Padding for left and right in graph + this.topBottomPadding = 8; // Padding for top and bottom in graph + this.leftmostPoint = this.x + 10; // Draw y-values for graph left of this point // Setup canvas for repeated patterns - this.patternCanvas = document.createElement('canvas') - const size = 20 - this.patternCanvas.width = size - this.patternCanvas.height = size - const patternCtx = this.patternCanvas.getContext('2d') - patternCtx.fillStyle = "#E6E9ED" - patternCtx.strokeStyle = "#4C6D94" - patternCtx.lineWidth = Math.round(size / 10) - patternCtx.lineCap = 'square' - patternCtx.fillRect(0, 0, size, size) - patternCtx.moveTo(size / 2, 0) - patternCtx.lineTo(size, size / 2) - patternCtx.moveTo(0, size / 2) - patternCtx.lineTo(size / 2, size) - patternCtx.stroke() + this.patternCanvas = document.createElement("canvas"); + const size = 20; + this.patternCanvas.width = size; + this.patternCanvas.height = size; + const patternCtx = this.patternCanvas.getContext("2d"); + patternCtx.fillStyle = "#E6E9ED"; + patternCtx.strokeStyle = "#4C6D94"; + patternCtx.lineWidth = Math.round(size / 10); + patternCtx.lineCap = "square"; + patternCtx.fillRect(0, 0, size, size); + patternCtx.moveTo(size / 2, 0); + patternCtx.lineTo(size, size / 2); + patternCtx.moveTo(0, size / 2); + patternCtx.lineTo(size / 2, size); + patternCtx.stroke(); // BAF values this.baf = { yStart: 1.0, // Start value for y axis yEnd: 0.0, // End value for y axis step: 0.2, // Step value for drawing ticks along y-axis - color: '#000000' // Viz color - } + color: "#000000", // Viz color + }; // Log2 ratio values this.log2 = { yStart: 3.0, // Start value for y axis yEnd: -3.0, // End value for y axis step: 1.0, // Step value for drawing ticks along y-axis - color: '#000000' // Viz color - } + color: "#000000", // Viz color + }; // Canvas variables - this.disabledChroms = [] - this.width = document.body.clientWidth // Canvas width - this.height = this.y + 2 * this.plotHeight + 2 * this.topBottomPadding // Canvas height - this.drawCanvas.width = parseInt(this.width) - this.drawCanvas.height = parseInt(this.height) - this.staticCanvas = document.getElementById('overview-static') + this.disabledChroms = []; + this.width = document.body.clientWidth; // Canvas width + this.height = this.y + 2 * this.plotHeight + 2 * this.topBottomPadding; // Canvas height + this.drawCanvas.width = parseInt(this.width); + this.drawCanvas.height = parseInt(this.height); + this.staticCanvas = document.getElementById("overview-static"); // Initialize marker div element - this.markerElem = document.getElementById('overview-marker') - this.markerElem.style.height = (this.plotHeight * 2) + 'px' - this.markerElem.style.marginTop = 0 - (this.plotHeight + this.topBottomPadding) * 2 + 'px' + this.markerElem = document.getElementById("overview-marker"); + this.markerElem.style.height = this.plotHeight * 2 + "px"; + this.markerElem.style.marginTop = + 0 - (this.plotHeight + this.topBottomPadding) * 2 + "px"; // Set dimensions of overview canvases - this.staticCanvas.width = this.width - this.staticCanvas.height = this.height + this.staticCanvas.width = this.width; + this.staticCanvas.height = this.height; this.getOverviewChromDim().then(() => { // Select a chromosome in overview track - this.staticCanvas.addEventListener('mousedown', event => { - event.stopPropagation() - const selectedChrom = this.pixelPosToGenomicLoc(event.x) + this.staticCanvas.addEventListener("mousedown", (event) => { + event.stopPropagation(); + const selectedChrom = this.pixelPosToGenomicLoc(event.x); if (!this.disabledChroms.includes(selectedChrom.chrom)) { // Dont update if chrom previously selected // Move interactive view to selected region - const chrom = selectedChrom.chrom - const start = 1 - const end = this.dims[chrom].size - 1 + const chrom = selectedChrom.chrom; + const start = 1; + const end = this.dims[chrom].size - 1; // Mark region - this.markRegion({ chrom, start, end }) - drawTrack({ chrom, start, end }) // redraw canvas + this.markRegion({ chrom, start, end }); + drawTrack({ chrom, start, end }); // redraw canvas } - }) - this.staticCanvas.parentElement.addEventListener('mark-region', event => { - this.markRegion({ ...event.detail.region }) - }) - }) + }); + this.staticCanvas.parentElement.addEventListener( + "mark-region", + (event) => { + this.markRegion({ ...event.detail.region }); + }, + ); + }); } - pixelPosToGenomicLoc (pixelpos) { - const match = {} + pixelPosToGenomicLoc(pixelpos) { + const match = {}; for (const i of CHROMOSOMES) { - const chr = this.dims[i] + const chr = this.dims[i]; if (pixelpos > chr.x_pos && pixelpos < chr.x_pos + chr.width) { - match.chrom = i - match.pos = Math.floor(chr.size * (pixelpos - chr.x_pos) / chr.width) + match.chrom = i; + match.pos = Math.floor((chr.size * (pixelpos - chr.x_pos)) / chr.width); } } - return match + return match; } - async getOverviewChromDim () { - await get('get-overview-chrom-dim', { + async getOverviewChromDim() { + await get("get-overview-chrom-dim", { x_pos: this.x, y_pos: this.y, plot_width: this.fullPlotWidth, - genome_build: this.genomeBuild - }).then(result => { - this.dims = result.chrom_dims - this.chromPos = CHROMOSOMES.map(chrom => { + genome_build: this.genomeBuild, + }).then((result) => { + this.dims = result.chrom_dims; + this.chromPos = CHROMOSOMES.map((chrom) => { return { region: `${chrom}:0-None`, x_pos: this.dims[chrom].x_pos + this.leftRightPadding, y_pos: this.dims[chrom].y_pos, - x_ampl: this.dims[chrom].width - 2 * this.leftRightPadding - } - }) - }) + x_ampl: this.dims[chrom].width - 2 * this.leftRightPadding, + }; + }); + }); } - markRegion ({ chrom, start, end }) { + markRegion({ chrom, start, end }) { if (this.dims !== undefined) { - const scale = this.dims[chrom].width / this.dims[chrom].size - const overviewMarker = document.getElementById('overview-marker') + const scale = this.dims[chrom].width / this.dims[chrom].size; + const overviewMarker = document.getElementById("overview-marker"); - let markerStartPos, markerWidth + let markerStartPos, markerWidth; // Calculate position and size of marker if ((end - start) * scale < 2) { - markerStartPos = 1 + (this.dims[chrom].x_pos + start * scale) - markerWidth = 2 + markerStartPos = 1 + (this.dims[chrom].x_pos + start * scale); + markerWidth = 2; } else { - markerStartPos = 1.5 + (this.dims[chrom].x_pos + start * scale) - markerWidth = Math.max(2, Math.ceil((end - start) * scale) - 1) + markerStartPos = 1.5 + (this.dims[chrom].x_pos + start * scale); + markerWidth = Math.max(2, Math.ceil((end - start) * scale) - 1); } // Update the dom element - overviewMarker.style.left = markerStartPos + 'px' - overviewMarker.style.width = (markerWidth) + 'px' + overviewMarker.style.left = markerStartPos + "px"; + overviewMarker.style.width = markerWidth + "px"; } } - async drawOverviewPlotSegment ({ canvas, chrom, width, chromCovData }) { + async drawOverviewPlotSegment({ canvas, chrom, width, chromCovData }) { // Draw chromosome title - const ctx = canvas.getContext('2d') + const ctx = canvas.getContext("2d"); drawText({ ctx, x: chromCovData.x_pos - this.leftRightPadding + width / 2, y: chromCovData.y_pos - this.titleMargin, text: chromCovData.chrom, fontProp: 10, - align: 'center' - }) + align: "center", + }); // Draw rotated y-axis legends if (chromCovData.x_pos < this.leftmostPoint) { - drawRotatedText(ctx, 'B Allele Freq', 18, chromCovData.x_pos - this.legendMargin, - chromCovData.y_pos + this.plotHeight / 2, -Math.PI / 2, this.titleColor) - drawRotatedText(ctx, 'Log2 Ratio', 18, chromCovData.x_pos - this.legendMargin, - chromCovData.y_pos + 1.5 * this.plotHeight, -Math.PI / 2, this.titleColor) + drawRotatedText( + ctx, + "B Allele Freq", + 18, + chromCovData.x_pos - this.legendMargin, + chromCovData.y_pos + this.plotHeight / 2, + -Math.PI / 2, + this.titleColor, + ); + drawRotatedText( + ctx, + "Log2 Ratio", + 18, + chromCovData.x_pos - this.legendMargin, + chromCovData.y_pos + 1.5 * this.plotHeight, + -Math.PI / 2, + this.titleColor, + ); } // Draw BAF - createGraph(ctx, + createGraph( + ctx, chromCovData.x_pos - this.leftRightPadding, - chromCovData.y_pos, width, this.plotHeight, this.topBottomPadding, - this.baf.yStart, this.baf.yEnd, this.baf.step, - chromCovData.x_pos < this.leftmostPoint, this.borderColor, chrom !== CHROMOSOMES[0]) + chromCovData.y_pos, + width, + this.plotHeight, + this.topBottomPadding, + this.baf.yStart, + this.baf.yEnd, + this.baf.step, + chromCovData.x_pos < this.leftmostPoint, + this.borderColor, + chrom !== CHROMOSOMES[0], + ); drawGraphLines({ ctx, x: chromCovData.x_pos, @@ -179,17 +221,24 @@ export class OverviewCanvas extends BaseScatterTrack { stepLength: this.baf.step, yMargin: this.topBottomPadding, width: width, - height: this.plotHeight - }) + height: this.plotHeight, + }); // Draw Log 2 ratio createGraph( ctx, chromCovData.x_pos - this.leftRightPadding, - chromCovData.y_pos + this.plotHeight, width, - this.plotHeight, this.topBottomPadding, this.log2.yStart, - this.log2.yEnd, this.log2.step, - chromCovData.x_pos < this.leftmostPoint, this.borderColor, chrom !== CHROMOSOMES[0]) + chromCovData.y_pos + this.plotHeight, + width, + this.plotHeight, + this.topBottomPadding, + this.log2.yStart, + this.log2.yEnd, + this.log2.step, + chromCovData.x_pos < this.leftmostPoint, + this.borderColor, + chrom !== CHROMOSOMES[0], + ); drawGraphLines({ ctx, x: chromCovData.x_pos, @@ -199,32 +248,38 @@ export class OverviewCanvas extends BaseScatterTrack { stepLength: this.log2.step, yMargin: this.topBottomPadding, width: width, - height: this.plotHeight - }) + height: this.plotHeight, + }); // Plot scatter data - if ( chromCovData.baf.length > 0 || chromCovData.data.length > 0 ) { + if (chromCovData.baf.length > 0 || chromCovData.data.length > 0) { drawPoints({ ctx, data: chromCovData.baf, - color: this.baf.color - }) + color: this.baf.color, + }); drawPoints({ ctx, data: chromCovData.data, - color: this.log2.color - }) + color: this.log2.color, + }); } else { - const pattern = ctx.createPattern(this.patternCanvas, 'repeat') - ctx.fillStyle = pattern - ctx.fillRect(chromCovData.x_pos, chromCovData.y_pos + 1, width - 2, (this.plotHeight * 2) - 2) - this.disabledChroms.push(chrom) + const pattern = ctx.createPattern(this.patternCanvas, "repeat"); + ctx.fillStyle = pattern; + ctx.fillRect( + chromCovData.x_pos, + chromCovData.y_pos + 1, + width - 2, + this.plotHeight * 2 - 2, + ); + this.disabledChroms.push(chrom); } } - async drawOverviewContent (printing) { - await this.getOverviewChromDim() + async drawOverviewContent(printing) { + await this.getOverviewChromDim(); // query gens for coverage values - const covData = await create('get-multiple-coverages', { + const covData = await create("get-multiple-coverages", { + case_id: this.caseId, sample_id: this.sampleName, genome_build: this.genomeBuild, plot_height: this.plotHeight, @@ -234,16 +289,16 @@ export class OverviewCanvas extends BaseScatterTrack { baf_y_end: this.baf.yEnd, log2_y_start: this.log2.yStart, log2_y_end: this.log2.yEnd, - overview: 'True', - reduce_data: 1 - }) + overview: "True", + reduce_data: 1, + }); for (const [chrom, res] of Object.entries(covData.results)) { this.drawOverviewPlotSegment({ canvas: this.staticCanvas, chrom: chrom, width: this.dims[chrom].width, - chromCovData: res - }) + chromCovData: res, + }); } } } diff --git a/assets/js/track.js b/assets/js/track.js index 939ff6f7..4ecbf679 100644 --- a/assets/js/track.js +++ b/assets/js/track.js @@ -1,8 +1,11 @@ // Entrypoint for track module -export { CHROMOSOMES } from './track/constants.js' -export { VariantTrack } from './track/variant.js' -export { TranscriptTrack } from './track/transcript.js' -export { AnnotationTrack } from './track/annotation.js' -export { BaseScatterTrack } from './track/base.js' -export { CytogeneticIdeogram, setupGenericEventManager } from './track/ideogram.js' +export { CHROMOSOMES } from "./track/constants.js"; +export { VariantTrack } from "./track/variant.js"; +export { TranscriptTrack } from "./track/transcript.js"; +export { AnnotationTrack } from "./track/annotation.js"; +export { BaseScatterTrack } from "./track/base.js"; +export { + CytogeneticIdeogram, + setupGenericEventManager, +} from "./track/ideogram.js"; diff --git a/assets/js/track/annotation.js b/assets/js/track/annotation.js index 338fb366..abb5ce3a 100644 --- a/assets/js/track/annotation.js +++ b/assets/js/track/annotation.js @@ -1,144 +1,151 @@ // Annotation track definition -import { BaseAnnotationTrack } from './base.js' -import { isElementOverlapping } from './utils.js' -import { get } from '../fetch.js' -import { parseRegionDesignation } from '../navigation.js' -import { drawRect, drawText } from '../draw.js' -import { initTrackTooltips, createTooltipElement, makeVirtualDOMElement, updateVisableElementCoordinates } from './tooltip.js' -import { createPopper } from '@popperjs/core' +import { BaseAnnotationTrack } from "./base.js"; +import { isElementOverlapping } from "./utils.js"; +import { get } from "../fetch.js"; +import { parseRegionDesignation } from "../navigation.js"; +import { drawRect, drawText } from "../draw.js"; +import { + initTrackTooltips, + createTooltipElement, + makeVirtualDOMElement, + updateVisibleElementCoordinates, +} from "./tooltip.js"; +import { createPopper } from "@popperjs/core"; // Convert to 32bit integer -function stringToHash (string) { - let hash = 0 - if (string.length === 0) return hash +function stringToHash(string) { + let hash = 0; + if (string.length === 0) return hash; for (let i = 0; i < string.length; i++) { - const char = string.charCodeAt(i) - hash = ((hash << 5) - hash) + char - hash = hash & hash + const char = string.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; } - return hash + return hash; } export class AnnotationTrack extends BaseAnnotationTrack { - constructor (x, width, near, far, genomeBuild, defaultAnnotation) { + constructor(x, width, near, far, genomeBuild, defaultAnnotation) { // Dimensions of track canvas - const visibleHeight = 300 // Visible height for expanded canvas, overflows for scroll - const minHeight = 35 // Minimized height + const visibleHeight = 300; // Visible height for expanded canvas, overflows for scroll + const minHeight = 35; // Minimized height - super(width, near, far, visibleHeight, minHeight) + super(width, near, far, visibleHeight, minHeight); // Set inherited variables // TODO use the names contentCanvas and drawCanvas - this.drawCanvas = document.getElementById('annotation-draw') - this.contentCanvas = document.getElementById('annotation-content') - this.trackTitle = document.getElementById('annotation-titles') - this.trackContainer = document.getElementById('annotation-track-container') - this.featureHeight = 18 - this.arrowThickness = 2 + this.drawCanvas = document.getElementById("annotation-draw"); + this.contentCanvas = document.getElementById("annotation-content"); + this.trackTitle = document.getElementById("annotation-titles"); + this.trackContainer = document.getElementById("annotation-track-container"); + this.featureHeight = 18; + this.arrowThickness = 2; // Setup html objects now that we have gotten the canvas and div elements - this.setupHTML(x + 1) + this.setupHTML(x + 1); - this.trackContainer.style.marginTop = '-1px' - this.genomeBuild = genomeBuild + this.trackContainer.style.marginTop = "-1px"; + this.genomeBuild = genomeBuild; // Setup annotation list - this.sourceList = document.getElementById('source-list') - this.sourceList.addEventListener('change', () => { - this.expanded = false - this.additionalQueryParams = { source: this.sourceList.value } - const region = parseRegionDesignation(document.getElementById('region-field').value) - this.drawTrack({ forceRedraw: true, ...region }) - }) - this.annotSourceList(defaultAnnotation) + this.sourceList = document.getElementById("source-list"); + this.sourceList.addEventListener("change", () => { + this.expanded = false; + this.additionalQueryParams = { source: this.sourceList.value }; + const region = parseRegionDesignation( + document.getElementById("region-field").value, + ); + this.drawTrack({ forceRedraw: true, ...region }); + }); + this.annotSourceList(defaultAnnotation); // GENS api parameters - this.apiEntrypoint = 'get-annotation-data' - this.additionalQueryParams = { source: defaultAnnotation } + this.apiEntrypoint = "get-annotation-data"; + this.additionalQueryParams = { source: defaultAnnotation }; - this.maxResolution = 6 // define other max resolution - this.numRenderedElements = 0 - initTrackTooltips(this) + this.maxResolution = 6; // define other max resolution + this.numRenderedElements = 0; + initTrackTooltips(this); } // Fills the list with source files - annotSourceList (defaultAnntotation) { - get('get-annotation-sources', { genome_build: this.genomeBuild }) - .then(result => { + annotSourceList(defaultAnnotation) { + get("get-annotation-sources", { genome_build: this.genomeBuild }) + .then((result) => { if (result.sources.length > 0) { - this.sourceList.style.visibility = 'visible' + this.sourceList.style.visibility = "visible"; } for (const fileName of result.sources) { // Add annotation file name to list - const opt = document.createElement('option') - opt.value = fileName - opt.innerHTML = fileName + const opt = document.createElement("option"); + opt.value = fileName; + opt.innerHTML = fileName; // Set mimisbrunnr as default file - if (fileName.match(defaultAnntotation)) { - opt.setAttribute('selected', true) + if (fileName.match(defaultAnnotation)) { + opt.setAttribute("selected", true); } - this.sourceList.appendChild(opt) + this.sourceList.appendChild(opt); } }) .then(() => { - const region = parseRegionDesignation(document.getElementById('region-field').value) - this.drawTrack({ ...region }) - }) + const region = parseRegionDesignation( + document.getElementById("region-field").value, + ); + this.drawTrack({ ...region }); + }); } // Draws annotations in given range - async drawOffScreenTrack ({ startPos, endPos, maxHeightOrder, data }) { - const textSize = 10 + async drawOffScreenTrack({ startPos, endPos, maxHeightOrder, data }) { + const textSize = 10; // store positions used when rendering the canvas this.offscreenPosition = { start: startPos, end: endPos, - scale: (this.drawCanvas.width / - (endPos - startPos)) - } - const scale = this.offscreenPosition.scale + scale: this.drawCanvas.width / (endPos - startPos), + }; + const scale = this.offscreenPosition.scale; this.heightOrderRecord = { latestHeight: 0, // Latest height order for annotation latestNameEnd: 0, // Latest annotations end position - latestTrackEnd: 0 // Latest annotations title's end position - } + latestTrackEnd: 0, // Latest annotations title's end position + }; // limit drawing of transcript to pre-defined resolutions - let filteredAnnotations = [] + let filteredAnnotations = []; if (this.getResolution < this.maxResolution + 1) { - filteredAnnotations = data - .annotations - .filter(annot => isElementOverlapping(annot, - { - start: startPos, - end: endPos - })) + filteredAnnotations = data.annotations.filter((annot) => + isElementOverlapping(annot, { + start: startPos, + end: endPos, + }), + ); } // dont show tracks with no data in them if (filteredAnnotations.length > 0) { // Set needed height of visible canvas and transcript tooltips - this.setContainerHeight(this.trackData.max_height_order) + this.setContainerHeight(this.trackData.max_height_order); } else { // Set needed height of visible canvas and transcript tooltips - this.setContainerHeight(0) + this.setContainerHeight(0); } - this.clearTracks() + this.clearTracks(); // Go through results and draw appropriate symbols for (const track of filteredAnnotations) { - const annotationName = track.name - const heightOrder = track.height_order - const start = track.start - const end = track.end - const color = track.color + const annotationName = track.name; + const heightOrder = track.height_order; + const start = track.start; + const end = track.end; + const color = track.color; // Only draw visible tracks if (!this.expanded && heightOrder !== 1) { - continue + continue; } // Keep track of latest annotations @@ -146,12 +153,12 @@ export class AnnotationTrack extends BaseAnnotationTrack { this.heightOrderRecord = { latestHeight: heightOrder, latestNameEnd: 0, - latestTrackEnd: 0 - } + latestTrackEnd: 0, + }; } // make an annotation object that tracks screen coordinates of object - const x1 = scale * (start - this.offscreenPosition.start) - const canvasYPos = this.tracksYPos(heightOrder) + const x1 = scale * (start - this.offscreenPosition.start); + const canvasYPos = this.tracksYPos(heightOrder); const annotationObj = { id: stringToHash(track.name), name: track.name, @@ -160,10 +167,10 @@ export class AnnotationTrack extends BaseAnnotationTrack { x1: x1, x2: x1 + scale * (end - start + 1), y1: canvasYPos, - y2: canvasYPos + (this.featureHeight / 2), + y2: canvasYPos + this.featureHeight / 2, features: [], - isDisplayed: false - } + isDisplayed: false, + }; // Draw box for annotation drawRect({ ctx: this.drawCtx, @@ -173,46 +180,51 @@ export class AnnotationTrack extends BaseAnnotationTrack { height: this.featureHeight / 2, lineWidth: 1, fillColor: color, - open: false - }) + open: false, + }); // get onscreen positions for offscreen xy coordinates - updateVisableElementCoordinates({ + updateVisibleElementCoordinates({ element: annotationObj, screenPosition: this.onscreenPosition, - scale: this.offscreenPosition.scale - }) + scale: this.offscreenPosition.scale, + }); // create a tooltip html element and append to DOM const tooltip = createTooltipElement({ id: `popover-${annotationObj.id}`, title: annotationObj.name, information: [ { title: track.chrom, value: `${track.start}-${track.end}` }, - { title: 'Score', value: `${track.score}` } - ] - }) - this.trackContainer.appendChild(tooltip) + { title: "Score", value: `${track.score}` }, + ], + }); + this.trackContainer.appendChild(tooltip); // make a virtual element as tooltip hitbox const virtualElement = makeVirtualDOMElement({ x1: annotationObj.visibleX1, x2: annotationObj.visibleX2, y1: annotationObj.visibleY1, y2: annotationObj.visibleY2, - canvas: this.contentCanvas - }) + canvas: this.contentCanvas, + }); // add tooltip to annotationObj annotationObj.tooltip = { instance: createPopper(virtualElement, tooltip, { modifiers: [ - { name: 'offset', options: { offset: [0, virtualElement.getBoundingClientRect().height] } } - ] + { + name: "offset", + options: { + offset: [0, virtualElement.getBoundingClientRect().height], + }, + }, + ], }), virtualElement: virtualElement, tooltip: tooltip, - isDisplayed: false - } - this.geneticElements.push(annotationObj) + isDisplayed: false, + }; + this.geneticElements.push(annotationObj); - const textYPos = this.tracksYPos(heightOrder) + const textYPos = this.tracksYPos(heightOrder); // limit drawing of titles to certain resolution if (this.getResolution < 6) { // Draw annotation name @@ -221,8 +233,8 @@ export class AnnotationTrack extends BaseAnnotationTrack { text: annotationName, x: scale * ((start + end) / 2 - this.offscreenPosition.start), y: textYPos + this.featureHeight, - fontProp: textSize - }) + fontProp: textSize, + }); } } } diff --git a/assets/js/track/base.js b/assets/js/track/base.js index aca38df7..c920f0ab 100644 --- a/assets/js/track/base.js +++ b/assets/js/track/base.js @@ -1,178 +1,192 @@ // Generic functions related to drawing annotation tracks -import { get } from '../fetch.js' -import { hideTooltip } from './tooltip.js' +import { get } from "../fetch.js"; +import { hideTooltip } from "./tooltip.js"; // Calculate offscreen position -export function calculateOffscreenWindowPos ({ start, end, multiplier }) { - const width = end - start - const padding = ((width * multiplier) - width) / 2 +export function calculateOffscreenWindowPos({ start, end, multiplier }) { + const width = end - start; + const padding = (width * multiplier - width) / 2; // const paddedStart = (start - padding) > 0 ? (start - padding) : 1; return { - start: Math.round(start - padding), end: Math.round(end + padding) - } + start: Math.round(start - padding), + end: Math.round(end + padding), + }; } // function for shading and blending colors on the fly -export function lightenColor (color, percent) { - const num = parseInt(color.replace('#', ''), 16) - const amt = Math.round(2.55 * percent) - const red = (num >> 16) + amt - const blue = (num >> 8 & 0x00FF) + amt - const green = (num & 0x0000FF) + amt - return '#' + (0x1000000 + (red < 255 ? red < 1 ? 0 : red : 255) * 0x10000 + (blue < 255 ? blue < 1 ? 0 : blue : 255) * 0x100 + (green < 255 ? green < 1 ? 0 : green : 255)).toString(16).slice(1) -}; +export function lightenColor(color, percent) { + const num = parseInt(color.replace("#", ""), 16); + const amt = Math.round(2.55 * percent); + const red = (num >> 16) + amt; + const blue = ((num >> 8) & 0x00ff) + amt; + const green = (num & 0x0000ff) + amt; + return ( + "#" + + ( + 0x1000000 + + (red < 255 ? (red < 1 ? 0 : red) : 255) * 0x10000 + + (blue < 255 ? (blue < 1 ? 0 : blue) : 255) * 0x100 + + (green < 255 ? (green < 1 ? 0 : green) : 255) + ) + .toString(16) + .slice(1) + ); +} export class BaseScatterTrack { - constructor ({ sampleName, genomeBuild, hgFileDir }) { + constructor({ caseId, sampleName, genomeBuild, hgFileDir }) { // setup IO - this.sampleName = sampleName // File name to load data from - this.genomeBuild = genomeBuild // Whether to load HG37 or HG38, default is HG38 - this.hgFileDir = hgFileDir // File directory + this.caseId = caseId; // Case id to use for querying data + this.sampleName = sampleName; // File name to load data from + this.genomeBuild = genomeBuild; // Whether to load HG37 or HG38, default is HG38 + this.hgFileDir = hgFileDir; // File directory // Border - this.borderColor = '#666' // Color of border - this.titleColor = 'black' // Color of titles/legends + this.borderColor = "#666"; // Color of border + this.titleColor = "black"; // Color of titles/legends // Setup canvas - this.drawCanvas = document.createElement('canvas') - this.context = this.drawCanvas.getContext('2d') + this.drawCanvas = document.createElement("canvas"); + this.context = this.drawCanvas.getContext("2d"); } } export class BaseAnnotationTrack { - constructor (width, near, far, visibleHeight, minHeight, colorSchema) { + constructor(width, near, far, visibleHeight, minHeight, colorSchema) { // Track variables - this.featureHeight = 20 // Max height for feature - this.featureMargin = 14 // Margin for fitting gene name under track - this.yPos = this.featureHeight / 2 // First y-position - this.arrowColor = 'white' - this.arrowWidth = 4 - this.arrowDistance = 200 - this.arrowThickness = 1 - this.expanded = false - this.colorSchema = colorSchema + this.featureHeight = 20; // Max height for feature + this.featureMargin = 14; // Margin for fitting gene name under track + this.yPos = this.featureHeight / 2; // First y-position + this.arrowColor = "white"; + this.arrowWidth = 4; + this.arrowDistance = 200; + this.arrowThickness = 1; + this.expanded = false; + this.colorSchema = colorSchema; // errors preventing fetching of data - this.preventDrawingTrack = false - + this.preventDrawingTrack = false; // Dimensions of track canvas - this.width = Math.round(width) // Width of displayed canvas - this.drawCanvasMultiplier = 4 - this.maxHeight = 16000 // Max height of canvas - this.visibleHeight = visibleHeight // Visible height for expanded canvas, overflows for scroll - this.minHeight = minHeight // Minimized height + this.width = Math.round(width); // Width of displayed canvas + this.drawCanvasMultiplier = 4; + this.maxHeight = 16000; // Max height of canvas + this.visibleHeight = visibleHeight; // Visible height for expanded canvas, overflows for scroll + this.minHeight = minHeight; // Minimized height // Canvases // the drawCanvas is used to draw objects offscreen // the region to be displayed is blitted to the onscreen contentCanvas - this.trackContainer = null // Set in parent class - this.drawCanvas = null // Set in parent class + this.trackContainer = null; // Set in parent class + this.drawCanvas = null; // Set in parent class // Canvases for static content - this.contentCanvas = null - this.trackTitle = null // Set in parent class + this.contentCanvas = null; + this.trackTitle = null; // Set in parent class // data cache - this.trackData = null + this.trackData = null; // Store coordinates of offscreen canvas - this.offscreenPosition = { start: null, end: null, scale: null } - this.onscreenPosition = { start: null, end: null } + this.offscreenPosition = { start: null, end: null, scale: null }; + this.onscreenPosition = { start: null, end: null }; // Max resolution - this.maxResolution = 4 - this.geneticElements = [] // for tooltips + this.maxResolution = 4; + this.geneticElements = []; // for tooltips } - tracksYPos (heightOrder) { - return this.yPos + (heightOrder - 1) * (this.featureHeight + this.featureMargin) - }; + tracksYPos(heightOrder) { + return ( + this.yPos + (heightOrder - 1) * (this.featureHeight + this.featureMargin) + ); + } - setupHTML (xPos) { - this.contentCanvas.style.width = this.width + 'px' + setupHTML(xPos) { + this.contentCanvas.style.width = this.width + "px"; // Setup variant canvas - this.trackContainer.style.marginLeft = xPos + 'px' - this.trackContainer.style.width = this.width + 'px' + this.trackContainer.style.marginLeft = xPos + "px"; + this.trackContainer.style.width = this.width + "px"; // set xlabel - this.trackContainer - .parentElement - .querySelector('.track-xlabel') - .style - .left = `${xPos - 60}px` + this.trackContainer.parentElement.querySelector( + ".track-xlabel", + ).style.left = `${xPos - 60}px`; // Setup initial track Canvas - this.drawCtx = this.drawCanvas.getContext('2d') - this.drawCanvas.width = this.width * this.drawCanvasMultiplier - this.drawCanvas.height = this.maxHeight - this.contentCanvas.width = this.width - this.contentCanvas.height = this.minHeight + this.drawCtx = this.drawCanvas.getContext("2d"); + this.drawCanvas.width = this.width * this.drawCanvasMultiplier; + this.drawCanvas.height = this.maxHeight; + this.contentCanvas.width = this.width; + this.contentCanvas.height = this.minHeight; // Setup track div - this.trackTitle.style.width = this.width + 'px' - this.trackTitle.style.height = this.minHeight + 'px' + this.trackTitle.style.width = this.width + "px"; + this.trackTitle.style.height = this.minHeight + "px"; - this.trackContainer.parentElement.addEventListener('draw', (event) => { - console.log('track recived draw', event.detail.region) - this.drawTrack({ ...event.detail.region }) - }) + this.trackContainer.parentElement.addEventListener("draw", (event) => { + this.drawTrack({ ...event.detail.region }); + }); // Setup context menu - this.trackContainer.addEventListener('contextmenu', + this.trackContainer.addEventListener( + "contextmenu", async (event) => { - event.preventDefault() + event.preventDefault(); // hide all tooltips for (const element of this.geneticElements) { - if (element.tooltip) hideTooltip(element.tooltip) + if (element.tooltip) hideTooltip(element.tooltip); } // Toggle between expanded/collapsed view - this.expanded = !this.expanded + this.expanded = !this.expanded; // set datastate for css if (this.expanded) { - this.trackContainer.setAttribute('data-state', 'expanded') + this.trackContainer.setAttribute("data-state", "expanded"); } else { - this.trackContainer.setAttribute('data-state', 'collapsed') + this.trackContainer.setAttribute("data-state", "collapsed"); } await this.drawOffScreenTrack({ startPos: this.offscreenPosition.start, endPos: this.offscreenPosition.end, maxHeightOrder: this.expanded ? this.trackData.max_height_order : 1, - data: this.trackData - }) - this.blitCanvas(this.onscreenPosition.start, this.onscreenPosition.end) - }, false) + data: this.trackData, + }); + this.blitCanvas(this.onscreenPosition.start, this.onscreenPosition.end); + this.drawDynamicOverlay(); + }, + false, + ); } // Clears previous tracks - clearTracks () { + clearTracks() { // Clear canvas - this.drawCtx.clearRect(0, 0, this.drawCanvas.width, this.drawCanvas.height) + this.drawCtx.clearRect(0, 0, this.drawCanvas.width, this.drawCanvas.height); // Clear tooltip titles - this.trackTitle.innerHTML = '' + this.trackTitle.innerHTML = ""; } // Sets the container height depending on maximum height of tracks - setContainerHeight (maxHeightOrder) { + setContainerHeight(maxHeightOrder) { if (maxHeightOrder === 0) { // No results, do not show tracks - this.contentCanvas.height = 0 - this.trackTitle.style.height = '0px' - this.trackContainer.style.height = '0px' + this.contentCanvas.height = 0; + this.trackTitle.style.height = "0px"; + this.trackContainer.style.height = "0px"; // hide parent element - this.trackContainer.parentElement.setAttribute('data-state', 'nodata') + this.trackContainer.parentElement.setAttribute("data-state", "nodata"); } else { - this.trackContainer.parentElement.setAttribute('data-state', 'data') + this.trackContainer.parentElement.setAttribute("data-state", "data"); // controll track content if (this.expanded) { // Set variables for an expanded view - const maxYPos = this.tracksYPos(maxHeightOrder + 1) - this.contentCanvas.height = maxYPos - this.drawCanvas.height = maxYPos - this.trackTitle.style.height = `${maxYPos}px` - this.trackContainer.style.height = `${this.visibleHeight}px` - this.trackContainer.setAttribute('data-state', 'expanded') + const maxYPos = this.tracksYPos(maxHeightOrder + 1); + this.contentCanvas.height = maxYPos; + this.drawCanvas.height = maxYPos; + this.trackTitle.style.height = `${maxYPos}px`; + this.trackContainer.style.height = `${this.visibleHeight}px`; + this.trackContainer.setAttribute("data-state", "expanded"); } else { // Set variables for a collapsed view - this.contentCanvas.height = this.minHeight - this.drawCanvas.height = this.minHeight - this.trackTitle.style.height = `${this.minHeight}px` - this.trackContainer.style.height = `${this.minHeight}px` - this.trackContainer.setAttribute('data-state', 'collapsed') + this.contentCanvas.height = this.minHeight; + this.drawCanvas.height = this.minHeight; + this.trackTitle.style.height = `${this.minHeight}px`; + this.trackContainer.style.height = `${this.minHeight}px`; + this.trackContainer.setAttribute("data-state", "collapsed"); } } } @@ -183,81 +197,97 @@ export class BaseAnnotationTrack { // if new chromosome selected --> cache all annotations for chrom // if new region in offscreen canvas --> blit image // if new region outside offscreen canvas --> redraw offscreen using cache - async drawTrack ({ chrom, start, end, forceRedraw = false, hideWhileLoading = false }) { - if (this.preventDrawingTrack) return // disable drawing track + async drawTrack({ + chrom, + start, + end, + forceRedraw = false, + hideWhileLoading = false, + }) { + if (this.preventDrawingTrack) return; // disable drawing track // store genomic position of the region to draw - this.onscreenPosition.start = start - this.onscreenPosition.end = end - const width = end - start + 1 - let updatedData = false + this.onscreenPosition.start = start; + this.onscreenPosition.end = end; + const width = end - start + 1; + let updatedData = false; // verify that // 1. data is loaded // 2. right chromosome is loaded // 3. right expansion - if (!this.trackData || - this.trackData.chromosome !== chrom || - forceRedraw) { + if (!this.trackData || this.trackData.chromosome !== chrom || forceRedraw) { // hide track while loading - if (hideWhileLoading) this.trackContainer.parentElement.setAttribute('data-state', 'nodata') + if (hideWhileLoading) + this.trackContainer.parentElement.setAttribute("data-state", "nodata"); // request new data this.trackData = await get( this.apiEntrypoint, - Object.assign({ // build query parameters - sample_id: oc.sampleName, - region: `${chrom}:1-None`, - genome_build: this.genomeBuild, - collapsed: false // allways get all height orders - }, this.additionalQueryParams) // parameters specific to track type - ) + Object.assign( + { + // build query parameters + sample_id: this.sampleName, + region: `${chrom}:1-None`, + genome_build: this.genomeBuild, + collapsed: false, // always get all height orders + }, + this.additionalQueryParams, + ), // parameters specific to track type + ); // disable track if data loading encountered an error - if (this.trackData.status === 'error') { - this.trackContainer.parentElement.setAttribute('data-state', 'nodata') - this.preventDrawingTrack = true - return + if (this.trackData.status === "error") { + this.trackContainer.parentElement.setAttribute("data-state", "nodata"); + this.preventDrawingTrack = true; + return; } // the track data is used to determine the new start/ end positions - end = end > this.trackData.end_pos ? this.trackData.end_pos : end - updatedData = true + end = end > this.trackData.end_pos ? this.trackData.end_pos : end; + updatedData = true; } // redraw offscreen canvas if, // 1. not drawn before; // 2. if onscreen canvas close of offscreen canvas edge // 3. size of region has been changed, zoom in or out - if (!this.offscreenPosition.start || - start < this.offscreenPosition.start + width || - this.offscreenPosition.end - width < end || - this.offscreenPosition.scale !== this.contentCanvas.width / (end - start) || - updatedData + if ( + !this.offscreenPosition.start || + start < this.offscreenPosition.start + width || + this.offscreenPosition.end - width < end || + this.offscreenPosition.scale !== + this.contentCanvas.width / (end - start) || + updatedData ) { const offscreenPos = calculateOffscreenWindowPos({ - start: start, end: end, multiplier: this.drawCanvasMultiplier - }) + start: start, + end: end, + multiplier: this.drawCanvasMultiplier, + }); // draw offscreen position for the first time await this.drawOffScreenTrack({ startPos: offscreenPos.start, endPos: offscreenPos.end, maxHeightOrder: this.trackData.max_height_order, - data: this.trackData - }) + data: this.trackData, + }); } // blit image from offscreen canvas to onscreen canvas - this.blitCanvas(start, end) + this.blitCanvas(start, end); + this.drawDynamicOverlay(); } // blit drawCanvas to content canvas. - blitCanvas (chromStart, chromEnd) { + blitCanvas(chromStart, chromEnd) { // blit drawCanvas to content canvas. // clear current canvas - const ctx = this.contentCanvas.getContext('2d') - ctx.clearRect(0, 0, this.contentCanvas.width, - this.contentCanvas.height) - const width = chromEnd - chromStart - this.onscreenPosition = { start: chromStart, end: chromEnd } // store onscreen coords + const ctx = this.contentCanvas.getContext("2d"); + ctx.clearRect(0, 0, this.contentCanvas.width, this.contentCanvas.height); + const width = chromEnd - chromStart; + this.onscreenPosition = { start: chromStart, end: chromEnd }; // store onscreen coords // Debugging - const offscreenOffset = Math.round((chromStart - this.offscreenPosition.start) * this.offscreenPosition.scale) - const elementWidth = Math.round(width * this.offscreenPosition.scale) + const offscreenOffset = Math.round( + (chromStart - this.offscreenPosition.start) * + this.offscreenPosition.scale, + ); + const elementWidth = Math.round(width * this.offscreenPosition.scale); // normalize the genomic coordinates to screen coordinates ctx.drawImage( @@ -269,27 +299,29 @@ export class BaseAnnotationTrack { 0, // dX 0, // dY this.contentCanvas.width, // dWidth - this.drawCanvas.height // dHeight - ) + this.drawCanvas.height, // dHeight + ); } + drawDynamicOverlay() {} + // Classify the resolution wich can be used chose when to display variants - get getResolution () { - const width = this.onscreenPosition.end - this.onscreenPosition.start + 1 - let resolution + get getResolution() { + const width = this.onscreenPosition.end - this.onscreenPosition.start + 1; + let resolution; if (width > 5 * Math.pow(10, 7)) { - resolution = 6 + resolution = 6; } else if (width > 1.5 * Math.pow(10, 7)) { - resolution = 5 + resolution = 5; } else if (width > 4 * Math.pow(10, 6)) { - resolution = 4 + resolution = 4; } else if (width > 1 * Math.pow(10, 6)) { - resolution = 3 + resolution = 3; } else if (width > 2 * Math.pow(10, 5)) { - resolution = 2 + resolution = 2; } else { - resolution = 1 + resolution = 1; } - return resolution + return resolution; } } diff --git a/assets/js/track/base.test.js b/assets/js/track/base.test.js index b6ded476..79ff9436 100644 --- a/assets/js/track/base.test.js +++ b/assets/js/track/base.test.js @@ -1,16 +1,23 @@ // Test tracks -import { calculateOffscreenWindowPos } from './base.js' +import { calculateOffscreenWindowPos } from "./base.js"; import "regenerator-runtime/runtime"; - // test that offscreen window position -describe('Test calculateOffscreenWindowPos', () => { - test('test no padding', () => { - const region = calculateOffscreenWindowPos({start: 100, end: 200, multiplier: 1}) - expect(region).toEqual({start: 100, end: 200}) - }) - test('test padding to region', () => { - const region = calculateOffscreenWindowPos({start: 100, end: 200, multiplier: 2}) - expect(region).toEqual({start: 50, end: 250}) - }) -}) +describe("Test calculateOffscreenWindowPos", () => { + test("test no padding", () => { + const region = calculateOffscreenWindowPos({ + start: 100, + end: 200, + multiplier: 1, + }); + expect(region).toEqual({ start: 100, end: 200 }); + }); + test("test padding to region", () => { + const region = calculateOffscreenWindowPos({ + start: 100, + end: 200, + multiplier: 2, + }); + expect(region).toEqual({ start: 50, end: 250 }); + }); +}); diff --git a/assets/js/track/constants.js b/assets/js/track/constants.js index ddd54f5e..af1da0f6 100644 --- a/assets/js/track/constants.js +++ b/assets/js/track/constants.js @@ -1,6 +1,29 @@ // constants used throughout gens -const CHROMOSOMES = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', - '11', '12', '13', '14', '15', '16', '17', '18', '19', '20', '21', - '22', 'X', 'Y'] -export { CHROMOSOMES } +const CHROMOSOMES = [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "19", + "20", + "21", + "22", + "X", + "Y", +]; +export { CHROMOSOMES }; diff --git a/assets/js/track/ideogram.js b/assets/js/track/ideogram.js index d3956d05..a2466ef3 100644 --- a/assets/js/track/ideogram.js +++ b/assets/js/track/ideogram.js @@ -1,252 +1,337 @@ // Cytogenetic ideogram -import { get } from '../fetch.js' -import { drawRect } from '../draw.js' -import { lightenColor } from './base.js' -import tippy, { followCursor } from 'tippy.js' -import 'tippy.js/dist/tippy.css'; -import { isElementOverlapping } from './utils.js'; -import { thisExpression } from '@babel/types'; +import { get } from "../fetch.js"; +import { drawRect } from "../draw.js"; +import { lightenColor } from "./base.js"; +import tippy, { followCursor } from "tippy.js"; +import "tippy.js/dist/tippy.css"; +import { isElementOverlapping } from "./utils.js"; export class CytogeneticIdeogram { - constructor({targetId, genomeBuild, x, y, width, height}) { + constructor({ targetId, genomeBuild, x, y, width, height }) { // define core varialbes - this.genomeBuild = genomeBuild - this.x = x - this.y = y - this.plotWidth = width - this.plotHeight = height + this.genomeBuild = genomeBuild; + this.x = x; + this.y = y; + this.plotWidth = width; + this.plotHeight = height; // Setup cytogenetic ideogram image - this.targetElement = document.getElementById(targetId) - this.targetElement.style.height = `${height + 10}px` + this.targetElement = document.getElementById(targetId); + this.targetElement.style.height = `${height + 10}px`; // create canvas and append to target section of dom - const canvas = document.createElement('canvas') - canvas.style.marginLeft = `${x}px` - canvas.width = width - canvas.height = height - this.targetElement.appendChild(canvas) - this.canvas = canvas - this.ctx = canvas.getContext('2d') + const canvas = document.createElement("canvas"); + canvas.style.marginLeft = `${x}px`; + canvas.width = width; + canvas.height = height; + this.targetElement.appendChild(canvas); + this.canvas = canvas; + this.ctx = canvas.getContext("2d"); // create region marker - const markerElement = document.createElement('div') - markerElement.id = "ideogram-marker" - markerElement.classList = ["marker"] - markerElement.style.height = `${height - 4}px` - markerElement.style.width = 0 - markerElement.style.top = `-${height -4}px` - markerElement.style.marginLeft = `${x}px` - this.targetElement.appendChild(markerElement) + const markerElement = document.createElement("div"); + markerElement.id = "ideogram-marker"; + markerElement.classList = ["marker"]; + markerElement.style.height = `${height - 4}px`; + markerElement.style.width = 0; + markerElement.style.top = `-${height - 4}px`; + markerElement.style.marginLeft = `${x}px`; + this.targetElement.appendChild(markerElement); // chromosomeImage - this.drawPaths = null + this.drawPaths = null; // define tooltip element - const tooltip = createChromosomeTooltip({}) + const tooltip = createChromosomeTooltip({}); tippy(canvas, { arrow: true, - followCursor: 'horizontal', + followCursor: "horizontal", content: tooltip, - plugins: [followCursor] - }) + plugins: [followCursor], + }); + + // register event handler for updating popups + const ctx = canvas.getContext("2d"); + + canvas.addEventListener("mousemove", (event) => { + if (this.drawPaths !== null) { + this.drawPaths.bands.forEach((bandPath) => { + if (ctx.isPointInPath(bandPath.path, event.offsetX, event.offsetY)) { + tooltip.querySelector(".ideogram-tooltip-value").innerHTML = + bandPath.id; + } + }); + } + }); - // register event handeler for updating popups - canvas.addEventListener('mousemove', (event) => { - this.drawPaths !== null && this.drawPaths.bands.map((bandPath) => { - const ctx = canvas.getContext('2d') - if (ctx.isPointInPath(bandPath.path, event.offsetX, event.offsetY)) { - tooltip.querySelector('.ideogram-tooltip-value').innerHTML = bandPath.id - } - }) - }) // register event for moving and zooming region marker - this.targetElement.addEventListener('mark-region', (event) => { + this.targetElement.addEventListener("mark-region", (event) => { // if marking a subset of chromosome - const { chrom, start, end } = event.detail.region + const { chrom, start, end } = event.detail.region; // get marker element - const markerElement = document.getElementById('ideogram-marker') - if (this.drawPaths !== null && chrom === this.drawPaths.chromosome.chromInfo.chrom) { + const markerElement = document.getElementById("ideogram-marker"); + if ( + this.drawPaths !== null && + chrom === this.drawPaths.chromosome.chromInfo.chrom + ) { // if segment of chromosome is drawn - const { scale, x } = this.drawPaths.chromosome.chromInfo - markerElement.hidden = false // display marker - markerElement.style.marginLeft = `${Math.round(x + (start * scale))}px` - markerElement.style.width = `${Math.round((end - start + 1) * scale)}px` + const { scale, x } = this.drawPaths.chromosome.chromInfo; + markerElement.hidden = false; // display marker + markerElement.style.marginLeft = `${Math.round(x + start * scale)}px`; + markerElement.style.width = `${Math.round((end - start + 1) * scale)}px`; // dispatch event to update title - const scaledStart = Math.round(start * scale) - const scaledEnd = Math.round(end * scale) - const bandsWithinMarkedRegion = this.drawPaths.bands.filter((band) => isElementOverlapping({start: scaledStart, end: scaledEnd}, band)) - document.getElementById('visualization-container').dispatchEvent( - new CustomEvent('update-title', { - detail: { bands: bandsWithinMarkedRegion, chrom: chrom } }) - ) + const scaledStart = Math.round(start * scale); + const scaledEnd = Math.round(end * scale); + const bandsWithinMarkedRegion = this.drawPaths.bands.filter((band) => + isElementOverlapping({ start: scaledStart, end: scaledEnd }, band), + ); + document.getElementById("visualization-container").dispatchEvent( + new CustomEvent("update-title", { + detail: { bands: bandsWithinMarkedRegion, chrom: chrom }, + }), + ); } else { // if entire chromosome is drawn - markerElement.hidden = true // hide marker + markerElement.hidden = true; // hide marker } - }) + }); // register event for moving and zooming region marker - this.targetElement.addEventListener('draw', (event) => { + this.targetElement.addEventListener("draw", (event) => { // check if this is supposed to be excluded - if ( !event.detail.exclude.includes(this.targetElement.id) ) { + if (!event.detail.exclude.includes(this.targetElement.id)) { // remove old figures - this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) - this.ctx.save() - markerElement.style.width = 0 + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.ctx.save(); + markerElement.style.width = 0; // draw new figure cytogeneticIdeogram({ ctx: this.ctx, chromosomeName: event.detail.region.chrom, - x: this.x, - width: this.plotWidth, + x: this.x, + width: this.plotWidth, height: this.plotHeight, genomeBuild: this.genomeBuild, - }).then((paths) => { - this.drawPaths = paths - }) + }).then((paths) => { + this.drawPaths = paths; + }); } - }) + }); } } -export function setupGenericEventManager({eventName, ownerElement, targetElementIds}) { - // pass directed from owner element to taget elements +export function setupGenericEventManager({ + eventName, + ownerElement, + targetElementIds, +}) { + // pass directed from owner element to target elements ownerElement.addEventListener(eventName, (event) => { - targetElementIds.map((id) => { - document.getElementById(id).dispatchEvent( - new CustomEvent(eventName, {detail: event.detail}) - ) - }) - }) + targetElementIds.forEach((id) => { + document + .getElementById(id) + .dispatchEvent(new CustomEvent(eventName, { detail: event.detail })); + }); + }); } function createChromosomeTooltip({ bandId }) { - const element = document.createElement('div') - element.id = 'ideogram-tooltip' - const name = document.createElement('span') - name.innerHTML = 'ID:' - name.classList = ["ideogram-tooltip-key"] - element.appendChild(name) - const value = document.createElement('span') - value.classList = ["ideogram-tooltip-value"] - element.appendChild(value) - return element + const element = document.createElement("div"); + element.id = "ideogram-tooltip"; + const name = document.createElement("span"); + name.innerHTML = "ID:"; + name.classList = ["ideogram-tooltip-key"]; + element.appendChild(name); + const value = document.createElement("span"); + value.classList = ["ideogram-tooltip-value"]; + element.appendChild(value); + return element; } -export async function cytogeneticIdeogram({ctx, chromosomeName, genomeBuild, x, width, height}) { - const chromInfo = await getChromosomeInfo(chromosomeName, genomeBuild) +export async function cytogeneticIdeogram({ + ctx, + chromosomeName, + genomeBuild, + x, + width, + height, +}) { + const chromInfo = await getChromosomeInfo(chromosomeName, genomeBuild); // recalculate genomic coordinates to screen coordinates - const scale = width / chromInfo.size - const centromere = chromInfo.centromere !== null ? - { start: Math.round(chromInfo.centromere.start * scale), end: Math.round(chromInfo.centromere.end * scale) } : null + const scale = width / chromInfo.size; + const centromere = + chromInfo.centromere !== null + ? { + start: Math.round(chromInfo.centromere.start * scale), + end: Math.round(chromInfo.centromere.end * scale), + } + : null; const drawPaths = drawChromosome({ - ctx: ctx, x: 3, y: 5, + ctx: ctx, + x: 3, + y: 5, width: width - 5, height: height - 6, centromere, - color: 'white', + color: "white", bands: chromInfo.bands.map((band) => { - band.start = Math.round(band.start * scale) - band.end = Math.round(band.end * scale) - return band - }) - }) + band.start = Math.round(band.start * scale); + band.end = Math.round(band.end * scale); + return band; + }), + }); drawPaths.chromosome.chromInfo = { chrom: chromosomeName, x: x, width: width, scale: scale, size: chromInfo.size, - } - return drawPaths + }; + return drawPaths; } async function getChromosomeInfo(chromosomeName, genomeBuild) { - const result = await get('get-chromosome-info', { chromosome: chromosomeName, genome_build: genomeBuild }) - return result + const result = await get("get-chromosome-info", { + chromosome: chromosomeName, + genome_build: genomeBuild, + }); + return result; } -function drawChromosome({ ctx, x, y, width, height, centromere, bands, color, lineColor }) { - const basePosColor = '#000' // dark green +function drawChromosome({ + ctx, + x, + y, + width, + height, + centromere, + bands, + color, + lineColor, +}) { + const basePosColor = "#000"; // dark green const bandColors = { - gneg: '#FFFAF0', - acen: '#673888', - gvar: '#4C6D94', + gneg: "#FFFAF0", + acen: "#673888", + gvar: "#4C6D94", gpos25: lightenColor(basePosColor, 75), gpos50: lightenColor(basePosColor, 50), gpos75: lightenColor(basePosColor, 25), gpos100: basePosColor, - } + }; const chromPath = { - path: drawChromosomeShape({ ctx, x, y, width, height, centromere, color, lineColor }) - } - - ctx.clip(chromPath.path) - const bandPaths = bands.map((band) => { - band.path = drawRect({ + path: drawChromosomeShape({ ctx, - x: x + band.start, - y: y - 5, - width: band.end - band.start, - height: height + 5, - color: bandColors[band.stain], - fillColor: bandColors[band.stain], + x, + y, + width, + height, + centromere, + color, + lineColor, + }), + }; + + ctx.clip(chromPath.path); + const bandPaths = bands + .map((band) => { + band.path = drawRect({ + ctx, + x: x + band.start, + y: y - 5, + width: band.end - band.start, + height: height + 5, + color: bandColors[band.stain], + fillColor: bandColors[band.stain], + }); + band.x = x + band.start; + band.y = y - 5; + band.width = band.end - band.start; + band.height = height + 5; + return band; }) - band.x = x + band.start - band.y = y - 5 - band.width = band.end - band.start - band.height = height + 5 - return band - }).filter((band) => { return band.path !== null }) - ctx.restore() - return { chromosome: chromPath, bands: bandPaths } + .filter((band) => { + return band.path !== null; + }); + ctx.restore(); + return { chromosome: chromPath, bands: bandPaths }; } -function drawChromosomeShape({ ctx, x, y, width, height, centromere, color, lineColor = '#000' }) { +function drawChromosomeShape({ + ctx, + x, + y, + width, + height, + centromere, + color, + lineColor = "#000", +}) { // draw shape of chromosome // define proportions of shape - const endBevelProportion = 0.05 - const centromereIndentProportion = 0.3 + const endBevelProportion = 0.05; + const centromereIndentProportion = 0.3; // compute basic meassurement - const bevelWidth = Math.round(width * endBevelProportion) + const bevelWidth = Math.round(width * endBevelProportion); // cacluate dimensions of the centromere - const centromereLenght = centromere.end - centromere.start - const centromereIndent = Math.round(height * centromereIndentProportion) - const centromereIndentRadius = centromereIndent * 2.5 < centromereLenght / 3 ? Math.round(centromereIndent * 2.5) : centromereLenght / 3 - const centromereCenter = centromere.start + Math.round((centromere.end - centromere.start) / 2) + const centromereLenght = centromere.end - centromere.start; + const centromereIndent = Math.round(height * centromereIndentProportion); + const centromereIndentRadius = + centromereIndent * 2.5 < centromereLenght / 3 + ? Math.round(centromereIndent * 2.5) + : centromereLenght / 3; + const centromereCenter = + centromere.start + Math.round((centromere.end - centromere.start) / 2); - const chromEndRadius = Math.round(height * .7 / 2) + const chromEndRadius = Math.round((height * 0.7) / 2); // path object - const path = new Path2D() + const path = new Path2D(); // draw shape - path.moveTo(x + bevelWidth, y) // move to start + path.moveTo(x + bevelWidth, y); // move to start // handle centromere if (centromere) { - path.lineTo(centromere.start, y) + path.lineTo(centromere.start, y); // indent for centromere - path.arcTo(centromereCenter, y + centromereIndent, centromere.end, y, centromereIndentRadius) - path.lineTo(centromere.end, y) + path.arcTo( + centromereCenter, + y + centromereIndent, + centromere.end, + y, + centromereIndentRadius, + ); + path.lineTo(centromere.end, y); } - path.lineTo(x + width - bevelWidth, y) // line to end cap + path.lineTo(x + width - bevelWidth, y); // line to end cap // right end cap - path.arcTo(x + width, y, x + width, y + (height / 2), chromEndRadius) - path.arcTo(x + width, y + height, x + width - bevelWidth, y + height, chromEndRadius) + path.arcTo(x + width, y, x + width, y + height / 2, chromEndRadius); + path.arcTo( + x + width, + y + height, + x + width - bevelWidth, + y + height, + chromEndRadius, + ); // bottom line if (centromere) { - path.lineTo(centromere.end, y + height) - path.arcTo(centromereCenter, (y + height) - centromereIndent, centromere.start, y + height, centromereIndentRadius) - path.lineTo(centromere.start, y + height) + path.lineTo(centromere.end, y + height); + path.arcTo( + centromereCenter, + y + height - centromereIndent, + centromere.start, + y + height, + centromereIndentRadius, + ); + path.lineTo(centromere.start, y + height); } - path.lineTo(x + bevelWidth, y + height) + path.lineTo(x + bevelWidth, y + height); // left end cap - path.arcTo(x, y + height, x, y + (height / 2), chromEndRadius) - path.arcTo(x, y, x + bevelWidth, y, chromEndRadius) + path.arcTo(x, y + height, x, y + height / 2, chromEndRadius); + path.arcTo(x, y, x + bevelWidth, y, chromEndRadius); // finish figure - path.closePath() + path.closePath(); // setup coloring - ctx.strokeStyle = lineColor - ctx.stroke(path) + ctx.strokeStyle = lineColor; + ctx.stroke(path); if (color !== undefined) { - ctx.fillStyle = color - ctx.fill(path) + ctx.fillStyle = color; + ctx.fill(path); } - return path -} \ No newline at end of file + return path; +} diff --git a/assets/js/track/tooltip.js b/assets/js/track/tooltip.js index e7f34cc9..87e55bc8 100644 --- a/assets/js/track/tooltip.js +++ b/assets/js/track/tooltip.js @@ -1,216 +1,251 @@ // functions for handling tooltips -import { getVisibleXCoordinates, getVisibleYCoordinates, isWithinElementBbox } from './utils.js' +import { + getVisibleXCoordinates, + getVisibleYCoordinates, + isWithinElementBbox, +} from "./utils.js"; -// make virtual DOM element that represents a annoatation element -export function makeVirtualDOMElement ({ x1, x2, y1, y2, canvas }) { - return { getBoundingClientRect: generateGetBoundingClientRect(x1, x2, y1, y2, canvas) } +// make virtual DOM element that represents a annotation element +export function makeVirtualDOMElement({ x1, x2, y1, y2, canvas }) { + return { + getBoundingClientRect: generateGetBoundingClientRect( + x1, + x2, + y1, + y2, + canvas, + ), + }; } - // Make a virtual DOM element from a genetic element object -export function generateGetBoundingClientRect (x1, x2, y1, y2, canvas) { - const track = canvas +export function generateGetBoundingClientRect(x1, x2, y1, y2, canvas) { + const track = canvas; return () => ({ width: Math.round(x2 - x1), height: Math.round(y2 - y1), top: y1 + Math.round(track.getBoundingClientRect().y), left: x1 + Math.round(track.getBoundingClientRect().x), right: x2 + Math.round(track.getBoundingClientRect().x), - bottom: y2 + Math.round(track.getBoundingClientRect().y) - }) + bottom: y2 + Math.round(track.getBoundingClientRect().y), + }); } -export function updateVisableElementCoordinates ({ element, screenPosition, scale }) { - const { x1, x2 } = getVisibleXCoordinates({ canvas: screenPosition, feature: element, scale: scale }) - const { y1, y2 } = getVisibleYCoordinates({ element }) +export function updateVisibleElementCoordinates({ + element, + screenPosition, + scale, +}) { + const { x1, x2 } = getVisibleXCoordinates({ + canvas: screenPosition, + feature: element, + scale: scale, + }); + const { y1, y2 } = getVisibleYCoordinates({ element }); // update coordinates - element.visibleX1 = x1 - element.visibleX2 = x2 - element.visibleY1 = y1 - element.visibleY2 = y2 + element.visibleX1 = x1; + element.visibleX2 = x2; + element.visibleY1 = y1; + element.visibleY2 = y2; } -function showTooltip ({ tooltip, feature }) { - tooltip.tooltip.setAttribute('data-show', '') +function showTooltip({ tooltip, feature }) { + tooltip.tooltip.setAttribute("data-show", ""); if (feature !== undefined) { - const featureElement = tooltip.tooltip.querySelector(`#feature-${feature.id}`) - featureElement.setAttribute('data-show', '') - feature.isDisplayed = true + const featureElement = tooltip.tooltip.querySelector( + `#feature-${feature.id}`, + ); + featureElement.setAttribute("data-show", ""); + feature.isDisplayed = true; } - tooltip.isDisplayed = true + tooltip.isDisplayed = true; } -function hideFeatureInTooltip ({ tooltip, feature }) { - const selectedFeature = tooltip.tooltip.querySelector(`#feature-${feature.id}`) - selectedFeature.removeAttribute('data-show') - feature.isDisplayed = false +function hideFeatureInTooltip({ tooltip, feature }) { + const selectedFeature = tooltip.tooltip.querySelector( + `#feature-${feature.id}`, + ); + selectedFeature.removeAttribute("data-show"); + feature.isDisplayed = false; } -export function hideTooltip (tooltip) { +export function hideTooltip(tooltip) { // skip if tooltip has not been rendered - tooltip.tooltip.removeAttribute('data-show') - for (const feature of tooltip.tooltip.querySelectorAll('.feature')) { - feature.removeAttribute('data-show') + tooltip.tooltip.removeAttribute("data-show"); + for (const feature of tooltip.tooltip.querySelectorAll(".feature")) { + feature.removeAttribute("data-show"); } - tooltip.isDisplayed = false + tooltip.isDisplayed = false; } -export function createHtmlList (information) { - const list = document.createElement('ul') +export function createHtmlList(information) { + const list = document.createElement("ul"); for (const info of information) { - const li = document.createElement('li') - const bold = document.createElement('strong') - bold.innerText = info.title - li.innerText = bold.innerHTML += `: ${info.value}` - list.appendChild(li) + const li = document.createElement("li"); + const bold = document.createElement("strong"); + bold.innerText = info.title; + li.innerText = bold.innerHTML += `: ${info.value}`; + list.appendChild(li); } - return list + return list; } // create popover html element with message -export function createTooltipElement ({ id, title, information = [] }) { +export function createTooltipElement({ id, title, information = [] }) { // create popover base class - const popover = document.createElement('div') - popover.setAttribute('role', 'popover') + const popover = document.createElement("div"); + popover.setAttribute("role", "popover"); if (id !== undefined) { - popover.id = id + popover.id = id; } - popover.classList.add('tooltip') - popover.setAttribute('role', 'popover') + popover.classList.add("tooltip"); + popover.setAttribute("role", "popover"); // create information in container element - const container = document.createElement('div') - container.classList.add('tooltip-content') + const container = document.createElement("div"); + container.classList.add("tooltip-content"); // create title - const titleElem = document.createElement('h4') - titleElem.innerText = title - container.appendChild(titleElem) + const titleElem = document.createElement("h4"); + titleElem.innerText = title; + container.appendChild(titleElem); // create information list - const body = createHtmlList(information) - container.appendChild(body) - popover.appendChild(container) + const body = createHtmlList(information); + container.appendChild(body); + popover.appendChild(container); // return tooltip element - return popover + return popover; } // function for handeling apperance and content of tooltips // element == a the main rendered element, a gene for instance // features == genetic sub components of the parent elements, for instance a exome -function tooltipHandler (event, track) { - event.preventDefault() - event.stopPropagation() - const point = { x: event.offsetX, y: event.offsetY } +function tooltipHandler(event, track) { + event.preventDefault(); + event.stopPropagation(); + const point = { x: event.offsetX, y: event.offsetY }; for (const element of track.geneticElements) { - if ( !element.tooltip) { - continue + if (!element.tooltip) { + continue; } const isInElement = isWithinElementBbox({ element: { x1: element.visibleX1, x2: element.visibleX2, y1: element.y1, - y2: element.y2 + y2: element.y2, }, - point - }) + point, + }); if (isInElement) { // check if pointer is in a feature of element - let selectedFeature + let selectedFeature; for (const feature of element.features) { const isInFeature = isWithinElementBbox({ element: { x1: feature.visibleX1, x2: feature.visibleX2, y1: feature.y1, - y2: feature.y2 + y2: feature.y2, }, - point - }) + point, + }); if (isInFeature && !feature.isDisplayed) { // show feature - selectedFeature = feature + selectedFeature = feature; } else if (!isInFeature && feature.isDisplayed) { - hideFeatureInTooltip({ tooltip: element.tooltip, feature }) + hideFeatureInTooltip({ tooltip: element.tooltip, feature }); } } - showTooltip({ tooltip: element.tooltip, feature: selectedFeature }) + showTooltip({ tooltip: element.tooltip, feature: selectedFeature }); } else { - hideTooltip(element.tooltip) + hideTooltip(element.tooltip); } - element.tooltip.instance.update() + element.tooltip.instance.update(); } } // update tooltip position -function updateTooltipPos (track) { +function updateTooltipPos(track) { for (const element of track.geneticElements) { // skip if tooltip has not been rendered - if ( !element.tooltip ) { - continue + if (!element.tooltip) { + continue; } // update coordinates for the main element - updateVisableElementCoordinates({ + updateVisibleElementCoordinates({ element, canvas: track.contentCanvas, screenPosition: track.onscreenPosition, - scale: track.offscreenPosition.scale - }) + scale: track.offscreenPosition.scale, + }); // update coordinates for features on element for (const feature of element.features) { - updateVisableElementCoordinates({ + updateVisibleElementCoordinates({ element: feature, canvas: track.contentCanvas, screenPosition: track.onscreenPosition, - scale: track.offscreenPosition.scale - }) + scale: track.offscreenPosition.scale, + }); } // update the virtual DOM element that defines the tooltip hitbox - const xPos = track.contentCanvas.getBoundingClientRect().x + const xPos = track.contentCanvas.getBoundingClientRect().x; element.tooltip.virtualElement = makeVirtualDOMElement({ x1: Math.round(element.visibleX1 + xPos), x2: Math.round(element.visibleX2 + xPos), y1: element.visibleY1, - y2: element.visibleY2 - }) + y2: element.visibleY2, + }); // update tooltip instance - element.tooltip.instance.update() + element.tooltip.instance.update(); } } // teardown tooltips generated for a track -function teardownTooltips (track) { +function teardownTooltips(track) { while (track.geneticElements.length) { - const element = track.geneticElements.shift() + const element = track.geneticElements.shift(); // skip if tooltip has not been rendered - if ( !element.tooltip ) { - continue + if (!element.tooltip) { + continue; } - element.tooltip.instance.destroy() // kill popper - track.trackContainer.querySelector(`#${element.tooltip.tooltip.id}`).remove() + element.tooltip.instance.destroy(); // kill popper + track.trackContainer + .querySelector(`#${element.tooltip.tooltip.id}`) + .remove(); } } - // initialize event listeners for hover function -export function initTrackTooltips (track) { +export function initTrackTooltips(track) { // when mouse is leaving track - track.trackContainer.addEventListener('mouseleave', - () => { - for (const element of track.geneticElements) { - if (element.tooltip) hideTooltip(element.tooltip) - } - }) + track.trackContainer.addEventListener("mouseleave", () => { + for (const element of track.geneticElements) { + if (element.tooltip) hideTooltip(element.tooltip); + } + }); // when mouse is leaving track - track.trackContainer.addEventListener('mousemove', (e) => { tooltipHandler(e, track) }) + track.trackContainer.addEventListener("mousemove", (e) => { + tooltipHandler(e, track); + }); // extend drawOffScreenTrack to teardown old tooltips prior to drawing new - const oldDrawOffscreenTrack = track.drawOffScreenTrack - track.drawOffScreenTrack = async ({ startPos, endPos, maxHeightOrder, data }) => { - teardownTooltips(track) - await oldDrawOffscreenTrack.call(track, { startPos, endPos, maxHeightOrder, data }) - } + const oldDrawOffscreenTrack = track.drawOffScreenTrack; + track.drawOffScreenTrack = async ({ + startPos, + endPos, + maxHeightOrder, + data, + }) => { + teardownTooltips(track); + await oldDrawOffscreenTrack.call(track, { + startPos, + endPos, + maxHeightOrder, + data, + }); + }; // extend instance function to recalculate positions of virtual dom elements - const oldBlit = track.blitCanvas + const oldBlit = track.blitCanvas; track.blitCanvas = (start, end) => { - updateTooltipPos(track) - oldBlit.call(track, start, end) - } + updateTooltipPos(track); + oldBlit.call(track, start, end); + }; } diff --git a/assets/js/track/transcript.js b/assets/js/track/transcript.js index c46b437d..f1528bc1 100644 --- a/assets/js/track/transcript.js +++ b/assets/js/track/transcript.js @@ -1,72 +1,79 @@ // Transcript definition -import { BaseAnnotationTrack, lightenColor } from './base.js' -import { initTrackTooltips, createTooltipElement, createHtmlList, makeVirtualDOMElement, updateVisableElementCoordinates } from './tooltip.js' -import { createPopper } from '@popperjs/core' -import { drawRect, drawLine, drawArrow, drawText } from '../draw.js' -import { getVisibleXCoordinates, isElementOverlapping } from './utils.js' +import { BaseAnnotationTrack, lightenColor } from "./base.js"; +import { + initTrackTooltips, + createTooltipElement, + createHtmlList, + makeVirtualDOMElement, + updateVisibleElementCoordinates, +} from "./tooltip.js"; +import { createPopper } from "@popperjs/core"; +import { drawRect, drawLine, drawArrow, drawText } from "../draw.js"; +import { getVisibleXCoordinates, isElementOverlapping } from "./utils.js"; // add feature information to tooltipElement -function addFeatures (elem, tooltipElement) { - const body = tooltipElement.querySelector('ul') +function addFeatures(elem, tooltipElement) { + const body = tooltipElement.querySelector("ul"); for (const feature of elem.features) { // divide and conquer - const featureContainer = document.createElement('div') - featureContainer.id = `feature-${feature.exon_number}` - featureContainer.classList.add('feature') - featureContainer.appendChild(document.createElement('hr')) + const featureContainer = document.createElement("div"); + featureContainer.id = `feature-${feature.exon_number}`; + featureContainer.classList.add("feature"); + featureContainer.appendChild(document.createElement("hr")); const information = [ - { title: 'exon', value: feature.exon_number }, - { title: 'position', value: `${feature.start}-${feature.end}` } - ] - featureContainer.appendChild(createHtmlList(information)) - body.appendChild(featureContainer) + { title: "exon", value: feature.exon_number }, + { title: "position", value: `${feature.start}-${feature.end}` }, + ]; + featureContainer.appendChild(createHtmlList(information)); + body.appendChild(featureContainer); } } export class TranscriptTrack extends BaseAnnotationTrack { - constructor (x, width, near, far, genomeBuild, colorSchema) { + constructor(x, width, near, far, genomeBuild, colorSchema) { // Dimensions of track canvas - const visibleHeight = 100 // Visible height for expanded canvas, overflows for scroll - const minHeight = 35 // Minimized height + const visibleHeight = 100; // Visible height for expanded canvas, overflows for scroll + const minHeight = 35; // Minimized height - super(width, near, far, visibleHeight, minHeight, colorSchema) + super(width, near, far, visibleHeight, minHeight, colorSchema); // Set inherited variables - this.drawCanvas = document.getElementById('transcript-draw') - this.contentCanvas = document.getElementById('transcript-content') - this.trackTitle = document.getElementById('transcript-titles') - this.trackContainer = document.getElementById('transcript-track-container') + this.drawCanvas = document.getElementById("transcript-draw"); + this.contentCanvas = document.getElementById("transcript-content"); + this.trackTitle = document.getElementById("transcript-titles"); + this.trackContainer = document.getElementById("transcript-track-container"); // Setup html objects now that we have gotten the canvas and div elements - this.setupHTML(x + 1) + this.setupHTML(x + 1); // GENS api parameters - this.apiEntrypoint = 'get-transcript-data' + this.apiEntrypoint = "get-transcript-data"; - this.genomeBuild = genomeBuild - this.maxResolution = 4 + this.genomeBuild = genomeBuild; + this.maxResolution = 4; // Define with of the elements - this.geneLineWidth = 2 - initTrackTooltips(this) + this.geneLineWidth = 2; + initTrackTooltips(this); } // draw feature - _drawFeature (feature, heightOrder, canvasYPos, - color, plotFormat) { + _drawFeature(feature, heightOrder, canvasYPos, color, plotFormat) { // Go trough feature list and draw geometries - const scale = this.offscreenPosition.scale + const scale = this.offscreenPosition.scale; // store feature rendering information - const x = scale * (feature.start - this.offscreenPosition.start) - const y = canvasYPos - this.featureHeight / 2 - const width = Math.round(scale * (feature.end - feature.start)) - const height = Math.round(this.featureHeight) + const x = scale * (feature.start - this.offscreenPosition.start); + const y = canvasYPos - this.featureHeight / 2; + const width = Math.round(scale * (feature.end - feature.start)); + const height = Math.round(this.featureHeight); // Draw the geometry that represents the feature - if (feature.feature === 'exon') { + if (feature.feature === "exon") { // generate feature object const visibleCoords = getVisibleXCoordinates({ - canvas: this.onscreenPosition, feature: feature, scale: scale - }) + canvas: this.onscreenPosition, + feature: feature, + scale: scale, + }); const featureObj = { id: feature.exon_number, start: feature.start, @@ -77,8 +84,8 @@ export class TranscriptTrack extends BaseAnnotationTrack { y2: Math.round(y + height), isDisplayed: false, visibleX1: visibleCoords.x1, - visibleX2: visibleCoords.x2 - } + visibleX2: visibleCoords.x2, + }; drawRect({ ctx: this.drawCtx, x: featureObj.x1, @@ -87,19 +94,25 @@ export class TranscriptTrack extends BaseAnnotationTrack { height: height, lineWidth: 1, fillColor: color, - open: false - }) - return featureObj + open: false, + }); + return featureObj; } } // draw transcript figures - async _drawTranscript (element, color, plotFormat, - drawName = true, drawAsArrow = false, addTooltip = true) { - const canvasYPos = this.tracksYPos(element.height_order) - const scale = this.offscreenPosition.scale + async _drawTranscript( + element, + color, + plotFormat, + drawName = true, + drawAsArrow = false, + addTooltip = true, + ) { + const canvasYPos = this.tracksYPos(element.height_order); + const scale = this.offscreenPosition.scale; // sizes - const textSize = plotFormat.textSize + const textSize = plotFormat.textSize; // store element metadata const transcriptObj = { id: element.transcript_id, @@ -110,33 +123,33 @@ export class TranscriptTrack extends BaseAnnotationTrack { mane: element.mane, scale: scale, color: element.mane ? lightenColor(color, 15) : color, // lighten colors for MANE transcripts - features: [] - } + features: [], + }; // Keep track of latest track if (this.heightOrderRecord.latestHeight !== element.height_order) { this.heightOrderRecord = { latestHeight: element.height_order, latestNameEnd: 0, - latestTrackEnd: 0 - } + latestTrackEnd: 0, + }; } // Draw a line to mark gene's length // cap lines at offscreen canvas start/end const displayedTrStart = Math.round( - (transcriptObj.start > this.offscreenPosition.start + transcriptObj.start > this.offscreenPosition.start ? scale * (transcriptObj.start - this.offscreenPosition.start) - : 0) - ) + : 0, + ); const displayedTrEnd = Math.round( - (this.offscreenPosition.end > transcriptObj.end + this.offscreenPosition.end > transcriptObj.end ? scale * (transcriptObj.end - this.offscreenPosition.start) - : this.offscreenPosition.end) - ) + : this.offscreenPosition.end, + ); // store start and end coordinates - transcriptObj.x1 = displayedTrStart - transcriptObj.x2 = displayedTrEnd - transcriptObj.y1 = canvasYPos - (this.geneLineWidth / 2) - transcriptObj.y2 = canvasYPos + (this.geneLineWidth / 2) + transcriptObj.x1 = displayedTrStart; + transcriptObj.x2 = displayedTrEnd; + transcriptObj.y1 = canvasYPos - this.geneLineWidth / 2; + transcriptObj.y2 = canvasYPos + this.geneLineWidth / 2; // draw transcript backbone drawLine({ ctx: this.drawCtx, @@ -145,147 +158,165 @@ export class TranscriptTrack extends BaseAnnotationTrack { y: canvasYPos, y2: canvasYPos, color: transcriptObj.color, - lineWith: this.geneLineWidth // set width of the element - }) + lineWith: this.geneLineWidth, // set width of the element + }); // Draw gene name - const textYPos = this.tracksYPos(element.height_order) + const textYPos = this.tracksYPos(element.height_order); if (drawName) { - const mane = element.mane ? ' [MANE] ' : '' + const mane = element.mane ? " [MANE] " : ""; drawText({ ctx: this.drawCtx, - text: `${transcriptObj.name}${mane}${element.strand === '+' ? '→' : '←'}`, - x: Math.round(((displayedTrEnd - displayedTrStart) / 2) + displayedTrStart), + text: `${transcriptObj.name}${mane}${element.strand === "+" ? "→" : "←"}`, + x: Math.round( + (displayedTrEnd - displayedTrStart) / 2 + displayedTrStart, + ), y: textYPos + this.featureHeight, - fontProp: textSize - }) + fontProp: textSize, + }); } // draw arrows in gene if (drawAsArrow) { drawArrow({ ctx: this.drawCtx, - x: element.strand === '+' ? displayedTrEnd : displayedTrStart, // xPos + x: element.strand === "+" ? displayedTrEnd : displayedTrStart, // xPos y: canvasYPos, // yPos - dir: element.strand === '+' ? 1 : -1, // direction + dir: element.strand === "+" ? 1 : -1, // direction height: this.featureHeight / 2, // height lineWidth: this.geneLineWidth, // lineWidth - color: transcriptObj.color // color - }) + color: transcriptObj.color, // color + }); } else { // draw features for (const feature of element.features) { const featureObj = this._drawFeature( - feature, element.height_order, - canvasYPos, transcriptObj.color, plotFormat - ) + feature, + element.height_order, + canvasYPos, + transcriptObj.color, + plotFormat, + ); if (featureObj !== undefined) { - transcriptObj.features.push(featureObj) + transcriptObj.features.push(featureObj); } } - transcriptObj.y1 = Math.min(...transcriptObj.features.map(feat => feat.y1)) - transcriptObj.y2 = Math.max(...transcriptObj.features.map(feat => feat.y2)) + transcriptObj.y1 = Math.min( + ...transcriptObj.features.map((feat) => feat.y1), + ); + transcriptObj.y2 = Math.max( + ...transcriptObj.features.map((feat) => feat.y2), + ); } // adapt coordinates to global screen coordinates from coorinates local to canvas - updateVisableElementCoordinates({ + updateVisibleElementCoordinates({ element: transcriptObj, screenPosition: this.onscreenPosition, - scale: this.offscreenPosition.scale - }) + scale: this.offscreenPosition.scale, + }); // make a virtual representation of the genetic element const virtualElement = makeVirtualDOMElement({ x1: transcriptObj.visibleX1, x2: transcriptObj.visibleX2, y1: transcriptObj.visibleY1, y2: transcriptObj.visibleY2, - canvas: this.contentCanvas - }) + canvas: this.contentCanvas, + }); // create a tooltip html element and append to DOM const elementInfo = [ { title: element.chrom, value: `${element.start}-${element.end}` }, - { title: 'id', value: element.transcript_id } - ] - if (element.refseq_id) { elementInfo.push({ title: 'refSeq', value: element.refseq_id }) } - if (element.hgnc_id) { elementInfo.push({ title: 'hgnc', value: element.hgnc_id }) } - if ( addTooltip ) { + { title: "id", value: element.transcript_id }, + ]; + if (element.refseq_id) { + elementInfo.push({ title: "refSeq", value: element.refseq_id }); + } + if (element.hgnc_id) { + elementInfo.push({ title: "hgnc", value: element.hgnc_id }); + } + if (addTooltip) { const tooltip = createTooltipElement({ id: `popover-${element.transcript_id}`, title: transcriptObj.name, - information: elementInfo - }) + information: elementInfo, + }); // add features to element - addFeatures(element, tooltip) + addFeatures(element, tooltip); // create tooltip - this.trackContainer.appendChild(tooltip) + this.trackContainer.appendChild(tooltip); transcriptObj.tooltip = { instance: createPopper(virtualElement, tooltip, { modifiers: [ - { name: 'offset', options: { offset: [0, virtualElement.getBoundingClientRect().height] } } - ] + { + name: "offset", + options: { + offset: [0, virtualElement.getBoundingClientRect().height], + }, + }, + ], }), virtualElement: virtualElement, tooltip: tooltip, - isDisplayed: false - } - } else { - transcriptObj.tooltip = false + isDisplayed: false, + }; + } else { + transcriptObj.tooltip = false; } - return transcriptObj + return transcriptObj; } // Draws transcripts in given range - async drawOffScreenTrack ({ startPos, endPos, maxHeightOrder, data }) { + async drawOffScreenTrack({ startPos, endPos, maxHeightOrder, data }) { // store positions used when rendering the canvas this.offscreenPosition = { start: startPos, end: endPos, - scale: (this.drawCanvas.width / - (endPos - startPos)) - } + scale: this.drawCanvas.width / (endPos - startPos), + }; // Set needed height of visible canvas and transcript tooltips - this.setContainerHeight(maxHeightOrder) + this.setContainerHeight(maxHeightOrder); // Keeps track of previous values this.heightOrderRecord = { latestHeight: 0, // Latest height order for annotation latestNameEnd: 0, // Latest annotations end position - latestTrackEnd: 0 // Latest annotations title's end position - } + latestTrackEnd: 0, // Latest annotations title's end position + }; // limit drawing of transcript to pre-defined resolutions - let filteredTranscripts = [] + let filteredTranscripts = []; if (this.getResolution < this.maxResolution + 1) { - filteredTranscripts = data.transcripts.filter( - transc => isElementOverlapping( - transc, { start: startPos, end: endPos } - ) - ) + filteredTranscripts = data.transcripts.filter((transc) => + isElementOverlapping(transc, { start: startPos, end: endPos }), + ); } // dont show tracks with no data in them if (filteredTranscripts.length > 0) { - this.setContainerHeight(this.trackData.max_height_order) + this.setContainerHeight(this.trackData.max_height_order); } else { - this.setContainerHeight(0) + this.setContainerHeight(0); } - this.clearTracks() + this.clearTracks(); // define plot formating parameters const plotFormat = { textSize: 10, - titleMargin: 2 - } + titleMargin: 2, + }; // Go through queryResults and draw appropriate symbols - const drawGeneName = this.getResolution < 3 - const drawTooltips = this.getResolution < 4 - const drawExons = this.getResolution < 4 + const drawGeneName = this.getResolution < 3; + const drawTooltips = this.getResolution < 4; + const drawExons = this.getResolution < 4; for (const transc of filteredTranscripts) { - if (!this.expanded && transc.height_order !== 1) { continue } + if (!this.expanded && transc.height_order !== 1) { + continue; + } // draw base transcript - const color = transc.strand === '+' - ? this.colorSchema.strand_pos - : this.colorSchema.strand_neg + const color = + transc.strand === "+" + ? this.colorSchema.strand_pos + : this.colorSchema.strand_neg; // test create some genetic elements and store them const transcriptObj = await this._drawTranscript( transc, @@ -293,9 +324,9 @@ export class TranscriptTrack extends BaseAnnotationTrack { plotFormat, drawGeneName, // if gene names should be drawn !drawExons, // if transcripts should be represented as arrows - drawTooltips, // if tooltips should be added - ) - this.geneticElements.push(transcriptObj) + drawTooltips, // if tooltips should be added + ); + this.geneticElements.push(transcriptObj); } } } diff --git a/assets/js/track/utils.js b/assets/js/track/utils.js index 9b878b0e..b08c6cc8 100644 --- a/assets/js/track/utils.js +++ b/assets/js/track/utils.js @@ -1,41 +1,62 @@ // Utility functions -export function getVisibleYCoordinates ({ element, minHeight = 4 }) { - let y1 = Math.round(element.y1) - let y2 = Math.round(element.y2) - const height = y2 - y1 +export function getVisibleYCoordinates({ element, minHeight = 4 }) { + let y1 = Math.round(element.y1); + let y2 = Math.round(element.y2); + const height = y2 - y1; if (height < minHeight) { - y1 = Math.round(y1 - ((minHeight - height) / 2)) - y2 = Math.round(y2 + ((minHeight - height) / 2)) + y1 = Math.round(y1 - (minHeight - height) / 2); + y2 = Math.round(y2 + (minHeight - height) / 2); } - return { y1, y2 } + return { y1, y2 }; } -export function getVisibleXCoordinates ({ canvas, feature, scale, minWidth = 4 }) { - let x1 = Math.round((Math.max(0, feature.start - canvas.start)) * scale) - let x2 = Math.round((Math.min(canvas.end, feature.end - canvas.start)) * scale) +export function getVisibleXCoordinates({ + canvas, + feature, + scale, + minWidth = 4, +}) { + let x1 = Math.round(Math.max(0, feature.start - canvas.start) * scale); + let x2 = Math.round(Math.min(canvas.end, feature.end - canvas.start) * scale); if (x2 - x1 < minWidth) { - x1 = Math.round(x1 - (minWidth - (x2 - x1) / 2)) - x2 = Math.round(x2 + (minWidth - (x2 - x1) / 2)) + x1 = Math.round(x1 - (minWidth - (x2 - x1) / 2)); + x2 = Math.round(x2 + (minWidth - (x2 - x1) / 2)); } - return { x1, x2 } + return { x1, x2 }; } // Check if two geometries are overlapping // each input is an object with start/ end coordinates // f >----------------< // s >---------< -export function isElementOverlapping (first, second) { - if ((first.start > second.start && first.start < second.end) || // - (first.end > second.start && first.end < second.end) || - (second.start > first.start && second.start < first.end) || - (second.end > first.start && second.end < first.end)) { - return true +export function isElementOverlapping(first, second) { + if ( + (first.start > second.start && first.start < second.end) || // + (first.end > second.start && first.end < second.end) || + (second.start > first.start && second.start < first.end) || + (second.end > first.start && second.end < first.end) + ) { + return true; } - return false + return false; } // check if point is within an element -export function isWithinElementBbox ({ element, point }) { - return (element.x1 < point.x && point.x < element.x2) && (element.y1 < point.y && point.y < element.y2) +export function isWithinElementBbox({ element, point }) { + return ( + element.x1 < point.x && + point.x < element.x2 && + element.y1 < point.y && + point.y < element.y2 + ); +} + +export function isWithinElementVisibleBbox({ element, point }) { + return ( + element.visibleX1 < point.x && + point.x < element.visibleX2 && + element.visibleY1 < point.y && + point.y < element.visibleY2 + ); } diff --git a/assets/js/track/utils.test.js b/assets/js/track/utils.test.js index 13309bfd..48b870c7 100644 --- a/assets/js/track/utils.test.js +++ b/assets/js/track/utils.test.js @@ -1,93 +1,122 @@ -import { isElementOverlapping, isWithinElementBbox, getVisibleXCoordinates, getVisibleYCoordinates } from './utils.js' +import { + isElementOverlapping, + isWithinElementBbox, + getVisibleXCoordinates, + getVisibleYCoordinates, +} from "./utils.js"; // Test overlapping elements -describe('Test isElementOverlapping', () => { - test('first is within second', () => { - const first = {start: 100, end: 600}, second = {start: 50, end: 1000} - const resp = isElementOverlapping(first, second) - expect(resp).toBeTruthy() - }) - test('first end is overapping second', () => { - const first = {start: 100, end: 600}, second = {start: 500, end: 1000} - const resp = isElementOverlapping(first, second) - expect(resp).toBeTruthy() - }) - test('first start is overapping second', () => { - const first = {start: 100, end: 600}, second = {start: 20, end: 120} - const resp = isElementOverlapping(first, second) - expect(resp).toBeTruthy() - }) - test('first start and second does not overlapp', () => { - const first = {start: 100, end: 200}, second = {start: 500, end: 900} - const resp = isElementOverlapping(first, second) - expect(resp).not.toBeTruthy() - }) -}) +describe("Test isElementOverlapping", () => { + test("first is within second", () => { + const first = { start: 100, end: 600 }, + second = { start: 50, end: 1000 }; + const resp = isElementOverlapping(first, second); + expect(resp).toBeTruthy(); + }); + test("first end is overapping second", () => { + const first = { start: 100, end: 600 }, + second = { start: 500, end: 1000 }; + const resp = isElementOverlapping(first, second); + expect(resp).toBeTruthy(); + }); + test("first start is overapping second", () => { + const first = { start: 100, end: 600 }, + second = { start: 20, end: 120 }; + const resp = isElementOverlapping(first, second); + expect(resp).toBeTruthy(); + }); + test("first start and second does not overlapp", () => { + const first = { start: 100, end: 200 }, + second = { start: 500, end: 900 }; + const resp = isElementOverlapping(first, second); + expect(resp).not.toBeTruthy(); + }); +}); // test if point is within element -describe('Test if point is within element', () => { - const element = {x1: 10, x2: 90, y1: -10, y2: 10} - test('test point within element bbox, xy', () => { - expect(isWithinElementBbox({element, point: {x: 20, y: 5}})).toBeTruthy() - }) - test('test point is outside element bbox', () =>{ - expect(isWithinElementBbox({element, point: {x: 20, y: 15}})).toBeFalsy() - }) - test('test point is on element bbox edge', () => { - expect(isWithinElementBbox({element, point: {x: 10, y: 10}})).toBeFalsy() - expect(isWithinElementBbox({element, point: {x: 50, y: 10}})).toBeFalsy() - }) -}) +describe("Test if point is within element", () => { + const element = { x1: 10, x2: 90, y1: -10, y2: 10 }; + test("test point within element bbox, xy", () => { + expect( + isWithinElementBbox({ element, point: { x: 20, y: 5 } }), + ).toBeTruthy(); + }); + test("test point is outside element bbox", () => { + expect( + isWithinElementBbox({ element, point: { x: 20, y: 15 } }), + ).toBeFalsy(); + }); + test("test point is on element bbox edge", () => { + expect( + isWithinElementBbox({ element, point: { x: 10, y: 10 } }), + ).toBeFalsy(); + expect( + isWithinElementBbox({ element, point: { x: 50, y: 10 } }), + ).toBeFalsy(); + }); +}); // test getVisibleYCoordinates function -describe('Test getVisibleYCoordinates', () => { - test('test element higher than minHeight', () => { - const element = {y1: 10, y2: 40} - const resp = getVisibleYCoordinates({ element, minHeight: 4 }) - expect(resp).toEqual({ y1: 10, y2: 40 }) - }) +describe("Test getVisibleYCoordinates", () => { + test("test element higher than minHeight", () => { + const element = { y1: 10, y2: 40 }; + const resp = getVisibleYCoordinates({ element, minHeight: 4 }); + expect(resp).toEqual({ y1: 10, y2: 40 }); + }); - test('test element shorter than minHeight', () => { - const element = {y1: 10, y2: 20} - const resp = getVisibleYCoordinates({ element, minHeight: 20 }) - expect(resp).toEqual({ y1: 5, y2: 25 }) - }) -}) + test("test element shorter than minHeight", () => { + const element = { y1: 10, y2: 20 }; + const resp = getVisibleYCoordinates({ element, minHeight: 20 }); + expect(resp).toEqual({ y1: 5, y2: 25 }); + }); +}); // test getVisibleXCoordinates function -describe('Test getVisibleXCoordinates', () => { - const canvas = {start: 100, end: 200} - const scale = 0.1 +describe("Test getVisibleXCoordinates", () => { + const canvas = { start: 100, end: 200 }; + const scale = 0.1; - test('test feature inside visable canvas', () => { - const feature = {start: 120, end: 150} - const resp = getVisibleXCoordinates({ - canvas, feature, scale, minWidth: 1 - }) - expect(resp).toEqual({ x1: 2, x2: 5 }) - }) + test("test feature inside visable canvas", () => { + const feature = { start: 120, end: 150 }; + const resp = getVisibleXCoordinates({ + canvas, + feature, + scale, + minWidth: 1, + }); + expect(resp).toEqual({ x1: 2, x2: 5 }); + }); - test('test feature inside visable canvas, no scale', () => { - const feature = {start: 120, end: 150} - const resp = getVisibleXCoordinates({ - canvas, feature, scale: 1, minWidth: 1 - }) - expect(resp).toEqual({ x1: 20, x2: 50 }) - }) + test("test feature inside visable canvas, no scale", () => { + const feature = { start: 120, end: 150 }; + const resp = getVisibleXCoordinates({ + canvas, + feature, + scale: 1, + minWidth: 1, + }); + expect(resp).toEqual({ x1: 20, x2: 50 }); + }); - test('test feature partly inside visable canvas, caped at begining', () => { - const feature = {start: 90, end: 150} - const resp = getVisibleXCoordinates({ - canvas, feature, scale: 1, minWidth: 1 - }) - expect(resp).toEqual({ x1: 0, x2: 50 }) - }) + test("test feature partly inside visable canvas, caped at begining", () => { + const feature = { start: 90, end: 150 }; + const resp = getVisibleXCoordinates({ + canvas, + feature, + scale: 1, + minWidth: 1, + }); + expect(resp).toEqual({ x1: 0, x2: 50 }); + }); - test('test feature partly inside visable canvas, caped at end', () => { - const feature = {start: 120, end: 600} - const resp = getVisibleXCoordinates({ - canvas, feature, scale: 1, minWidth: 1 - }) - expect(resp).toEqual({ x1: 20, x2: 200 }) - }) -}) + test("test feature partly inside visable canvas, caped at end", () => { + const feature = { start: 120, end: 600 }; + const resp = getVisibleXCoordinates({ + canvas, + feature, + scale: 1, + minWidth: 1, + }); + expect(resp).toEqual({ x1: 20, x2: 200 }); + }); +}); diff --git a/assets/js/track/variant.js b/assets/js/track/variant.js index 81ebbff3..660fd92f 100644 --- a/assets/js/track/variant.js +++ b/assets/js/track/variant.js @@ -1,46 +1,92 @@ // Variant track definition -import { BaseAnnotationTrack } from './base.js' -import { isElementOverlapping } from './utils.js' -import { drawRect, drawLine, drawWaveLine, drawText } from '../draw.js' -import { initTrackTooltips, createTooltipElement, makeVirtualDOMElement, updateVisableElementCoordinates } from './tooltip.js' -import { createPopper } from '@popperjs/core' +import { BaseAnnotationTrack } from "./base.js"; +import { isElementOverlapping, isWithinElementVisibleBbox } from "./utils.js"; +import { drawRect, drawLine, drawWaveLine, drawText } from "../draw.js"; +import { + initTrackTooltips, + createTooltipElement, + makeVirtualDOMElement, + updateVisibleElementCoordinates, +} from "./tooltip.js"; +import { createPopper } from "@popperjs/core"; // Draw variants -const VARIANT_TR_TABLE = { del: 'deletion', dup: 'duplication' } +const VARIANT_TR_TABLE = { + del: "deletion", + dup: "duplication", + cnv: "copy number variation", + inv: "inversion", + bnd: "break end", +}; export class VariantTrack extends BaseAnnotationTrack { - constructor (x, width, near, far, genomeBuild, colorSchema, highlightedVariantId) { + constructor( + x, + width, + near, + far, + caseId, + genomeBuild, + colorSchema, + scoutBaseURL, + highlightedVariantId, + ) { // Dimensions of track canvas - const visibleHeight = 100 // Visible height for expanded canvas, overflows for scroll - const minHeight = 35 // Minimized height + const visibleHeight = 100; // Visible height for expanded canvas, overflows for scroll + const minHeight = 35; // Minimized height - super(width, near, far, visibleHeight, minHeight, colorSchema) + super(width, near, far, visibleHeight, minHeight, colorSchema); // Set inherited variables - this.drawCanvas = document.getElementById('variant-draw') - this.contentCanvas = document.getElementById('variant-content') - this.trackTitle = document.getElementById('variant-titles') - this.trackContainer = document.getElementById('variant-track-container') - this.featureHeight = 18 - + this.drawCanvas = document.getElementById("variant-draw"); + this.contentCanvas = document.getElementById("variant-content"); + this.trackTitle = document.getElementById("variant-titles"); + this.trackContainer = document.getElementById("variant-track-container"); + this.scoutBaseURL = scoutBaseURL; + // Add click menu event listener linking out to the Scout variant + this.trackContainer.addEventListener( + "click", + async (event) => { + for (const element of this.geneticElements) { + const rect = this.contentCanvas.getBoundingClientRect(); + const point = { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }; + if (isWithinElementVisibleBbox({ element, point })) { + const url = this.scoutBaseURL + "/document_id/" + element.id; + console.log(`Visit ${url}: Scout variant`); + const win = window.open(url, "_blank"); + win.focus(); + } + } + }, + false, + ); + this.featureHeight = 18; // Setup html objects now that we have gotten the canvas and div elements - this.setupHTML(x + 1) + this.setupHTML(x + 1); - this.trackContainer.style.marginTop = '-1px' - this.genomeBuild = genomeBuild + this.trackContainer.style.marginTop = "-1px"; + this.genomeBuild = genomeBuild; // GENS api parameters - this.apiEntrypoint = 'get-variant-data' - this.additionalQueryParams = { variant_category: 'sv' } - + this.apiEntrypoint = "get-variant-data"; + this.additionalQueryParams = { + variant_category: "sv", + case_id: caseId, + }; // Initialize highlighted variant - this.highlightedVariantId = highlightedVariantId - initTrackTooltips(this) + this.highlightedVariantId = highlightedVariantId; + initTrackTooltips(this); + + // Initialize label tracking + this.labelData = []; } // Draw highlight for a given region - drawHighlight (startPos, endPos, color = 'rgb(255, 200, 87, 0.5)') { + drawHighlight(startPos, endPos, color = "rgb(255, 200, 87, 0.5)") { drawRect({ ctx: this.drawCtx, x: startPos, @@ -49,161 +95,211 @@ export class VariantTrack extends BaseAnnotationTrack { height: this.visibleHeight, lineWidth: 0, fillColor: color, - open: false - }) + open: false, + }); } - async drawOffScreenTrack ({ startPos, endPos, maxHeightOrder, data }) { + async drawOffScreenTrack({ startPos, endPos, maxHeightOrder, data }) { // Draws variants in given range - const textSize = 10 + const textSize = 10; // store positions used when rendering the canvas this.offscreenPosition = { start: startPos, end: endPos, - scale: this.drawCanvas.width / - (endPos - startPos) - } - const scale = this.offscreenPosition.scale + scale: this.drawCanvas.width / (endPos - startPos), + }; + const scale = this.offscreenPosition.scale; // Set needed height of visible canvas and transcript tooltips - this.setContainerHeight(maxHeightOrder) + this.setContainerHeight(maxHeightOrder); // Keeps track of previous values this.heightOrderRecord = { latestHeight: 0, // Latest height order for annotation latestNameEnd: 0, // Latest annotations end position - latestTrackEnd: 0 // Latest annotations title's end position - } + latestTrackEnd: 0, // Latest annotations title's end position + }; // limit drawing of annotations to pre-defined resolutions - let filteredVariants = [] + let filteredVariants = []; if (this.getResolution < this.maxResolution + 1) { - filteredVariants = data - .variants - .filter(variant => isElementOverlapping( + filteredVariants = data.variants.filter((variant) => + isElementOverlapping( { start: variant.position, end: variant.end }, - { start: startPos, end: endPos })) + { start: startPos, end: endPos }, + ), + ); } + filteredVariants.sort((a, b) => a.position - b.position); + + let heightTracker = Array(200); + let actualMaxHeightOrder = 1; + for (const variant of filteredVariants) { + const variantCategory = variant.sub_category; // del, dup, sv, str + if (["dup", "del", "cnv"].includes(variantCategory)) { + let heightOrder = 1; + while (heightTracker[heightOrder] >= variant.position) heightOrder += 1; + heightTracker[heightOrder] = variant.end; + actualMaxHeightOrder = Math.max(actualMaxHeightOrder, heightOrder); + } + } + + this.trackData.max_height_order = actualMaxHeightOrder; + // dont show tracks with no data in them - if (filteredVariants.length > 0 && - this.getResolution < this.maxResolution + 1 + if ( + filteredVariants.length > 0 && + this.getResolution < this.maxResolution + 1 ) { - this.setContainerHeight(this.trackData.max_height_order) + this.setContainerHeight(this.trackData.max_height_order); } else { - this.setContainerHeight(0) + this.setContainerHeight(0); } - this.clearTracks() + this.clearTracks(); + + heightTracker = Array(200); + + const labelData = []; // Draw track - const drawTooltips = this.getResolution < 4 + const drawTooltips = this.getResolution < 4; for (const variant of filteredVariants) { - const variantCategory = variant.sub_category // del, dup, sv, str - const variantType = variant.variant_type - const variantLength = variant.length - const color = this.colorSchema[variantCategory] || this.colorSchema.default || 'black' - const heightOrder = 1 - const canvasYPos = this.tracksYPos(heightOrder) + const variantCategory = variant.sub_category; // del, dup, sv, str + const variantType = variant.variant_type; + const variantLength = variant.length; + const color = + this.colorSchema[variantCategory] || + this.colorSchema.default || + "black"; + + let heightOrder = 1; + if (["dup", "del", "cnv"].includes(variantCategory)) { + while (heightTracker[heightOrder] >= variant.position) heightOrder += 1; + heightTracker[heightOrder] = variant.end; + } + + const canvasYPos = this.tracksYPos(heightOrder); // Only draw visible tracks - if (!this.expanded && heightOrder !== 1) { continue } + if (!this.expanded && heightOrder !== 1) { + continue; + } // create variant object - const featureHeight = variantCategory === 'del' ? 7 : 8 + const featureHeight = variantCategory === "del" ? 7 : 8; const variantObj = { - id: variant.variant_id, + id: variant.document_id, name: variant.display_name, start: variant.position, end: variant.end, - x1: Math.round(scale * (variant.position - this.offscreenPosition.start)), + x1: Math.round( + scale * (variant.position - this.offscreenPosition.start), + ), x2: Math.round(scale * (variant.end - this.offscreenPosition.start)), y1: canvasYPos, - y2: Math.round((canvasYPos + featureHeight)), + y2: Math.round(canvasYPos + featureHeight), features: [], isDisplayed: false, tooltip: false, - } + }; // get onscreen positions for offscreen xy coordinates - updateVisableElementCoordinates({ + updateVisibleElementCoordinates({ element: variantObj, screenPosition: this.onscreenPosition, - scale: this.offscreenPosition.scale - }) + scale: this.offscreenPosition.scale, + }); // create a tooltip html element and append to DOM - if ( drawTooltips ) { + if (drawTooltips && ["dup", "del", "cnv"].includes(variantCategory)) { const tooltip = createTooltipElement({ id: `popover-${variantObj.id}`, title: `${variantType.toUpperCase()}: ${variant.category} - ${VARIANT_TR_TABLE[variantCategory]}`, information: [ - { title: 'Type', value: variant.category }, + { title: "Type", value: variant.category }, { title: variant.chromosome, value: `${variant.position}` }, - { title: 'Ref', value: `${variant.reference}` }, - { title: 'Alt', value: `${variant.alternative}` }, - { title: 'Cytoband start/end', value: `${variant.cytoband_start}/${variant.cytoband_end}` }, - { title: 'Quality', value: `${variant.quality}` }, - { title: 'Rank score', value: `${variant.rank_score}` } - ] - }) - this.trackContainer.appendChild(tooltip) + { title: "Ref", value: `${variant.reference}` }, + { title: "Alt", value: `${variant.alternative}` }, + { + title: "Cytoband start/end", + value: `${variant.cytoband_start}/${variant.cytoband_end}`, + }, + { title: "Length", value: `${variant.length}` }, + { title: "Quality", value: `${variant.quality}` }, + { title: "Rank score", value: `${variant.rank_score}` }, + ], + }); + this.trackContainer.appendChild(tooltip); // make a virtual element as tooltip hitbox const virtualElement = makeVirtualDOMElement({ x1: variantObj.visibleX1, x2: variantObj.visibleX2, y1: variantObj.visibleY1, y2: variantObj.visibleY2, - canvas: this.contentCanvas - }) + canvas: this.contentCanvas, + }); // add tooltip to variantObj variantObj.tooltip = { instance: createPopper(virtualElement, tooltip, { modifiers: [ - { name: 'offset', options: { offset: [0, virtualElement.getBoundingClientRect().height] } } - ] + { + name: "offset", + options: { + offset: [0, virtualElement.getBoundingClientRect().height], + }, + }, + ], }), virtualElement: virtualElement, tooltip: tooltip, - isDisplayed: false - } + isDisplayed: false, + }; + } + if (["dup", "del", "cnv"].includes(variantCategory)) { + this.geneticElements.push(variantObj); } - this.geneticElements.push(variantObj) // Keep track of latest track if (this.heightOrderRecord.latestHeight !== heightOrder) { this.heightOrderRecord = { latestHeight: heightOrder, latestNameEnd: 0, - latestTrackEnd: 0 - } + latestTrackEnd: 0, + }; } // Draw motif line switch (variantCategory) { - case 'del': + case "del": drawWaveLine({ ctx: this.drawCtx, x: variantObj.x1, y: variantObj.y2, x2: variantObj.x2, height: featureHeight, - color - }) - break - case 'dup': + color, + }); + break; + case "cnv": + case "dup": drawLine({ ctx: this.drawCtx, x: variantObj.x1, y: variantObj.y1, x2: variantObj.x2, y2: variantObj.y1, - color - }) + color, + }); drawLine({ ctx: this.drawCtx, x: variantObj.x1, y: variantObj.y2, x2: variantObj.x2, y2: variantObj.y2, - color - }) - break + color, + }); + break; + case "bnd": + case "inv": + // no support for balanced events + break; default: // other types of elements drawLine({ ctx: this.drawCtx, @@ -211,24 +307,65 @@ export class VariantTrack extends BaseAnnotationTrack { y: variantObj.y1, x2: variantObj.x2, y2: variantObj.y1, - color - }) - console.log(`Unhandled variant type ${variantCategory}; drawing default shape`) + color, + }); + console.log( + `Unhandled variant type ${variantCategory}; drawing default shape`, + ); } // Move and display highlighted region if (variant._id === this.highlightedVariantId) { - this.drawHighlight(variantObj.x1, variantObj.x2) + this.drawHighlight(variantObj.x1, variantObj.x2); } - const textYPos = this.tracksYPos(heightOrder) - // Draw variant type - drawText({ - ctx: this.drawCtx, - text: `${variant.category} - ${variantType} ${VARIANT_TR_TABLE[variantCategory]}; length: ${variantLength}`, - x: scale * ((variantObj.start + variantObj.end) / 2 - this.offscreenPosition.start), - y: textYPos + this.featureHeight, - fontProp: textSize - }) + if (["dup", "del", "cnv"].includes(variantCategory)) { + const textYPos = this.tracksYPos(heightOrder); + // Draw variant type + labelData.push({ + text: `${variant.category} - ${variantType} ${VARIANT_TR_TABLE[variantCategory]}; length: ${variantLength}`, + start: variantObj.start, + end: variantObj.end, + x: (variantObj.start + variantObj.end) / 2, + y: textYPos + this.featureHeight, + fontProp: textSize, + }); + /* drawText({ + ctx: this.drawCtx, + text: `${variant.category} - ${variantType} ${VARIANT_TR_TABLE[variantCategory]}; length: ${variantLength}`, + x: scale * ((variantObj.start + variantObj.end) / 2 - this.offscreenPosition.start), + y: textYPos + this.featureHeight, + fontProp: textSize + }) */ + } } + this.labelData = labelData; + } + + drawDynamicOverlay() { + const ctx = this.contentCanvas.getContext("2d"); + const scale = + this.contentCanvas.width / + (this.onscreenPosition.end - this.onscreenPosition.start); + const margin = 100; + this.labelData.forEach((label) => { + if ( + label.start < this.onscreenPosition.end && + label.end > this.onscreenPosition.start + ) { + drawText({ + ctx: ctx, + text: label.text, + x: Math.max( + margin, + Math.min( + this.contentCanvas.width - margin, + scale * (label.x - this.onscreenPosition.start), + ), + ), + y: label.y, + fontProp: label.fontProp, + }); + } + }); } } diff --git a/docker-compose.yml b/docker-compose.yml index 128aef71..6ff76d68 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,14 @@ version: "3.8" services: - markus_mongodb: - container_name: markus_mongodb + mongodb: + container_name: mongodb image: mongo volumes: - ./volumes/mongo_db/data:/data/db networks: - gens-net - markus_gens: - container_name: markus_gens + gens: + container_name: gens build: . volumes: - ./utils:/home/app/app/utils @@ -20,7 +20,7 @@ services: environment: - FLASK_APP=gens/app.py - FLASK_ENV=development - - MONGODB_HOST=markus_mongodb + - MONGODB_HOST=mongodb - MONGODB_PORT=27017 - SCOUT_DBNAME=scout - GENS_DBNAME=gens @@ -31,7 +31,7 @@ services: networks: - gens-net depends_on: - - markus_mongodb + - mongodb command: "flask run --host 0.0.0.0" networks: gens-net: diff --git a/gens/__version__.py b/gens/__version__.py index b7775796..ea9d6945 100644 --- a/gens/__version__.py +++ b/gens/__version__.py @@ -1 +1 @@ -VERSION = "2.1.2" +VERSION = "3.0.0" diff --git a/gens/api.py b/gens/api.py index b78019c6..5bec0180 100644 --- a/gens/api.py +++ b/gens/api.py @@ -54,6 +54,7 @@ class ChromCoverageRequest: """Request for getting coverage from multiple chromosome and regions.""" sample_id: str + case_id: str genome_build: int = attr.ib() plot_height: float top_bottom_padding: float @@ -75,7 +76,6 @@ def valid_perc(self, attribute, value): if not 0 <= value <= 1: raise ValueError(f"{value} is not within 0-1") - def get_overview_chrom_dim(x_pos, y_pos, plot_width, genome_build): """ Returns the dimensions of all chromosome graphs in screen coordinates @@ -106,7 +106,7 @@ def get_annotation_data(region, source, genome_build, collapsed): if region == "" or source == "": msg = "Could not find annotation data in DB" LOG.error(msg) - retrun (jsonify({"detail": msg}), 404) + return (jsonify({"detail": msg}), 404) genome_build = request.args.get("genome_build", "38") res, chrom, start_pos, end_pos = parse_region_str(region, genome_build) @@ -148,7 +148,7 @@ def get_transcript_data(region, genome_build, collapsed): if region == "": msg = "Could not find transcript in database" LOG.error(msg) - retrun (jsonify({"detail": msg}), 404) + return (jsonify({"detail": msg}), 404) # Get transcripts within span [start_pos, end_pos] or transcripts that go over the span transcripts = list( @@ -205,7 +205,7 @@ def search_annotation(query: str, genome_build, annotation_type): return jsonify({**data, "status": response_code}) -def get_variant_data(sample_id, variant_category, **optional_kwargs): +def get_variant_data(case_id, sample_id, variant_category, **optional_kwargs): """Search Scout database for variants associated with a case and return info in JSON format.""" default_height_order = 0 base_return = {"status": "ok"} @@ -232,6 +232,7 @@ def get_variant_data(sample_id, variant_category, **optional_kwargs): try: variants = list( query_variants( + case_id, sample_id, cattr.structure(variant_category, VariantCategory), **region_params, @@ -262,7 +263,7 @@ def get_multiple_coverages(): # read sample information db = current_app.config["GENS_DB"] - sample_obj = query_sample(db, data.sample_id, data.genome_build) + sample_obj = query_sample(db, data.sample_id, data.case_id, data.genome_build) # Try to find and load an overview json data file json_data, cov_file, baf_file = None, None, None if sample_obj.overview_file and os.path.isfile(sample_obj.overview_file): @@ -325,6 +326,7 @@ def get_multiple_coverages(): def get_coverage( sample_id, + case_id, region, x_pos, y_pos, @@ -363,7 +365,7 @@ def get_coverage( reduce_data, ) db = current_app.config["GENS_DB"] - sample_obj = query_sample(db, sample_id, genome_build) + sample_obj = query_sample(db, sample_id, case_id, genome_build) cov_file, baf_file = get_tabix_files(sample_obj.coverage_file, sample_obj.baf_file) # Parse region try: diff --git a/gens/app.py b/gens/app.py index d7eb964c..67d25bc0 100644 --- a/gens/app.py +++ b/gens/app.py @@ -3,21 +3,19 @@ """ import logging import os -from datetime import date from logging.config import dictConfig import connexion -from flask import abort, render_template, request +from flask import redirect, request, url_for from flask_compress import Compress +from flask_login import current_user from .__version__ import VERSION as version -from .blueprints import gens_bp, home_bp +from .blueprints import gens_bp, home_bp, login_bp from .cache import cache from .db import SampleNotFoundError, init_database from .errors import (generic_abort_error, generic_exception_error, sample_not_found) -from .graph import parse_region_str -from .io import BAF_SUFFIX, COV_SUFFIX, _get_filepath -from connexion.resolver import RestyResolver +from .extensions import login_manager, oauth_client dictConfig( { @@ -64,10 +62,30 @@ def create_app(): # prepare app context initialize_extensions(app) + + configure_extensions(app) + # register bluprints and errors register_blueprints(app) register_errors(app) + @app.before_request + def check_user(): + if app.config.get("LOGIN_DISABLED") or not request.endpoint: + return + + # check if the endpoint requires authentication + static_endpoint = "static" in request.endpoint + public_endpoint = getattr(app.view_functions[request.endpoint], "is_public", False) + relevant_endpoint = not (static_endpoint or public_endpoint) + # if endpoint requires auth, check if user is authenticated + if relevant_endpoint and not current_user.is_authenticated: + # combine visited URL (convert byte string query string to unicode!) + next_url = "{}?{}".format(request.path, request.query_string.decode()) + login_url = url_for("home.landing", next=next_url) + return redirect(login_url) + + return app @@ -75,6 +93,34 @@ def initialize_extensions(app): """Initialize flask extensions.""" cache.init_app(app) compress.init_app(app) + login_manager.init_app(app) + + +def configure_extensions(app): + # configure extensions + if app.config.get("GOOGLE"): + LOG.info("Google login enabled") + # setup connection to google oauth2 + configure_oauth_login(app) + + +def configure_oauth_login(app): + """Register the Google Oauth2 login client using config settings""" + + google_conf = app.config["GOOGLE"] + discovery_url = google_conf.get("discovery_url") + client_id = google_conf.get("client_id") + client_secret = google_conf.get("client_secret") + + oauth_client.init_app(app) + + oauth_client.register( + name="google", + server_metadata_url=discovery_url, + client_id=client_id, + client_secret=client_secret, + client_kwargs={"scope": "openid email profile"}, + ) def register_errors(app): @@ -90,3 +136,4 @@ def register_blueprints(app): """Register blueprints.""" app.register_blueprint(gens_bp) app.register_blueprint(home_bp) + app.register_blueprint(login_bp) diff --git a/gens/blueprints/__init__.py b/gens/blueprints/__init__.py index 84d15d50..7d1992c8 100644 --- a/gens/blueprints/__init__.py +++ b/gens/blueprints/__init__.py @@ -1,2 +1,4 @@ from .gens.views import gens_bp from .home.views import home_bp +from .login.views import login_bp + diff --git a/gens/static/svg/broken-dna.svg b/gens/blueprints/gens/static/svg/broken-dna.svg similarity index 100% rename from gens/static/svg/broken-dna.svg rename to gens/blueprints/gens/static/svg/broken-dna.svg diff --git a/gens/blueprints/gens/templates/gens.html b/gens/blueprints/gens/templates/gens.html index 1071a6bc..43e0f2d4 100644 --- a/gens/blueprints/gens/templates/gens.html +++ b/gens/blueprints/gens/templates/gens.html @@ -13,7 +13,7 @@ {% endmacro %} {% extends "layout.html" %} -{% block title %}Visualization{% endblock title %} +{% block title %}{{ sample_name }}{% endblock title %} {% block css_style %} {{ super() }} @@ -46,7 +46,7 @@ {{todays_date}}
- {{sample_name}} + {{sample_name}} | {{case_id}} | {{individual_id}} (Genome build: {{genome_build}})
@@ -76,7 +76,7 @@
- -
@@ -157,9 +157,11 @@ // Initiate variant, annotation and transcript canvases // and pass flask values to tracks const {ic, oc, ac, tc, vc} = gens.initCanvases({ - sampleName: '{{ sample_name }}', + sampleName: '{{ individual_id }}', + caseId: '{{ case_id }}', genomeBuild: {{ genome_build }}, uiColors: {{ ui_colors | tojson | safe }}, + scoutBaseURL: '{{ scout_base_url | safe }}', selectedVariant: '{{ selected_variant }}', annotationFile: '{{ annotation }}', }) diff --git a/gens/blueprints/gens/views.py b/gens/blueprints/gens/views.py index d558329f..1902d69b 100644 --- a/gens/blueprints/gens/views.py +++ b/gens/blueprints/gens/views.py @@ -9,7 +9,7 @@ from gens.cache import cache from gens.db import query_sample from gens.graph import parse_region_str -from gens.io import BAF_SUFFIX, COV_SUFFIX, _get_filepath +from gens.io import _get_filepath LOG = logging.getLogger(__name__) @@ -28,6 +28,9 @@ def display_case(sample_name): Renders the Gens template Expects sample_id as input to be able to load the sample data """ + case_id = request.args.get("case_id", None) + individual_id = request.args.get("individual_id", sample_name) + # get genome build and region region = request.args.get("region", None) print_page = request.args.get("print_page", "false") @@ -45,7 +48,7 @@ def display_case(sample_name): # verify that sample has been loaded db = current_app.config["GENS_DB"] - sample = query_sample(db, sample_name, genome_build) + sample = query_sample(db, individual_id, case_id, genome_build) # Check that BAF and Log2 file exists try: @@ -69,10 +72,13 @@ def display_case(sample_name): return render_template( "gens.html", ui_colors=current_app.config["UI_COLORS"], + scout_base_url=current_app.config.get("SCOUT_BASE_URL"), chrom=chrom, start=start_pos, end=end_pos, sample_name=sample_name, + individual_id=individual_id, + case_id=case_id, genome_build=genome_build, print_page=print_page, annotation=annotation, diff --git a/gens/blueprints/home/templates/about.html b/gens/blueprints/home/templates/about.html index eb8651d3..f8faee44 100644 --- a/gens/blueprints/home/templates/about.html +++ b/gens/blueprints/home/templates/about.html @@ -9,7 +9,7 @@ {% endblock css_style %} {% block body %} - {{ navbar('about', version) }} + {{ navbar('about', version, current_user) }} {{ super() }} {% endblock %} diff --git a/gens/blueprints/home/templates/home.html b/gens/blueprints/home/templates/home.html index 77ac365e..bf398731 100644 --- a/gens/blueprints/home/templates/home.html +++ b/gens/blueprints/home/templates/home.html @@ -13,14 +13,14 @@ {% endblock scripts %} {% block body %} - {{ navbar('home', version) }} + {{ navbar('home', version, current_user) }} {{ super() }} {% endblock %} {% block content %}

Samples

-

There are {{ samples | length }} samles loaded into Gens. Click on a sample_id to open it

+

There are {{ total_samples }} samples loaded into Gens. Click on a sample_id to open it

{{ samples_table(samples, pagination) }}
@@ -34,8 +34,9 @@

Samples

Sample id + Case id Genome build - Overivew file + Overview file BAM/BAF found Import date @@ -44,8 +45,16 @@

Samples

{% for sample in samples_obj %} {% set genome_build=sample["genome_build"]%} {% set sample_id=sample["sample_id"]%} + {% set case_id=sample["case_id"]%} - {{ sample_id }} + {{ sample_id }} + {% if case_id %} + {{ case_id }} + {% endif %} {{ genome_build }} {% if sample["has_overview_file"] %} diff --git a/gens/blueprints/home/templates/landing.html b/gens/blueprints/home/templates/landing.html new file mode 100644 index 00000000..936c1596 --- /dev/null +++ b/gens/blueprints/home/templates/landing.html @@ -0,0 +1,53 @@ +{% extends "layout.html" %} +{% from "navbar.html" import navbar %} + +{% block title %}GENS - version {{version}}{% endblock title %} + +{% block css_style %} + {{ super() }} + +{% endblock css_style %} + +{% block scripts %} + {{ super() }} +{% endblock scripts %} + +{% block body %} + {{ navbar('landing', version, current_user) }} + {{ super() }} +{% endblock %} + +{% block content %} +
+

GENS - v{{ version }}

+ +

Gens is a web-based interactive tool to visualize genomic copy number profiles from WGS data. + It plots the normalized read depth and alternative allele frequency.

+

Gens currently does not attempt to visualize breakpoint information: use Scout/IGV for that. + The way we generate the data it is suitable for visualizing CNVs of sizes down to a couple of Kbp. + For smaller things, use Scout/IGV. +

+ {% if current_user and current_user.is_authenticated %} + Welcome to GENS {{ current_user.name }}! + {% elif config.GOOGLE %} + + Login with Google + + {% else %} + +
    +
  • + +
  • +
  • + +
  • +
+ + {% endif %} +
+
+{% endblock content %} + diff --git a/gens/blueprints/home/templates/navbar.html b/gens/blueprints/home/templates/navbar.html index a17083f1..98b0161e 100644 --- a/gens/blueprints/home/templates/navbar.html +++ b/gens/blueprints/home/templates/navbar.html @@ -1,4 +1,4 @@ -{% macro navbar(selected, version) %} +{% macro navbar(selected, version, current_user) %} {% endmacro %} diff --git a/gens/blueprints/home/views.py b/gens/blueprints/home/views.py index 96901331..84ea4098 100644 --- a/gens/blueprints/home/views.py +++ b/gens/blueprints/home/views.py @@ -2,9 +2,9 @@ import logging import os -from itertools import groupby from flask import Blueprint, current_app, render_template, request +from flask_login import current_user from gens import version from gens.db import get_samples, get_timestamps @@ -39,22 +39,23 @@ def home(): # set pagination page = request.args.get("page", 1, type=int) start = (page - 1) * SAMPLES_PER_PAGE - samples, tot_samples = get_samples(db, start=start, n_samples=SAMPLES_PER_PAGE) + samples, total_samples = get_samples(db, start=start, n_samples=SAMPLES_PER_PAGE) # calculate pagination pagination_info = { "from": start + 1, "to": start + SAMPLES_PER_PAGE, "current_page": page, "last_page": ( - tot_samples // SAMPLES_PER_PAGE - if tot_samples % SAMPLES_PER_PAGE == 0 - else (tot_samples // SAMPLES_PER_PAGE) + 1 + total_samples // SAMPLES_PER_PAGE + if total_samples % SAMPLES_PER_PAGE == 0 + else (total_samples // SAMPLES_PER_PAGE) + 1 ), } # parse samples samples = [ { "sample_id": smp.sample_id, + "case_id": smp.case_id, "genome_build": smp.genome_build, "has_overview_file": smp.overview_file is not None, "files_present": os.path.isfile(smp.baf_file) @@ -65,8 +66,10 @@ def home(): ] return render_template( "home.html", - samples=samples, pagination=pagination_info, + samples=samples, + total_samples=total_samples, + scout_base_url=current_app.config.get("SCOUT_BASE_URL"), version=version, ) @@ -79,8 +82,22 @@ def about(): ui_colors = current_app.config.get("UI_COLORS") return render_template( "about.html", - timestamps=timestamps, - version=version, config=config, + timestamps=timestamps, ui_colors=ui_colors, + version=version, ) + + +def public_endpoint(function): + """Set an endpoint as public""" + function.is_public = True + return function + + +@home_bp.route("/landing") +@public_endpoint +def landing(): + + return render_template("landing.html", + version=version,) \ No newline at end of file diff --git a/gens/blueprints/login/__init__.py b/gens/blueprints/login/__init__.py new file mode 100644 index 00000000..d8f514dd --- /dev/null +++ b/gens/blueprints/login/__init__.py @@ -0,0 +1 @@ +from .views import login_bp diff --git a/gens/blueprints/login/controllers.py b/gens/blueprints/login/controllers.py new file mode 100644 index 00000000..4ac2896f --- /dev/null +++ b/gens/blueprints/login/controllers.py @@ -0,0 +1 @@ +"""Login controllers """ diff --git a/gens/blueprints/login/views.py b/gens/blueprints/login/views.py new file mode 100644 index 00000000..9abbc865 --- /dev/null +++ b/gens/blueprints/login/views.py @@ -0,0 +1,95 @@ +import logging + +from flask import Blueprint, current_app, flash, redirect, request, session, url_for + +from flask_login import login_user, logout_user + +from gens.db.users import user +from gens.extensions import login_manager, oauth_client +from gens.blueprints.home.views import public_endpoint + +# from . import controllers + +LOG = logging.getLogger(__name__) + +@login_manager.user_loader +def load_user(user_id): + """Returns the currently active user as an object.""" + + user_obj = user(user_id) + return user_obj + + +login_bp = Blueprint( + "login", + __name__, + template_folder="templates", + static_folder="static", + static_url_path="/login/static", +) + +login_manager.login_view = "login.login" +login_manager.login_message = "Please log in to access this page." +login_manager.login_message_category = "info" + +@login_bp.route("/login", methods=["GET", "POST"]) +@public_endpoint +def login(): + if "next" in request.args: + session["next_url"] = request.args["next"] + + if current_app.config.get("GOOGLE"): + if session.get("email"): + user_mail = session["email"] + session.pop("email", None) + else: + LOG.info("Google Login!") + redirect_uri = url_for(".authorized", _external=True) + try: + return oauth_client.google.authorize_redirect(redirect_uri) + except Exception as ex: + flash("An error has occurred while logging user in using Google OAuth") + + if request.form.get("email"): # Log in against Scout database + user_mail = request.form.get("email") + LOG.info("Validating user %s against Scout database", user_mail) + + user_obj = user(user_mail) + if user_obj is None: + flash("User not found in Scout database", "warning") + return redirect(url_for("home.landing")) + + return perform_login(user_obj) + + +@login_bp.route("/authorized") +@public_endpoint +def authorized(): + """Google auth callback function""" + token = oauth_client.google.authorize_access_token() + google_user = oauth_client.google.parse_id_token(token, None) + session["email"] = google_user.get("email").lower() + session["name"] = google_user.get("name") + session["locale"] = google_user.get("locale") + + return redirect(url_for(".login")) + +@login_bp.route("/logout") +def logout(): + logout_user() + session.pop("email", None) + session.pop("name", None) + session.pop("locale", None) + flash("You have been logged out", "success") + return redirect(url_for("home.landing")) + + +def perform_login(user_dict): + if login_user(user_dict, remember=True): + flash("You logged in as: {}".format(user_dict.name), "success") + next_url = session.pop("next_url", None) + return redirect(request.args.get("next") or next_url or url_for("home.home")) + flash("Sorry, you were not logged in", "warning") + return redirect(url_for("home.landing")) + + diff --git a/gens/commands/base.py b/gens/commands/base.py index 62f2ac43..f8a14f9a 100644 --- a/gens/commands/base.py +++ b/gens/commands/base.py @@ -9,6 +9,7 @@ from .index import index as index_command from .load import load as load_command from .view import view as view_command +from .delete import delete as delete_command @click.group( @@ -26,3 +27,4 @@ def cli(*args, **kwargs): cli.add_command(index_command) cli.add_command(load_command) cli.add_command(view_command) +cli.add_command(delete_command) diff --git a/gens/commands/delete.py b/gens/commands/delete.py new file mode 100644 index 00000000..a49ea1e2 --- /dev/null +++ b/gens/commands/delete.py @@ -0,0 +1,50 @@ +import logging +from pathlib import Path + +import click +from flask import current_app as app +from flask.cli import with_appcontext +from pymongo import ASCENDING + +from gens.constants import GENOME_BUILDS +from gens.db import (SAMPLES_COLLECTION, create_index, + get_indexes, delete_sample) + +LOG = logging.getLogger(__name__) +valid_genome_builds = [str(gb) for gb in GENOME_BUILDS] + + +@click.group() +def delete(): + """Delete information from Gens database""" + + +@delete.command() +@click.option("-i", "--sample-id", type=str, required=True, help="Sample id") +@click.option( + "-b", + "--genome-build", + type=click.Choice(valid_genome_builds), + required=True, + help="Genome build", +) +@click.option( + "-n", + "--case-id", + required=True, + help="Id of case", +) +@with_appcontext +def sample(sample_id, genome_build, case_id): + """Remove a sample from Gens database.""" + db = app.config["GENS_DB"] + # if collection is not indexed, create index + if len(get_indexes(db, SAMPLES_COLLECTION)) == 0: + create_index(db, SAMPLES_COLLECTION) + delete_sample( + db, + sample_id=sample_id, + case_id=case_id, + genome_build=genome_build, + ) + click.secho("Finished removing a sample from database ✔", fg="green") diff --git a/gens/commands/load.py b/gens/commands/load.py index b58d061c..6cc1bd0c 100644 --- a/gens/commands/load.py +++ b/gens/commands/load.py @@ -46,27 +46,40 @@ def load(): type=click.Path(exists=True), help="File or directory of annotation files to load into the database", ) +@click.option( + "-n", + "--case-id", + required=True, + help="Id of case", +) @click.option( "-j", "--overview-json", type=click.Path(exists=True), help="Json file that contains preprocessed overview coverage", ) +@click.option( + "--force", + is_flag=True, + help="Overwrite any existing sample with the same key.", +) @with_appcontext -def sample(sample_id, genome_build, baf, coverage, overview_json): +def sample(sample_id, genome_build, baf, coverage, case_id, overview_json, force): """Load a sample into Gens database.""" db = app.config["GENS_DB"] - # if collection is not indexed, crate index + # if collection is not indexed, create index if len(get_indexes(db, SAMPLES_COLLECTION)) == 0: create_index(db, SAMPLES_COLLECTION) # load samples store_sample( db, sample_id=sample_id, + case_id=case_id, genome_build=genome_build, baf=baf, coverage=coverage, overview=overview_json, + force=force, ) click.secho("Finished adding a new sample to database ✔", fg="green") @@ -90,7 +103,7 @@ def sample(sample_id, genome_build, baf, coverage, overview_json): def annotations(file, genome_build): """Load annotations from file into the database.""" db = app.config["GENS_DB"] - # if collection is not indexed, crate index + # if collection is not indexed, create index if len(get_indexes(db, ANNOTATIONS_COLLECTION)) == 0: create_index(db, ANNOTATIONS_COLLECTION) # check if path is a directoy of a file @@ -150,7 +163,7 @@ def annotations(file, genome_build): def transcripts(file, mane, genome_build): """Load transcripts into the database.""" db = app.config["GENS_DB"] - # if collection is not indexed, crate index + # if collection is not indexed, create index if len(get_indexes(db, TRANSCRIPTS_COLLECTION)) > 0: create_index(db, TRANSCRIPTS_COLLECTION) LOG.info("Building transcript object") @@ -189,7 +202,7 @@ def transcripts(file, mane, genome_build): def chromosome_info(file, genome_build, timeout): """Load chromosome size information into the database.""" db = app.config["GENS_DB"] - # if collection is not indexed, crate index + # if collection is not indexed, create index if len(get_indexes(db, CHROMSIZES_COLLECTION)) == 0: create_index(db, CHROMSIZES_COLLECTION) # get chromosome info from ensemble diff --git a/gens/commands/view.py b/gens/commands/view.py index 4e06653c..38e4107f 100644 --- a/gens/commands/view.py +++ b/gens/commands/view.py @@ -15,7 +15,7 @@ @click.group() def view(): - """Load information into Gens database""" + """View information loaded into Gens database""" @view.command() @@ -35,6 +35,7 @@ def samples(summary): else: # show all samples columns = ( "Sample Id", + "Case Id", "Genome build", "Created at", "baf file", @@ -44,6 +45,7 @@ def samples(summary): sample_tbl = ( ( s.sample_id, + s.case_id, str(s.genome_build), s.created_at.isoformat(), s.baf_file, diff --git a/gens/config.py b/gens/config.py index 824aa61d..726bfc83 100644 --- a/gens/config.py +++ b/gens/config.py @@ -1,10 +1,13 @@ """Gens default configuration.""" # Database connection -MONGODB_HOST = "mongodb" -MONGODB_PORT = 27017 +MONGODB_GENS_URI = "mongodb://mongodb:27017" +MONGODB_SCOUT_URI = "mongodb://mongodb:27017" GENS_DBNAME = "gens" SCOUT_DBNAME = "scout" +# Scout browser base URL for link out and API +SCOUT_BASE_URL = "http://localhost:8000" + # Annotation DEFAULT_ANNOTATION_TRACK = ( "Mimisbrunnr_databank_plausibly_pathogenic_CNVs_Lund_hg38.aed" @@ -14,3 +17,9 @@ "variants": {"del": "#C84630", "dup": "#4C6D94"}, "transcripts": {"strand_pos": "#aa4362", "strand_neg": "#43AA8B"}, } + +#GOOGLE = dict( +# client_id="some.apps.googleusercontent.com", +# client_secret="a_secret", +# discovery_url="https://accounts.google.com/.well-known/openid-configuration", +#) diff --git a/gens/db/__init__.py b/gens/db/__init__.py index f5fe0f0c..d7c34aed 100644 --- a/gens/db/__init__.py +++ b/gens/db/__init__.py @@ -9,4 +9,4 @@ from .index import create_index, create_indexes, get_indexes, update_indexes from .samples import COLLECTION as SAMPLES_COLLECTION from .samples import (SampleNotFoundError, get_samples, query_sample, - store_sample) + store_sample, delete_sample) diff --git a/gens/db/annotation.py b/gens/db/annotation.py index 31f6671c..1eff37f1 100644 --- a/gens/db/annotation.py +++ b/gens/db/annotation.py @@ -49,23 +49,22 @@ def get_timestamps(track_type="all"): return results -def query_variants(case_name: str, variant_category: VariantCategory, **kwargs): +def query_variants(case_id: str, sample_name: str, variant_category: VariantCategory, **kwargs): """Search the scout database for variants associated with a case. - case_id :: name for a case (not database uid) - varaint_category :: categories + case_id :: id for a case + sample_name :: display name for a sample + variant_category :: categories Kwargs are optional search parameters that are passed to db.find(). """ - # lookup case_id from the displayed name db = app.config["SCOUT_DB"] - response = db.case.find_one({"display_name": case_name}) - if response is None: - raise ValueError(f"No case with name: {case_name}") # build query query = { - "case_id": response["_id"], + "case_id": case_id, "category": variant_category.value, + "$or": [{"samples.sample_id": sample_name}, + {"samples.display_name": sample_name}] } # add chromosome if "chromosome" in kwargs: diff --git a/gens/db/db.py b/gens/db/db.py index b81ccfec..438e2e85 100644 --- a/gens/db/db.py +++ b/gens/db/db.py @@ -24,16 +24,15 @@ def init_database_connection() -> None: # verify that database was properly configured LOG.info("Initialize db connection") variables = {} - for var_name in ["MONGODB_HOST", "MONGODB_PORT", "SCOUT_DBNAME", "GENS_DBNAME"]: + for var_name in ["MONGODB_SCOUT_URI", "MONGODB_GENS_URI", "SCOUT_DBNAME", "GENS_DBNAME"]: if not any([var_name in os.environ, var_name in app.config]): raise ConfigurationException( f"Variable {var_name} not defined in either config or env variable" ) variables[var_name] = os.environ.get(var_name, app.config.get(var_name)) # connect to database - client = MongoClient( - host=variables["MONGODB_HOST"], port=int(variables["MONGODB_PORT"]) - ) + scout_client = MongoClient(variables["MONGODB_SCOUT_URI"]) + gens_client = MongoClient(variables["MONGODB_GENS_URI"]) # store db handlers in configuration - app.config["SCOUT_DB"] = client[variables["SCOUT_DBNAME"]] - app.config["GENS_DB"] = client[variables["GENS_DBNAME"]] + app.config["SCOUT_DB"] = scout_client[variables["SCOUT_DBNAME"]] + app.config["GENS_DB"] = gens_client[variables["GENS_DBNAME"]] diff --git a/gens/db/index.py b/gens/db/index.py index fa65cb78..01d172c2 100644 --- a/gens/db/index.py +++ b/gens/db/index.py @@ -68,6 +68,12 @@ background=True, unique=True, ), + IndexModel( + [("sample_id", ASCENDING), ("case_id", ASCENDING), ("genome_build", ASCENDING)], + name="sample__sample_id_case_id_genome_build", + background=True, + unique=True, + ), IndexModel( [("created_at", ASCENDING)], name="sample__creation_date", @@ -90,7 +96,7 @@ def get_indexes(db, collection): def create_index(db, collection_name): - """Create indexe for collection in Gens db.""" + """Create indexes for collection in Gens db.""" indexes = INDEXES[collection_name] existing_indexes = get_indexes(db, collection_name) # Drop old indexes diff --git a/gens/db/models.py b/gens/db/models.py index 9570c4a7..1948a98e 100644 --- a/gens/db/models.py +++ b/gens/db/models.py @@ -19,6 +19,7 @@ class VariantCategory(Enum): @attr.s(frozen=True) class SampleObj: sample_id: str = attr.ib() + case_id: str = attr.ib() baf_file: str = attr.ib() coverage_file: str = attr.ib() genome_build: int = attr.ib( diff --git a/gens/db/samples.py b/gens/db/samples.py index 8e188d3c..d3690196 100644 --- a/gens/db/samples.py +++ b/gens/db/samples.py @@ -5,6 +5,7 @@ import logging from pymongo import DESCENDING +from pymongo.errors import DuplicateKeyError from .models import SampleObj @@ -20,19 +21,58 @@ def __init__(self, message, sample_id): self.sample_id = sample_id -def store_sample(db, sample_id, genome_build, baf, coverage, overview): +class NonUniqueIndexError(Exception): + def __init__(self, message, sample_id, case_id, genome_build): + super().__init__(message) + + self.sample_id = sample_id + self.case_id = case_id + self.genome_build = genome_build + + +def store_sample(db, sample_id, case_id, genome_build, baf, coverage, overview, force): """Store a new sample in the database.""" LOG.info(f'Store sample "{sample_id}" in database') - db[COLLECTION].insert_one( - { - "sample_id": sample_id, - "baf_file": baf, - "coverage_file": coverage, - "overview_file": overview, - "genome_build": genome_build, - "created_at": datetime.datetime.now(), - } - ) + if force: + result = db[COLLECTION].update_one( + { + "sample_id": sample_id, + "case_id": case_id, + "genome_build": genome_build, + }, + { + "$set": + { + "sample_id": sample_id, + "case_id": case_id, + "baf_file": baf, + "coverage_file": coverage, + "overview_file": overview, + "genome_build": genome_build, + "created_at": datetime.datetime.now(), + } + }, + upsert=True + ) + if result.modified_count == 1: + LOG.error(f'Sample with sample_id="{sample_id}" and case_id="{case_id}" was overwritten.') + if result.modified_count > 1: + raise NonUniqueIndexError(f'More than one entry matched sample_id="{sample_id}", case_id="{case_id}", and genome_build="{genome_build}". This should never happen.', sample_id, case_id, genome_build) + else: + try: + db[COLLECTION].insert_one( + { + "sample_id": sample_id, + "case_id": case_id, + "baf_file": baf, + "coverage_file": coverage, + "overview_file": overview, + "genome_build": genome_build, + "created_at": datetime.datetime.now(), + } + ) + except DuplicateKeyError: + LOG.error(f'DuplicateKeyError while storing sample with sample_id="{sample_id}" and case_id="{case_id}" in database.') def get_samples(db, start=0, n_samples=None): @@ -44,6 +84,7 @@ def get_samples(db, start=0, n_samples=None): results = ( SampleObj( sample_id=r["sample_id"], + case_id=r["case_id"], genome_build=r["genome_build"], baf_file=r["baf_file"], coverage_file=r["coverage_file"], @@ -58,9 +99,13 @@ def get_samples(db, start=0, n_samples=None): return results, db[COLLECTION].count_documents({}) -def query_sample(db, sample_id, genome_build): +def query_sample(db, sample_id, case_id, genome_build): """Get a sample with id.""" - result = db[COLLECTION].find_one({"sample_id": sample_id}) + result = None + if case_id is None: + result = db[COLLECTION].find_one({"sample_id": sample_id}) + else: + result = db[COLLECTION].find_one({"sample_id": sample_id, "case_id": case_id}) if result is None: raise SampleNotFoundError( @@ -68,9 +113,22 @@ def query_sample(db, sample_id, genome_build): ) return SampleObj( sample_id=result["sample_id"], + case_id=result["case_id"], genome_build=result["genome_build"], baf_file=result["baf_file"], coverage_file=result["coverage_file"], overview_file=result["overview_file"], created_at=result["created_at"], ) + + +def delete_sample(db, sample_id, case_id, genome_build): + """Remove a sample from the database.""" + LOG.info(f'Removing sample "{sample_id}" from database') + db[COLLECTION].delete_one( + { + "sample_id": sample_id, + "case_id": case_id, + "genome_build": genome_build, + } + ) diff --git a/gens/db/users.py b/gens/db/users.py new file mode 100644 index 00000000..f50bdcb2 --- /dev/null +++ b/gens/db/users.py @@ -0,0 +1,41 @@ +"""Retrieve users from scout db""" +from flask_login import UserMixin + +from flask import current_app as app + +from typing import Optional + +class LoginUser(UserMixin): + def __init__(self, user_data): + """Create a new user object.""" + self.roles = [] + for key, value in user_data.items(): + setattr(self, key, value) + + def get_id(self): + return self.email + + @property + def is_admin(self): + """Check if the user is admin.""" + return "admin" in self.roles + + +def user(email: str) -> Optional[LoginUser]: + db = app.config["SCOUT_DB"] + + # LOG.info("Inside user") + + query = {} + query["email"] = email + + # LOG.info(f"Running query {query}") + # LOG.info(f"Database name: {db.name}") + # LOG.info(f"Database name: {db.list_collection_names()}") + # LOG.info(f"Collection exists: {'user' in db.list_collection_names()}") + + user_dict = db.user.find_one(query) + # LOG.info(f"User dict: {user_dict}") + user_obj = LoginUser(user_dict) if user_dict else None + + return user_obj diff --git a/gens/extensions/__init__.py b/gens/extensions/__init__.py new file mode 100644 index 00000000..15167ebd --- /dev/null +++ b/gens/extensions/__init__.py @@ -0,0 +1,6 @@ +from authlib.integrations.flask_client import OAuth + +from flask_login import LoginManager + +login_manager = LoginManager() +oauth_client = OAuth() diff --git a/gens/load/annotations.py b/gens/load/annotations.py index 0c54b53b..c5813af2 100644 --- a/gens/load/annotations.py +++ b/gens/load/annotations.py @@ -9,6 +9,13 @@ from gens.db import ANNOTATIONS_COLLECTION LOG = logging.getLogger(__name__) +FIELD_TRANSLATIONS = { + "chromosome": "sequence", + "position": "start", + "stop": "end", + "chromstart": "start", + "chromend": "end" +} CORE_FIELDS = ("sequence", "start", "end", "name", "strand", "color", "score") AED_ENTRY = re.compile(r"[.+:]?(\w+)\(\w+:(\w+)\)", re.I) @@ -21,25 +28,12 @@ class ParserError(Exception): def parse_bed(file, genome_build): """Parse bed file.""" - HEADER = ( - "sequence", - "start", - "end", - "name", - "score", - "strand", - "null", - "null", - "color", - ) with open(file) as bed: bed_reader = csv.DictReader(bed, delimiter="\t") - # Skip bad header info - next(bed_reader) # Load in annotations for line in bed_reader: - yield dict(zip(HEADER, line)) + yield line def parse_aed(file): @@ -65,6 +59,10 @@ def parse_annotation_entry(entry, genome_build, annotation_name): annotation = {} # parse entry and format the values for name, value in entry.items(): + name = name.strip("#") + name = name.lower() + if name in FIELD_TRANSLATIONS: + name = FIELD_TRANSLATIONS[name] if name in CORE_FIELDS: name = "chrom" if name == "sequence" else name # for compatibility try: @@ -166,7 +164,7 @@ def update_height_order(db, name): def parse_annotation_file(file, genome_build, file_format): - """Parse a annotation file in bed or aed format.""" + """Parse an annotation file in bed or aed format.""" if file_format == "bed": return parse_bed(file, genome_build) if file_format == "aed": diff --git a/gens/openapi/openapi.yaml b/gens/openapi/openapi.yaml index 64a036a9..b6e79074 100644 --- a/gens/openapi/openapi.yaml +++ b/gens/openapi/openapi.yaml @@ -22,7 +22,10 @@ paths: type: object properties: sample_id: - description: Sample id + description: Sample/individual displayname + type: string + case_id: + description: Case id type: string baf_y_start: description: Y coordinate from where to draw BAF track. @@ -94,7 +97,13 @@ paths: parameters: - name: sample_id in: query - description: Region selector + description: Sample/individual displayname + required: true + schema: + type: string + - name: case_id + in: query + description: Case id required: true schema: type: string @@ -216,6 +225,12 @@ paths: description: Get annotation data operationId: gens.api.get_variant_data parameters: + - name: case_id + in: query + description: Case id + required: true + schema: + type: string - name: sample_id in: query description: Unique sample identifier diff --git a/gens/static/css/.gitkeep b/gens/static/css/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/gens/templates/generic_abort_error.html b/gens/templates/generic_abort_error.html index e9c59413..8a249fc7 100644 --- a/gens/templates/generic_abort_error.html +++ b/gens/templates/generic_abort_error.html @@ -4,13 +4,13 @@ {% block css_style %} {{ super() }} - + {% endblock css_style %} {% block body %}

{{ error_code }}

- broken DNA + broken DNA

Something went wrong

Gens encountered and error. Please contact the administrator to resolve this issue.

diff --git a/gens/templates/generic_exception_error.html b/gens/templates/generic_exception_error.html index c9cd7630..3cd48af0 100644 --- a/gens/templates/generic_exception_error.html +++ b/gens/templates/generic_exception_error.html @@ -4,12 +4,12 @@ {% block css_style %} {{ super() }} - + {% endblock css_style %} {% block body %}
- broken DNA + broken DNA

Error - {{ error_type }}

{% for row in message %} diff --git a/gens/templates/sample_not_found.html b/gens/templates/sample_not_found.html index 708b98b4..217d93d6 100644 --- a/gens/templates/sample_not_found.html +++ b/gens/templates/sample_not_found.html @@ -4,12 +4,12 @@ {% block css_style %} {{ super() }} - + {% endblock css_style %} {% block body %}
- broken DNA + broken DNA

Missing sample

The sample {{ file_name }} has not been added to Gens database. Please contact the administrator to resolve this issue.

diff --git a/gulpfile.js b/gulpfile.js index 8af24311..5eb0aaf6 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -52,6 +52,15 @@ gulp.task('build-home-css', function () { .pipe(gulp.dest(`${dest}/css`)) }) +gulp.task('build-landing-css', function () { + return gulp.src(`${assetPath}/css/landing.scss`) + .pipe(rename('landing.min.css')) + .pipe(sourcemaps.init()) + .pipe(sass({ outputStyle: 'compressed' })) + .pipe(sourcemaps.write()) + .pipe(gulp.dest(`${dest}/css`)) +}) + gulp.task('build-about-css', function () { return gulp.src(`${assetPath}/css/about.scss`) .pipe(rename('about.min.css')) @@ -71,7 +80,7 @@ gulp.task('build-error-css', function () { }) gulp.task('build', gulp.parallel('build-js', 'build-gens-css', -'build-home-css', 'build-about-css', 'build-error-css')) +'build-home-css', 'build-landing-css', 'build-about-css', 'build-error-css')) // DEVELOPMENT tasks // @@ -99,6 +108,13 @@ gulp.task('build-about-css-dev', () => { .pipe(gulp.dest(devAboutAssets)) }) +gulp.task('build-landing-css-dev', () => { + return gulp.src(`${assetPath}/css/landing.scss`) + .pipe(rename('landing.min.css')) + .pipe(sass()) + .pipe(gulp.dest(devAboutAssets)) +}) + gulp.task('build-home-css-dev', () => { return gulp.src(`${assetPath}/css/home.scss`) .pipe(rename('home.min.css')) @@ -115,7 +131,7 @@ gulp.task('build-error-css-dev', () => { gulp.task('watch', () => { gulp.watch(`${assetPath}/css/*.scss`, gulp.parallel('build-gens-css-dev', 'build-home-css-dev', - 'build-about-css-dev', 'build-error-css-dev')) + 'build-about-css-dev', 'build-landing-css-dev', 'build-error-css-dev')) gulp.watch(`${assetPath}/js/*.js`, gulp.parallel('build-js-dev')) gulp.watch(`${assetPath}/js/*/*.js`, gulp.parallel('build-js-dev')) }) diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..24d28ebd --- /dev/null +++ b/jest.config.js @@ -0,0 +1,194 @@ +/* + * For a detailed explanation regarding each configuration property, visit: + * https://jestjs.io/docs/en/configuration.html + */ + +module.exports = { + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "/trannel/tmp/jest_s7", + + // Automatically clear mock calls and instances between every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + // collectCoverage: false, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: undefined, + + // The directory where Jest should output its coverage files + coverageDirectory: "coverage", + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "/node_modules/" + // ], + + // Indicates which provider should be used to instrument code for coverage + coverageProvider: "v8", + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, + + // A path to a custom dependency extractor + // dependencyExtractor: undefined, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: undefined, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, + + // A set of global variables that need to be available in all test environments + // globals: {}, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "json", + // "jsx", + // "ts", + // "tsx", + // "node" + // ], + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + moduleNameMapper: {"\\.(css|less)$": "identity-obj-proxy"}, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + // preset: undefined, + + // Run tests from one or more projects + // projects: undefined, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state between every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: undefined, + + // Automatically restore mock state between every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + // rootDir: undefined, + + // A list of paths to directories that Jest should use to search for files in + // roots: [ + // "" + // ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: [], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + // setupFilesAfterEnv: [], + + // The number of seconds after which a test is considered as slow and reported as such in the results. + // slowTestThreshold: 5, + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + testEnvironment: "jsdom", + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + // testMatch: [ + // "**/__tests__/**/*.[jt]s?(x)", + // "**/?(*.)+(spec|test).[tj]s?(x)" + // ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // "/node_modules/" + // ], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + // testRunner: "jasmine2", + + // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href + // testURL: "http://localhost", + + // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" + // timers: "real", + + // A map from regular expressions to paths to transformers + // transform: undefined, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "/node_modules/", + // "\\.pnp\\.[^\\/]+$" + // ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: undefined, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; diff --git a/package-lock.json b/package-lock.json index f0ed5210..3438e244 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gens", - "version": "2.1.0", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gens", - "version": "2.1.0", + "version": "2.3.0", "dependencies": { "@popperjs/core": "^2.9.2", "npm": "^10.2.3", @@ -18,6 +18,7 @@ "@babel/preset-env": "^7.13.10", "css-loader": "^5.2.7", "eslint": "^7.22.0", + "eslint-config-prettier": "^9.1.0", "eslint-config-standard": "^16.0.2", "eslint-plugin-import": "^2.22.1", "eslint-plugin-node": "^11.1.0", @@ -27,7 +28,9 @@ "gulp-rename": "^2.0.0", "gulp-sass": "^5.0.0", "gulp-sourcemaps": "^3.0.0", + "identity-obj-proxy": "^3.0.0", "jest": "^26.6.3", + "prettier": "3.3.3", "process": "^0.11.10", "regenerator-runtime": "^0.13.7", "sass": "^1.32.8", @@ -5006,6 +5009,18 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-config-prettier": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", + "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-config-standard": { "version": "16.0.3", "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-16.0.3.tgz", @@ -7170,6 +7185,12 @@ "node": ">= 0.10" } }, + "node_modules/harmony-reflect": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz", + "integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==", + "dev": true + }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -7386,6 +7407,18 @@ "postcss": "^8.1.0" } }, + "node_modules/identity-obj-proxy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz", + "integrity": "sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==", + "dev": true, + "dependencies": { + "harmony-reflect": "^1.4.6" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/ignore": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", @@ -11328,6 +11361,8 @@ }, "node_modules/npm/node_modules/aggregate-error": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", "inBundle": true, "license": "MIT", "dependencies": { @@ -11340,6 +11375,8 @@ }, "node_modules/npm/node_modules/ansi-regex": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "inBundle": true, "license": "MIT", "engines": { @@ -11348,6 +11385,8 @@ }, "node_modules/npm/node_modules/ansi-styles": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "inBundle": true, "license": "MIT", "dependencies": { @@ -11362,6 +11401,8 @@ }, "node_modules/npm/node_modules/aproba": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", "inBundle": true, "license": "ISC" }, @@ -11384,6 +11425,8 @@ }, "node_modules/npm/node_modules/balanced-match": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "inBundle": true, "license": "MIT" }, @@ -11422,6 +11465,8 @@ }, "node_modules/npm/node_modules/binary-extensions": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", "inBundle": true, "license": "MIT", "engines": { @@ -11502,6 +11547,8 @@ }, "node_modules/npm/node_modules/chownr": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", "inBundle": true, "license": "ISC", "engines": { @@ -11535,6 +11582,8 @@ }, "node_modules/npm/node_modules/clean-stack": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", "inBundle": true, "license": "MIT", "engines": { @@ -11569,6 +11618,8 @@ }, "node_modules/npm/node_modules/clone": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", "inBundle": true, "license": "MIT", "engines": { @@ -11585,6 +11636,8 @@ }, "node_modules/npm/node_modules/color-convert": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "inBundle": true, "license": "MIT", "dependencies": { @@ -11596,11 +11649,15 @@ }, "node_modules/npm/node_modules/color-name": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/color-support": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", "inBundle": true, "license": "ISC", "bin": { @@ -11621,16 +11678,22 @@ }, "node_modules/npm/node_modules/common-ancestor-path": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", + "integrity": "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==", "inBundle": true, "license": "ISC" }, "node_modules/npm/node_modules/console-control-strings": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", "inBundle": true, "license": "ISC" }, "node_modules/npm/node_modules/cross-spawn": { "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", "inBundle": true, "license": "MIT", "dependencies": { @@ -11644,6 +11707,8 @@ }, "node_modules/npm/node_modules/cross-spawn/node_modules/which": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "inBundle": true, "license": "ISC", "dependencies": { @@ -11658,6 +11723,8 @@ }, "node_modules/npm/node_modules/cssesc": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "inBundle": true, "license": "MIT", "bin": { @@ -11685,6 +11752,8 @@ }, "node_modules/npm/node_modules/debug/node_modules/ms": { "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "inBundle": true, "license": "MIT" }, @@ -11701,6 +11770,8 @@ }, "node_modules/npm/node_modules/delegates": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", "inBundle": true, "license": "MIT" }, @@ -11719,6 +11790,8 @@ }, "node_modules/npm/node_modules/emoji-regex": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "inBundle": true, "license": "MIT" }, @@ -11733,6 +11806,8 @@ }, "node_modules/npm/node_modules/env-paths": { "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "inBundle": true, "license": "MIT", "engines": { @@ -11741,6 +11816,8 @@ }, "node_modules/npm/node_modules/err-code": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", "inBundle": true, "license": "MIT" }, @@ -11801,6 +11878,8 @@ }, "node_modules/npm/node_modules/function-bind": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", "inBundle": true, "license": "MIT" }, @@ -11850,6 +11929,8 @@ }, "node_modules/npm/node_modules/has": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", "inBundle": true, "license": "MIT", "dependencies": { @@ -11861,6 +11942,8 @@ }, "node_modules/npm/node_modules/has-unicode": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", "inBundle": true, "license": "ISC" }, @@ -11882,6 +11965,8 @@ }, "node_modules/npm/node_modules/iconv-lite": { "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "inBundle": true, "license": "MIT", "optional": true, @@ -11924,6 +12009,8 @@ }, "node_modules/npm/node_modules/imurmurhash": { "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", "inBundle": true, "license": "MIT", "engines": { @@ -11932,6 +12019,8 @@ }, "node_modules/npm/node_modules/indent-string": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "inBundle": true, "license": "MIT", "engines": { @@ -12000,6 +12089,8 @@ }, "node_modules/npm/node_modules/is-fullwidth-code-point": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "inBundle": true, "license": "MIT", "engines": { @@ -12008,11 +12099,15 @@ }, "node_modules/npm/node_modules/is-lambda": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/isexe": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", "inBundle": true, "license": "ISC" }, @@ -12043,6 +12138,8 @@ }, "node_modules/npm/node_modules/json-stringify-nice": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/json-stringify-nice/-/json-stringify-nice-1.1.4.tgz", + "integrity": "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw==", "inBundle": true, "license": "ISC", "funding": { @@ -12051,6 +12148,8 @@ }, "node_modules/npm/node_modules/jsonparse": { "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", "engines": [ "node >= 0.2.0" ], @@ -12277,6 +12376,8 @@ }, "node_modules/npm/node_modules/minipass-collect": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", "inBundle": true, "license": "ISC", "dependencies": { @@ -12315,6 +12416,8 @@ }, "node_modules/npm/node_modules/minipass-flush": { "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", "inBundle": true, "license": "ISC", "dependencies": { @@ -12337,6 +12440,8 @@ }, "node_modules/npm/node_modules/minipass-json-stream": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minipass-json-stream/-/minipass-json-stream-1.0.1.tgz", + "integrity": "sha512-ODqY18UZt/I8k+b7rl2AENgbWE8IDYam+undIJONvigAz8KR5GWblsFTEfQs0WODsjbSXWlm+JHEv8Gr6Tfdbg==", "inBundle": true, "license": "MIT", "dependencies": { @@ -12357,6 +12462,8 @@ }, "node_modules/npm/node_modules/minipass-pipeline": { "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", "inBundle": true, "license": "ISC", "dependencies": { @@ -12379,6 +12486,8 @@ }, "node_modules/npm/node_modules/minipass-sized": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", "inBundle": true, "license": "ISC", "dependencies": { @@ -12401,6 +12510,8 @@ }, "node_modules/npm/node_modules/minizlib": { "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", "inBundle": true, "license": "MIT", "dependencies": { @@ -12424,6 +12535,8 @@ }, "node_modules/npm/node_modules/mkdirp": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "inBundle": true, "license": "MIT", "bin": { @@ -12635,6 +12748,8 @@ }, "node_modules/npm/node_modules/p-map": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", "inBundle": true, "license": "MIT", "dependencies": { @@ -12693,6 +12808,8 @@ }, "node_modules/npm/node_modules/path-key": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "inBundle": true, "license": "MIT", "engines": { @@ -12736,6 +12853,8 @@ }, "node_modules/npm/node_modules/process": { "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=", "inBundle": true, "license": "MIT", "engines": { @@ -12744,6 +12863,8 @@ }, "node_modules/npm/node_modules/promise-all-reject-late": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-all-reject-late/-/promise-all-reject-late-1.0.1.tgz", + "integrity": "sha512-vuf0Lf0lOxyQREH7GDIOUMLS7kz+gs8i6B+Yi8dC68a2sychGrHTJYghMBD6k7eUcH0H5P73EckCA48xijWqXw==", "inBundle": true, "license": "ISC", "funding": { @@ -12760,11 +12881,15 @@ }, "node_modules/npm/node_modules/promise-inflight": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", "inBundle": true, "license": "ISC" }, "node_modules/npm/node_modules/promise-retry": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", "inBundle": true, "license": "MIT", "dependencies": { @@ -12855,6 +12980,8 @@ }, "node_modules/npm/node_modules/retry": { "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", "inBundle": true, "license": "MIT", "engines": { @@ -12863,6 +12990,8 @@ }, "node_modules/npm/node_modules/safe-buffer": { "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "funding": [ { "type": "github", @@ -12882,12 +13011,16 @@ }, "node_modules/npm/node_modules/safer-buffer": { "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "inBundle": true, "license": "MIT", "optional": true }, "node_modules/npm/node_modules/semver": { "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "inBundle": true, "license": "ISC", "dependencies": { @@ -12902,6 +13035,8 @@ }, "node_modules/npm/node_modules/semver/node_modules/lru-cache": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "inBundle": true, "license": "ISC", "dependencies": { @@ -12913,11 +13048,15 @@ }, "node_modules/npm/node_modules/set-blocking": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", "inBundle": true, "license": "ISC" }, "node_modules/npm/node_modules/shebang-command": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "inBundle": true, "license": "MIT", "dependencies": { @@ -12929,6 +13068,8 @@ }, "node_modules/npm/node_modules/shebang-regex": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "inBundle": true, "license": "MIT", "engines": { @@ -12993,11 +13134,15 @@ }, "node_modules/npm/node_modules/spdx-exceptions": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", "inBundle": true, "license": "CC-BY-3.0" }, "node_modules/npm/node_modules/spdx-expression-parse": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", "inBundle": true, "license": "MIT", "dependencies": { @@ -13031,6 +13176,8 @@ }, "node_modules/npm/node_modules/string-width": { "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "inBundle": true, "license": "MIT", "dependencies": { @@ -13058,6 +13205,8 @@ }, "node_modules/npm/node_modules/strip-ansi": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "inBundle": true, "license": "MIT", "dependencies": { @@ -13191,11 +13340,15 @@ }, "node_modules/npm/node_modules/util-deprecate": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/validate-npm-package-license": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", "inBundle": true, "license": "Apache-2.0", "dependencies": { @@ -13221,6 +13374,8 @@ }, "node_modules/npm/node_modules/wcwidth": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", "inBundle": true, "license": "MIT", "dependencies": { @@ -13361,6 +13516,8 @@ }, "node_modules/npm/node_modules/yallist": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "inBundle": true, "license": "ISC" }, @@ -14092,6 +14249,21 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", diff --git a/package.json b/package.json index b979b489..665a46a6 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,16 @@ "tippy.js": "^6.3.2" }, "name": "gens", - "version": "2.1.2", + "version": "3.0.0", "description": "Interactive tool to visualize genomic copy number profiles from WGS data", "main": "gens.js", "scripts": { "build": "gulp build", "lint": "eslint assets", + "lint:fix": "npm run lint -- --fix", + "prettier": "npx prettier assets --check", + "prettier:fix": "npm run prettier -- --write", + "format": "npm run prettier:fix && npm run lint:fix", "watch": "gulp watch", "test": "jest" }, @@ -47,6 +51,7 @@ "@babel/preset-env": "^7.13.10", "css-loader": "^5.2.7", "eslint": "^7.22.0", + "eslint-config-prettier": "^9.1.0", "eslint-config-standard": "^16.0.2", "eslint-plugin-import": "^2.22.1", "eslint-plugin-node": "^11.1.0", @@ -56,7 +61,9 @@ "gulp-rename": "^2.0.0", "gulp-sass": "^5.0.0", "gulp-sourcemaps": "^3.0.0", + "identity-obj-proxy": "^3.0.0", "jest": "^26.6.3", + "prettier": "3.3.3", "process": "^0.11.10", "regenerator-runtime": "^0.13.7", "sass": "^1.32.8", diff --git a/requirements.txt b/requirements.txt index 0dc2193d..543cc537 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ +authlib +flask_login gunicorn . diff --git a/setup.cfg b/setup.cfg index 97afa0c7..c47ba1c4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2.1.1 +current_version = 3.0.0 [metadata] description-file = README.md diff --git a/setup.py b/setup.py index c89393f0..148f7617 100644 --- a/setup.py +++ b/setup.py @@ -6,10 +6,10 @@ setup( name="gens", - version="2.1.1", + version="3.0.0", description="Gens is a web-based interactive tool to visualize genomic copy number profiles from WGS data.", license="MIT", - author="Ronja, Markus Johansson", + author="Ronja Grosz, Markus Johansson", classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", diff --git a/utils/Dockerfile b/utils/Dockerfile new file mode 100644 index 00000000..a72ec331 --- /dev/null +++ b/utils/Dockerfile @@ -0,0 +1,4 @@ +# syntax=docker/dockerfile:1 +FROM clinicalgenomics/htslib:1.13 +WORKDIR /bin +COPY . . diff --git a/utils/generate_gens_data.pl b/utils/generate_gens_data.pl index 309202e8..7f439499 100755 --- a/utils/generate_gens_data.pl +++ b/utils/generate_gens_data.pl @@ -23,7 +23,7 @@ # Calculate coverage data open( COVOUT, ">".$COV_OUTPUT ); for my $i (0..$#COV_WINDOW_SIZES) { - generate_cov_bed($cov_fn, $COV_WINDOW_SIZES[$i], $PREFIXES[$i]); + generate_cov_bed($cov_fn, $COV_WINDOW_SIZES[$i], $PREFIXES[$i]); } close COVOUT; @@ -31,13 +31,14 @@ print STDERR "Calculating BAFs from gvcf...\n"; # Calculate BAFs system( - $SCRIPT_ROOT."/gvcfvaf.pl ". - "$gvcf_fn $GNOMAD > baf.tmp" - ); + $SCRIPT_ROOT."/gvcfvaf.pl ". + "$gvcf_fn $GNOMAD > ".$SAMPLE_ID.".baf.tmp" + ); + open( BAFOUT, ">".$BAF_OUTPUT ); for my $i (0..$#BAF_SKIP_N) { - print STDERR "Outputting BAF $PREFIXES[$i]...\n"; - generate_baf_bed("baf.tmp", $BAF_SKIP_N[$i], $PREFIXES[$i]); + print STDERR "Outputting BAF $PREFIXES[$i]...\n"; + generate_baf_bed($SAMPLE_ID.".baf.tmp", $BAF_SKIP_N[$i], $PREFIXES[$i]); } close BAFOUT; @@ -46,76 +47,76 @@ system("tabix -f -p bed $BAF_OUTPUT.gz"); system("bgzip -f -\@10 $COV_OUTPUT"); system("tabix -f -p bed $COV_OUTPUT.gz"); -unlink("baf.tmp"); +unlink($SAMPLE_ID.".baf.tmp"); sub generate_baf_bed { - my( $fn, $skip, $prefix ) = @_; - open( my $fh, $fn ); - my $i = 0; - while(<$fh>) { - if( $i++ % $skip == 0 ) { - chomp; - my @a = split /\t/; - print BAFOUT $prefix."_".$a[0]."\t".($a[1]-1)."\t".$a[1]."\t".$a[2]."\n"; + my( $fn, $skip, $prefix ) = @_; + open( my $fh, $fn ); + my $i = 0; + while(<$fh>) { + if( $i++ % $skip == 0 ) { + chomp; + my @a = split /\t/; + next if $#a != 2; + print BAFOUT $prefix."_".$a[0]."\t".($a[1]-1)."\t".$a[1]."\t".$a[2]."\n"; + } } - } - close $fn; + close $fn; } sub generate_cov_bed { - my( $fn, $win_size, $prefix ) = @_; - - open(my $fh, $fn); - my( $reg_start, $reg_end, $reg_chr, $force_end ); - my @reg_ratios; - while(<$fh>) { - next if /^@/ or /^CONTIG/; - chomp; - my ($chr, $start, $end, $ratio ) = split /\t/; - my $orig_end = $end; - unless( $reg_start ) { - $reg_start = $start; - $reg_end = $end; - $reg_chr = $chr; - } - - if( $chr eq $reg_chr ) { - if( $start - $reg_end < $win_size ) { - push @reg_ratios, $ratio; - $reg_end = $end; - } + my( $fn, $win_size, $prefix ) = @_; + + open(my $fh, $fn); + my( $reg_start, $reg_end, $reg_chr, $force_end ); + my @reg_ratios; + while(<$fh>) { + next if /^@/ or /^CONTIG/; + chomp; + my ($chr, $start, $end, $ratio ) = split /\t/; + my $orig_end = $end; + unless( $reg_start ) { + $reg_start = $start; + $reg_end = $end; + $reg_chr = $chr; + } - # If there is a large gap to the next region, prematurely end region - else { - $force_end = 1; - $end = $reg_end; - } - } - else { - $force_end = 1; - $end = $reg_end; - } - if( $end - $reg_start + 1 >= $win_size or $force_end ) { - my $mid_point = $reg_start + int(($end - $reg_start)/2); - print COVOUT $prefix."_".$reg_chr."\t".($mid_point-1)."\t".$mid_point."\t".mean(@reg_ratios)."\n"; - undef $reg_start; - undef $reg_end; - undef $reg_chr; - undef @reg_ratios; - } + if( $chr eq $reg_chr ) { + if( $start - $reg_end < $win_size ) { + push @reg_ratios, $ratio; + $reg_end = $end; + } + # If there is a large gap to the next region, prematurely end region + else { + $force_end = 1; + $end = $reg_end; + } + } + else { + $force_end = 1; + $end = $reg_end; + } + if( $end - $reg_start + 1 >= $win_size or $force_end ) { + my $mid_point = $reg_start + int(($end - $reg_start)/2); + print COVOUT $prefix."_".$reg_chr."\t".($mid_point-1)."\t".$mid_point."\t".mean(@reg_ratios)."\n"; + undef $reg_start; + undef $reg_end; + undef $reg_chr; + undef @reg_ratios; + } - if( $force_end ) { - $reg_start = $start; - $reg_end = $orig_end; - $reg_chr = $chr; - push @reg_ratios, $ratio; - undef $force_end; + if( $force_end ) { + $reg_start = $start; + $reg_end = $orig_end; + $reg_chr = $chr; + push @reg_ratios, $ratio; + undef $force_end; + } } - } - close $fh; + close $fh; } - + sub mean { - return sum(@_)/@_; + return sum(@_)/@_; } diff --git a/utils/gvcfvaf.pl b/utils/gvcfvaf.pl index 56f6ec9a..f2576b71 100755 --- a/utils/gvcfvaf.pl +++ b/utils/gvcfvaf.pl @@ -11,7 +11,7 @@ my @chr_order = qw(1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 X Y MT); my %chr_order_hash; foreach my $i (0..$#chr_order ) { - $chr_order_hash{$chr_order[$i]} = $i; + $chr_order_hash{$chr_order[$i]} = $i; } open(GNOMAD, $ARGV[1]); @@ -19,108 +19,117 @@ open(GVCF, "zcat $ARGV[0]|"); my $gvcf_line; while($gvcf_line = ) { - last unless $gvcf_line =~ /^#/; + last unless $gvcf_line =~ /^#/; } +my $gvcf_count = 0; +my $gnomad_count = 0; +my $match_count = 0; + #my $gvcf = parse_gvcf_entry($gvcf_line); my $gvcf = gvcf_position($gvcf_line); -my $skipped = 0; while() { - chomp; - my( $gnomad_chr, $gnomad_pos ) = split /\t/; - print STDERR "Skipping gnomad $gnomad_chr $gnomad_pos ".$gvcf->{chr}." ".$gvcf->{start}."\n" if chr_less($gnomad_chr, $gvcf->{chr}); - next if chr_less($gnomad_chr, $gvcf->{chr}); - if( $gvcf->{start} < $gnomad_pos or chr_less($gvcf->{chr}, $gnomad_chr) ) { - my $prev_gvcf_chr = $gvcf->{chr}; - do { - $gvcf_line = ; - #$gvcf = parse_gvcf_entry($a); - $gvcf = gvcf_position($gvcf_line); - } until eof(GVCF) or ( $gvcf->{end} >= $gnomad_pos or $gvcf->{start} >= $gnomad_pos or $gvcf->{chr} ne $prev_gvcf_chr ); - } - if( $gnomad_pos >= $gvcf->{start} and $gnomad_chr eq $gvcf->{chr} ) { - if( $gnomad_pos <= $gvcf->{end} ) { - $gvcf = parse_gvcf_entry($gvcf_line); - print $gnomad_chr."\t".$gnomad_pos."\t".($gvcf->{frq} or 0)."\n" if defined $gvcf->{frq}; + chomp; + my( $gnomad_chr, $gnomad_pos ) = split /\t/; + ++$gnomad_count; + while( !eof(GVCF) and chr_position_less($gvcf->{chr}, $gvcf->{start}, $gnomad_chr, $gnomad_pos) ) { + $gvcf_line = ; + #$gvcf = parse_gvcf_entry($a); + $gvcf = gvcf_position($gvcf_line); + ++$gvcf_count; } - else { - $skipped ++; + if( $gnomad_pos == $gvcf->{start} and $gnomad_chr eq $gvcf->{chr} ) { + $gvcf = parse_gvcf_entry($gvcf_line); + print $gnomad_chr."\t".$gnomad_pos."\t".($gvcf->{frq} or 0)."\n" if defined $gvcf->{frq}; + ++$match_count; } - } - last if eof(GVCF); -} + last if eof(GVCF); +} + +#print STDERR "$gvcf_count variants found.\n"; +#print STDERR "$gnomad_count gnomad positions found.\n"; +#print STDERR "$match_count variants found at gnomad positions!\n"; +my $skipped = $gvcf_count - $match_count; print STDERR "$skipped variants skipped!\n"; +sub chr_position_less { + my( $chr1, $pos1, $chr2, $pos2 ) = @_; + return $pos1 < $pos2 if $chr1 eq $chr2; + return chr_less($chr1, $chr2); +} + sub chr_less { - my( $chr1, $chr2 ) = @_; - return $chr_order_hash{$chr1} < $chr_order_hash{$chr2}; + my( $chr1, $chr2 ) = @_; + return $chr_order_hash{$chr1} < $chr_order_hash{$chr2}; } sub gvcf_position { - my $str = shift; - my %data; - my @a = split /\t/, $str; - $data{chr} = $a[0]; - $data{start} = $a[1]; - $a[7] =~ /(^|;)END=(.*?)(;|$)/; - $data{end} = ($2 or $a[1]); - return \%data; + my $str = shift; + my %data; + my @a = split /\t/, $str; + $data{chr} = $a[0]; + $data{start} = $a[1]; + $a[7] =~ /(^|;)END=(.*?)(;|$)/; + $data{end} = ($2 or $a[1]); + return \%data; } - -sub parse_gvcf_entry { - my $str = shift; - chomp $str; - my %data; - my @a = split /\t/, $str; - $data{str} = $str; - $data{chr} = $a[0]; - $data{start} = $a[1]; - $a[7] =~ /(^|;)END=(.*?)(;|$)/; - $data{end} = ($2 or $a[1]); - return \%data if length($a[3]) > 1; +sub parse_gvcf_entry { + my $str = shift; + chomp $str; + my %data; + my @a = split /\t/, $str; + $data{str} = $str; + $data{chr} = $a[0]; + $data{start} = $a[1]; + $a[7] =~ /(^|;)END=(.*?)(;|$)/; + $data{end} = ($2 or $a[1]); + return \%data if length($a[3]) > 1; - unless( $a[8] =~ /:AD:/ ) { - $data{frq} = 0; - } - else { - my @ALT = split /,/, $a[4]; - my @fmt = split /:/, $a[8]; - my @sam = split /:/, $a[9]; - my( $alt, $alt_cnt, $dp ); - for my $i (0..@fmt) { - if( $fmt[$i] eq "GT" ) { - my( $a, $b ) = (split /\//, $sam[$i]); - $alt = $b; - return \%data if $alt != 0 and( !defined $ALT[$alt-1] or length($ALT[$alt-1]) > 1); - - } - if( $fmt[$i] eq "AD" ) { - if( $alt != 0 ) { - $alt_cnt = (split /,/, $sam[$i])[$alt]; + unless( $a[8] =~ /:AD:/ ) { + $data{frq} = 0; + } + else { + my @ALT = split /,/, $a[4]; + my @fmt = split /:/, $a[8]; + my @sam = split /:/, $a[9]; + my( $alt, $alt_cnt, $dp ); + for my $i (0 .. $#fmt) { + if( $fmt[$i] eq "GT" ) { + my( $a, $b ) = (split /\//, $sam[$i]); + $alt = $b; + return \%data if $alt eq "." or $alt != 0 and ( !defined $ALT[$alt-1] or length($ALT[$alt-1]) > 1 ); + last; + } + } + for my $i (0 .. $#fmt) { + if( $fmt[$i] eq "AD" ) { + if( $alt != 0 ) { + $alt_cnt = (split /,/, $sam[$i])[$alt]; + } + else { + my @cnts = split /,/, $sam[$i]; + shift @cnts; + pop @cnts; + $alt_cnt = max(@cnts); + } + last; + } } - else { - my @cnts = split /,/, $sam[$i]; - shift @cnts; - pop @cnts; - $alt_cnt = max(@cnts); + for my $i (0 .. $#fmt) { + if( $fmt[$i] eq "DP" ) { + $dp = $sam[$i]; + last; + } } - } - if( $fmt[$i] eq "DP" ) { - $dp = $sam[$i]; - last; - } - + return \%data if $dp < 10; + $data{frq} = $alt_cnt / $dp; + # $data{alt} = $ALT[$alt-1]; + $data{ref} = $a[3]; + $data{all} = $a[9]; } - return \%data if $dp < 10; - $data{frq} = $alt_cnt/$dp; - $data{alt} = $ALT[$alt]; - $data{ref} = $a[3]; - $data{all} = $a[9]; - } - - - return \%data; + + return \%data; }