From d1534f755148fd23ae831b97bf305d0bd472591c Mon Sep 17 00:00:00 2001 From: sagely1 <114952739+sagely1@users.noreply.github.com> Date: Thu, 19 Dec 2024 08:05:03 -0800 Subject: [PATCH] feat(agora): migrate GCT (AG-1602) (#2953) --- .vscode/launch.json | 22 + apps/agora/api/src/components/distribution.ts | 1 - apps/agora/api/src/models/overall-scores.ts | 5 +- apps/agora/app/project.json | 4 +- apps/agora/app/src/app/app.component.html | 12 +- apps/agora/app/src/app/app.component.scss | 9 + apps/agora/app/src/app/app.component.ts | 11 +- apps/agora/app/src/app/app.routes.ts | 31 +- .../app/src/types/dom-to-image-more.d.ts | 4 + libs/agora/about/src/lib/about.component.html | 8 + ...nt.spec.ts => about.component.spec.ts.off} | 0 libs/agora/about/src/lib/about.component.ts | 29 +- .../src/lib/model/similarGenesNetwork.ts | 8 +- .../src/lib/model/similarGenesNetworkLink.ts | 10 +- .../src/lib/model/similarGenesNetworkNode.ts | 6 +- libs/agora/api-description/build/openapi.yaml | 15 + .../schemas/SimilarGenesNetwork.yaml | 5 + .../schemas/SimilarGenesNetworkLink.yaml | 6 + .../schemas/SimilarGenesNetworkNode.yaml | 4 + libs/agora/assets/icons/gct.svg | 65 + libs/agora/{wiki => charts}/.eslintrc.json | 0 libs/agora/{wiki => charts}/README.md | 4 +- libs/agora/{wiki => charts}/jest.config.ts | 11 +- libs/agora/{wiki => charts}/project.json | 14 +- libs/agora/charts/src/index.ts | 9 + .../lib/base-chart/base-chart.component.html | 15 + .../lib/base-chart/base-chart.component.scss | 0 .../base-chart.component.spec.ts.off | 24 + .../lib/base-chart/base-chart.component.ts | 168 +++ .../biodomains-chart.component.html | 12 + .../biodomains-chart.component.scss | 69 + .../biodomains-chart.component.spec.ts.off | 53 + .../biodomains-chart.component.ts | 377 +++++ .../box-plot-chart.component.html | 31 + .../box-plot-chart.component.scss | 0 .../box-plot-chart.component.spec.ts.off | 73 + .../box-plot-chart.component.ts | 198 +++ .../charts/src/lib/box-plot-chart/box-plot.ts | 955 ++++++++++++ .../candlestick-chart.component.html | 19 + .../candlestick-chart.component.scss | 58 + .../candlestick-chart.component.spec.ts.off | 64 + .../candlestick-chart.component.ts | 212 +++ .../median-barchart.component.html | 9 + .../median-barchart.component.scss | 27 + .../median-barchart.component.spec.ts.off | 203 +++ .../median-barchart.component.ts | 301 ++++ .../median-chart/median-chart.component.html | 18 + .../median-chart/median-chart.component.scss | 0 .../median-chart.component.spec.ts.off | 64 + .../median-chart/median-chart.component.ts | 153 ++ .../network-chart.component.html | 12 + .../network-chart.component.scss | 0 .../network-chart.component.spec.ts.off | 55 + .../network-chart/network-chart.component.ts | 320 ++++ .../src/lib/network-chart/symbol-hexagon.ts | 38 + .../lib/row-chart/row-chart.component.html | 19 + .../lib/row-chart/row-chart.component.scss | 0 .../row-chart/row-chart.component.spec.ts.off | 64 + .../src/lib/row-chart/row-chart.component.ts | 568 +++++++ .../score-barchart.component.html | 9 + .../score-barchart.component.scss | 24 + .../score-barchart.component.spec.ts.off | 104 ++ .../score-barchart.component.ts | 452 ++++++ .../score-chart/score-chart.component.html | 15 + .../score-chart/score-chart.component.scss | 0 .../score-chart.component.spec.ts.off | 77 + .../lib/score-chart/score-chart.component.ts | 221 +++ libs/agora/charts/src/test-setup.ts | 1 + libs/agora/{wiki => charts}/tsconfig.json | 0 libs/agora/charts/tsconfig.lib.json | 12 + libs/agora/charts/tsconfig.spec.json | 17 + .../agora/gene-comparison-tool/.eslintrc.json | 40 + libs/agora/gene-comparison-tool/README.md | 7 + .../agora/gene-comparison-tool/jest.config.ts | 23 + libs/agora/gene-comparison-tool/project.json | 27 + libs/agora/gene-comparison-tool/src/index.ts | 1 + ...mparison-tool-details-panel.component.html | 89 ++ ...mparison-tool-details-panel.component.scss | 262 ++++ ...n-tool-details-panel.component.spec.ts.off | 72 + ...comparison-tool-details-panel.component.ts | 105 ++ ...rison-tool-filter-list-item.component.html | 25 + ...rison-tool-filter-list-item.component.scss | 39 + ...ool-filter-list-item.component.spec.ts.off | 50 + ...parison-tool-filter-list-item.component.ts | 21 + ...comparison-tool-filter-list.component.html | 34 + ...comparison-tool-filter-list.component.scss | 37 + ...son-tool-filter-list.component.spec.ts.off | 71 + ...e-comparison-tool-filter-list.component.ts | 69 + ...omparison-tool-filter-panel.component.html | 114 ++ ...omparison-tool-filter-panel.component.scss | 317 ++++ ...on-tool-filter-panel.component.spec.ts.off | 150 ++ ...-comparison-tool-filter-panel.component.ts | 53 + ...omparison-tool-how-to-panel.component.html | 50 + ...omparison-tool-how-to-panel.component.scss | 104 ++ ...on-tool-how-to-panel.component.spec.ts.off | 26 + ...-comparison-tool-how-to-panel.component.ts | 147 ++ ...omparison-tool-legend-panel.component.html | 15 + ...omparison-tool-legend-panel.component.scss | 46 + ...on-tool-legend-panel.component.spec.ts.off | 25 + ...-comparison-tool-legend-panel.component.ts | 24 + ...son-tool-pinned-genes-modal.component.html | 21 + ...son-tool-pinned-genes-modal.component.scss | 68 + ...l-pinned-genes-modal.component.spec.ts.off | 25 + ...rison-tool-pinned-genes-modal.component.ts | 40 + ...comparison-tool-score-panel.component.html | 39 + ...comparison-tool-score-panel.component.scss | 44 + ...son-tool-score-panel.component.spec.ts.off | 41 + ...e-comparison-tool-score-panel.component.ts | 77 + .../lib/gene-comparison-tool.component.html | 658 ++++++++ .../lib/gene-comparison-tool.component.scss | 826 +++++++++++ ...gene-comparison-tool.component.spec.ts.off | 21 + .../src/lib/gene-comparison-tool.component.ts | 1318 +++++++++++++++++ .../src/lib/gene-comparison-tool.helpers.ts | 155 ++ .../src/lib/gene-comparison-tool.routes.ts | 4 + .../src/lib/gene-comparison-tool.variables.ts | 279 ++++ .../gene-comparison-tool/src/test-setup.ts | 1 + libs/agora/gene-comparison-tool/tsconfig.json | 28 + .../tsconfig.lib.json | 0 .../gene-comparison-tool/tsconfig.spec.json | 11 + libs/agora/genes/src/index.ts | 22 + .../download-dom-image.component.html | 34 + .../download-dom-image.component.scss | 45 + .../download-dom-image.component.spec.ts.off | 58 + .../download-dom-image.component.ts | 85 ++ .../gene-biodomains.component.html | 35 + .../gene-biodomains.component.scss | 103 ++ .../gene-biodomains.component.spec.ts.off | 57 + .../gene-biodomains.component.ts | 111 ++ .../gene-details/gene-details.component.html | 119 ++ .../gene-details/gene-details.component.scss | 208 +++ .../gene-details.component.spec.ts.off | 81 + .../gene-details/gene-details.component.ts | 300 ++++ .../gene-details/gene-details.routes.ts | 4 + .../gene-druggability.component.html | 244 +++ .../gene-druggability.component.scss | 20 + .../gene-druggability.component.spec.ts.off | 39 + .../gene-druggability.component.ts | 397 +++++ .../gene-evidence-metabolomics.component.html | 90 ++ .../gene-evidence-metabolomics.component.scss | 0 ...vidence-metabolomics.component.spec.ts.off | 38 + .../gene-evidence-metabolomics.component.ts | 61 + .../gene-evidence-proteomics.component.html | 177 +++ .../gene-evidence-proteomics.component.scss | 0 ...-evidence-proteomics.component.spec.ts.off | 59 + .../gene-evidence-proteomics.component.ts | 198 +++ .../gene-evidence-rna.component.html | 205 +++ .../gene-evidence-rna.component.scss | 0 .../gene-evidence-rna.component.spec.ts.off | 38 + .../gene-evidence-rna.component.ts | 232 +++ ...ene-experimental-validation.component.html | 66 + ...ene-experimental-validation.component.scss | 11 + ...erimental-validation.component.spec.ts.off | 39 + .../gene-experimental-validation.component.ts | 49 + .../gene-hero/gene-hero.component.html | 65 + .../gene-hero/gene-hero.component.scss | 60 + .../gene-hero/gene-hero.component.spec.ts.off | 127 ++ .../gene-hero/gene-hero.component.ts | 119 ++ .../gene-model-selector.component.html | 9 + .../gene-model-selector.component.scss | 0 .../gene-model-selector.component.spec.ts.off | 35 + .../gene-model-selector.component.ts | 58 + .../gene-network/gene-network.component.html | 131 ++ .../gene-network/gene-network.component.scss | 248 ++++ .../gene-network.component.spec.ts.off | 37 + .../gene-network/gene-network.component.ts | 141 ++ .../gene-nominations.component.html | 92 ++ .../gene-nominations.component.scss | 26 + .../gene-nominations.component.spec.ts.off | 91 ++ .../gene-nominations.component.ts | 78 + .../gene-protein-selector.component.html | 9 + .../gene-protein-selector.component.scss | 0 ...ene-protein-selector.component.spec.ts.off | 35 + .../gene-protein-selector.component.ts | 44 + .../gene-resources.component.html | 101 ++ .../gene-resources.component.scss | 73 + .../gene-resources.component.spec.ts.off | 165 +++ .../gene-resources.component.ts | 101 ++ .../gene-search/gene-search.component.html | 1 - ...c.ts => gene-search.component.spec.ts.off} | 0 .../gene-search/gene-search.component.ts | 10 +- .../gene-soe-charts.component.html | 22 + .../gene-soe-charts.component.scss | 51 + .../gene-soe-charts.component.spec.ts.off | 82 + .../gene-soe-charts.component.ts | 105 ++ .../gene-soe-list.component.html | 27 + .../gene-soe-list.component.scss | 63 + .../gene-soe-list.component.spec.ts.off | 35 + .../gene-soe-list/gene-soe-list.component.ts | 125 ++ .../gene-soe/gene-soe.component.html | 96 ++ .../gene-soe/gene-soe.component.scss | 12 + .../gene-soe/gene-soe.component.spec.ts.off | 35 + .../components/gene-soe/gene-soe.component.ts | 16 + ...ec.ts => gene-table.component.spec.ts.off} | 0 .../overlay-panel-link.component.html | 25 + .../overlay-panel-link.component.scss | 19 + .../overlay-panel-link.component.spec.ts.off | 54 + .../overlay-panel-link.component.ts | 30 + .../genes/src/lib/helpers/GeneHelpers.ts | 13 + libs/agora/genes/src/lib/helpers/index.ts | 1 + .../ExperimentalValidationWithTeamData.ts | 5 + .../models/TargetNominationWithTeamData.ts | 5 + libs/agora/genes/src/lib/models/index.ts | 2 + ...ent.spec.ts => home.component.spec.ts.off} | 3 +- libs/agora/news/src/lib/news.component.ts | 5 +- ...> nominated-targets.component.spec.ts.off} | 0 .../nominated-targets.component.ts | 2 +- .../nomination-form.component.ts | 175 +-- .../src/lib/not-found.component.html | 23 +- .../src/lib/not-found.component.scss | 24 + ...pec.ts => not-found.component.spec.ts.off} | 0 .../not-found/src/lib/not-found.component.ts | 23 +- libs/agora/services/src/index.ts | 1 + .../services/src/lib/github.service.spec.ts | 30 + libs/agora/services/src/lib/github.service.ts | 28 + libs/agora/shared/.eslintrc.json | 40 + libs/agora/shared/README.md | 7 + libs/agora/shared/jest.config.ts | 23 + libs/agora/shared/project.json | 27 + libs/agora/shared/src/index.ts | 5 + .../agora/shared/src/lib/helpers/functions.ts | 9 + .../loading-icon/loading-icon.component.html | 0 .../loading-icon/loading-icon.component.scss | 0 .../loading-icon.component.spec.ts | 0 .../loading-icon/loading-icon.component.ts | 3 +- .../lib}/modal-link/modal-link.component.html | 0 .../lib}/modal-link/modal-link.component.scss | 0 .../modal-link.component.spec.ts.off} | 7 +- .../lib}/modal-link/modal-link.component.ts | 2 +- .../src/lib}/svg-icon/svg-icon.component.html | 0 .../src/lib}/svg-icon/svg-icon.component.scss | 0 .../lib}/svg-icon/svg-icon.component.spec.ts | 0 .../src/lib}/svg-icon/svg-icon.component.ts | 0 .../src/lib/wiki}/wiki.component.html | 0 .../src/lib/wiki}/wiki.component.scss | 5 +- .../src/lib/wiki/wiki.component.spec.ts.off} | 2 +- .../src/lib/wiki}/wiki.component.ts | 3 +- libs/agora/shared/src/test-setup.ts | 1 + libs/agora/shared/tsconfig.json | 28 + libs/agora/shared/tsconfig.lib.json | 12 + .../agora/{wiki => shared}/tsconfig.spec.json | 1 - libs/agora/styles/src/lib/_variables.scss | 11 +- libs/agora/teams/src/lib/teams.routes.ts | 2 +- libs/agora/ui/src/index.ts | 4 +- .../components/footer/footer.component.html | 2 +- .../lib/components/footer/footer.component.ts | 5 + ...t.spec.ts => header.component.spec.ts.off} | 0 .../loading-overlay.component.html | 5 + .../loading-overlay.component.scss | 33 + .../loading-overlay.component.spec.ts | 36 + .../loading-overlay.component.ts | 27 + libs/agora/util/src/index.ts | 1 + libs/agora/wiki/src/index.ts | 1 - libs/agora/wiki/src/lib/wiki.routes.ts | 4 - libs/agora/wiki/src/test-setup.ts | 20 - .../lib/boxplot/boxplot.directive.stories.ts | 6 +- package.json | 12 +- pnpm-lock.yaml | 748 +++++++++- tsconfig.base.json | 7 + 258 files changed, 18045 insertions(+), 375 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 apps/agora/app/src/types/dom-to-image-more.d.ts rename libs/agora/about/src/lib/{about.component.spec.ts => about.component.spec.ts.off} (100%) create mode 100644 libs/agora/assets/icons/gct.svg rename libs/agora/{wiki => charts}/.eslintrc.json (100%) rename libs/agora/{wiki => charts}/README.md (53%) rename libs/agora/{wiki => charts}/jest.config.ts (78%) rename libs/agora/{wiki => charts}/project.json (55%) create mode 100644 libs/agora/charts/src/index.ts create mode 100644 libs/agora/charts/src/lib/base-chart/base-chart.component.html create mode 100644 libs/agora/charts/src/lib/base-chart/base-chart.component.scss create mode 100644 libs/agora/charts/src/lib/base-chart/base-chart.component.spec.ts.off create mode 100644 libs/agora/charts/src/lib/base-chart/base-chart.component.ts create mode 100644 libs/agora/charts/src/lib/biodomains-chart/biodomains-chart.component.html create mode 100644 libs/agora/charts/src/lib/biodomains-chart/biodomains-chart.component.scss create mode 100644 libs/agora/charts/src/lib/biodomains-chart/biodomains-chart.component.spec.ts.off create mode 100644 libs/agora/charts/src/lib/biodomains-chart/biodomains-chart.component.ts create mode 100644 libs/agora/charts/src/lib/box-plot-chart/box-plot-chart.component.html create mode 100644 libs/agora/charts/src/lib/box-plot-chart/box-plot-chart.component.scss create mode 100644 libs/agora/charts/src/lib/box-plot-chart/box-plot-chart.component.spec.ts.off create mode 100644 libs/agora/charts/src/lib/box-plot-chart/box-plot-chart.component.ts create mode 100644 libs/agora/charts/src/lib/box-plot-chart/box-plot.ts create mode 100644 libs/agora/charts/src/lib/candlestick-chart/candlestick-chart.component.html create mode 100644 libs/agora/charts/src/lib/candlestick-chart/candlestick-chart.component.scss create mode 100644 libs/agora/charts/src/lib/candlestick-chart/candlestick-chart.component.spec.ts.off create mode 100644 libs/agora/charts/src/lib/candlestick-chart/candlestick-chart.component.ts create mode 100644 libs/agora/charts/src/lib/median-barchart/median-barchart.component.html create mode 100644 libs/agora/charts/src/lib/median-barchart/median-barchart.component.scss create mode 100644 libs/agora/charts/src/lib/median-barchart/median-barchart.component.spec.ts.off create mode 100644 libs/agora/charts/src/lib/median-barchart/median-barchart.component.ts create mode 100644 libs/agora/charts/src/lib/median-chart/median-chart.component.html create mode 100644 libs/agora/charts/src/lib/median-chart/median-chart.component.scss create mode 100644 libs/agora/charts/src/lib/median-chart/median-chart.component.spec.ts.off create mode 100644 libs/agora/charts/src/lib/median-chart/median-chart.component.ts create mode 100644 libs/agora/charts/src/lib/network-chart/network-chart.component.html create mode 100644 libs/agora/charts/src/lib/network-chart/network-chart.component.scss create mode 100644 libs/agora/charts/src/lib/network-chart/network-chart.component.spec.ts.off create mode 100644 libs/agora/charts/src/lib/network-chart/network-chart.component.ts create mode 100644 libs/agora/charts/src/lib/network-chart/symbol-hexagon.ts create mode 100644 libs/agora/charts/src/lib/row-chart/row-chart.component.html create mode 100644 libs/agora/charts/src/lib/row-chart/row-chart.component.scss create mode 100644 libs/agora/charts/src/lib/row-chart/row-chart.component.spec.ts.off create mode 100644 libs/agora/charts/src/lib/row-chart/row-chart.component.ts create mode 100644 libs/agora/charts/src/lib/score-barchart/score-barchart.component.html create mode 100644 libs/agora/charts/src/lib/score-barchart/score-barchart.component.scss create mode 100644 libs/agora/charts/src/lib/score-barchart/score-barchart.component.spec.ts.off create mode 100644 libs/agora/charts/src/lib/score-barchart/score-barchart.component.ts create mode 100644 libs/agora/charts/src/lib/score-chart/score-chart.component.html create mode 100644 libs/agora/charts/src/lib/score-chart/score-chart.component.scss create mode 100644 libs/agora/charts/src/lib/score-chart/score-chart.component.spec.ts.off create mode 100644 libs/agora/charts/src/lib/score-chart/score-chart.component.ts create mode 100644 libs/agora/charts/src/test-setup.ts rename libs/agora/{wiki => charts}/tsconfig.json (100%) create mode 100644 libs/agora/charts/tsconfig.lib.json create mode 100644 libs/agora/charts/tsconfig.spec.json create mode 100644 libs/agora/gene-comparison-tool/.eslintrc.json create mode 100644 libs/agora/gene-comparison-tool/README.md create mode 100644 libs/agora/gene-comparison-tool/jest.config.ts create mode 100644 libs/agora/gene-comparison-tool/project.json create mode 100644 libs/agora/gene-comparison-tool/src/index.ts create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.html create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.scss create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.spec.ts.off create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.ts create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list-item/gene-comparison-tool-filter-list-item.component.html create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list-item/gene-comparison-tool-filter-list-item.component.scss create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list-item/gene-comparison-tool-filter-list-item.component.spec.ts.off create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list-item/gene-comparison-tool-filter-list-item.component.ts create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list/gene-comparison-tool-filter-list.component.html create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list/gene-comparison-tool-filter-list.component.scss create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list/gene-comparison-tool-filter-list.component.spec.ts.off create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list/gene-comparison-tool-filter-list.component.ts create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-panel/gene-comparison-tool-filter-panel.component.html create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-panel/gene-comparison-tool-filter-panel.component.scss create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-panel/gene-comparison-tool-filter-panel.component.spec.ts.off create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-panel/gene-comparison-tool-filter-panel.component.ts create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-how-to-panel/gene-comparison-tool-how-to-panel.component.html create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-how-to-panel/gene-comparison-tool-how-to-panel.component.scss create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-how-to-panel/gene-comparison-tool-how-to-panel.component.spec.ts.off create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-how-to-panel/gene-comparison-tool-how-to-panel.component.ts create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-legend-panel/gene-comparison-tool-legend-panel.component.html create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-legend-panel/gene-comparison-tool-legend-panel.component.scss create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-legend-panel/gene-comparison-tool-legend-panel.component.spec.ts.off create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-legend-panel/gene-comparison-tool-legend-panel.component.ts create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-pinned-genes-modal/gene-comparison-tool-pinned-genes-modal.component.html create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-pinned-genes-modal/gene-comparison-tool-pinned-genes-modal.component.scss create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-pinned-genes-modal/gene-comparison-tool-pinned-genes-modal.component.spec.ts.off create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-pinned-genes-modal/gene-comparison-tool-pinned-genes-modal.component.ts create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-score-panel/gene-comparison-tool-score-panel.component.html create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-score-panel/gene-comparison-tool-score-panel.component.scss create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-score-panel/gene-comparison-tool-score-panel.component.spec.ts.off create mode 100644 libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-score-panel/gene-comparison-tool-score-panel.component.ts create mode 100644 libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.component.html create mode 100644 libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.component.scss create mode 100644 libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.component.spec.ts.off create mode 100644 libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.component.ts create mode 100644 libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.helpers.ts create mode 100644 libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.routes.ts create mode 100644 libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.variables.ts create mode 100644 libs/agora/gene-comparison-tool/src/test-setup.ts create mode 100644 libs/agora/gene-comparison-tool/tsconfig.json rename libs/agora/{wiki => gene-comparison-tool}/tsconfig.lib.json (100%) create mode 100644 libs/agora/gene-comparison-tool/tsconfig.spec.json create mode 100644 libs/agora/genes/src/lib/components/download-dom-image/download-dom-image.component.html create mode 100644 libs/agora/genes/src/lib/components/download-dom-image/download-dom-image.component.scss create mode 100644 libs/agora/genes/src/lib/components/download-dom-image/download-dom-image.component.spec.ts.off create mode 100644 libs/agora/genes/src/lib/components/download-dom-image/download-dom-image.component.ts create mode 100644 libs/agora/genes/src/lib/components/gene-biodomains/gene-biodomains.component.html create mode 100644 libs/agora/genes/src/lib/components/gene-biodomains/gene-biodomains.component.scss create mode 100644 libs/agora/genes/src/lib/components/gene-biodomains/gene-biodomains.component.spec.ts.off create mode 100644 libs/agora/genes/src/lib/components/gene-biodomains/gene-biodomains.component.ts create mode 100644 libs/agora/genes/src/lib/components/gene-details/gene-details.component.html create mode 100644 libs/agora/genes/src/lib/components/gene-details/gene-details.component.scss create mode 100644 libs/agora/genes/src/lib/components/gene-details/gene-details.component.spec.ts.off create mode 100644 libs/agora/genes/src/lib/components/gene-details/gene-details.component.ts create mode 100644 libs/agora/genes/src/lib/components/gene-details/gene-details.routes.ts create mode 100644 libs/agora/genes/src/lib/components/gene-druggability/gene-druggability.component.html create mode 100644 libs/agora/genes/src/lib/components/gene-druggability/gene-druggability.component.scss create mode 100644 libs/agora/genes/src/lib/components/gene-druggability/gene-druggability.component.spec.ts.off create mode 100644 libs/agora/genes/src/lib/components/gene-druggability/gene-druggability.component.ts create mode 100644 libs/agora/genes/src/lib/components/gene-evidence-metabolomics/gene-evidence-metabolomics.component.html create mode 100644 libs/agora/genes/src/lib/components/gene-evidence-metabolomics/gene-evidence-metabolomics.component.scss create mode 100644 libs/agora/genes/src/lib/components/gene-evidence-metabolomics/gene-evidence-metabolomics.component.spec.ts.off create mode 100644 libs/agora/genes/src/lib/components/gene-evidence-metabolomics/gene-evidence-metabolomics.component.ts create mode 100644 libs/agora/genes/src/lib/components/gene-evidence-proteomics/gene-evidence-proteomics.component.html create mode 100644 libs/agora/genes/src/lib/components/gene-evidence-proteomics/gene-evidence-proteomics.component.scss create mode 100644 libs/agora/genes/src/lib/components/gene-evidence-proteomics/gene-evidence-proteomics.component.spec.ts.off create mode 100644 libs/agora/genes/src/lib/components/gene-evidence-proteomics/gene-evidence-proteomics.component.ts create mode 100644 libs/agora/genes/src/lib/components/gene-evidence-rna/gene-evidence-rna.component.html create mode 100644 libs/agora/genes/src/lib/components/gene-evidence-rna/gene-evidence-rna.component.scss create mode 100644 libs/agora/genes/src/lib/components/gene-evidence-rna/gene-evidence-rna.component.spec.ts.off create mode 100644 libs/agora/genes/src/lib/components/gene-evidence-rna/gene-evidence-rna.component.ts create mode 100644 libs/agora/genes/src/lib/components/gene-experimental-validation/gene-experimental-validation.component.html create mode 100644 libs/agora/genes/src/lib/components/gene-experimental-validation/gene-experimental-validation.component.scss create mode 100644 libs/agora/genes/src/lib/components/gene-experimental-validation/gene-experimental-validation.component.spec.ts.off create mode 100644 libs/agora/genes/src/lib/components/gene-experimental-validation/gene-experimental-validation.component.ts create mode 100644 libs/agora/genes/src/lib/components/gene-hero/gene-hero.component.html create mode 100644 libs/agora/genes/src/lib/components/gene-hero/gene-hero.component.scss create mode 100644 libs/agora/genes/src/lib/components/gene-hero/gene-hero.component.spec.ts.off create mode 100644 libs/agora/genes/src/lib/components/gene-hero/gene-hero.component.ts create mode 100644 libs/agora/genes/src/lib/components/gene-model-selector/gene-model-selector.component.html create mode 100644 libs/agora/genes/src/lib/components/gene-model-selector/gene-model-selector.component.scss create mode 100644 libs/agora/genes/src/lib/components/gene-model-selector/gene-model-selector.component.spec.ts.off create mode 100644 libs/agora/genes/src/lib/components/gene-model-selector/gene-model-selector.component.ts create mode 100644 libs/agora/genes/src/lib/components/gene-network/gene-network.component.html create mode 100644 libs/agora/genes/src/lib/components/gene-network/gene-network.component.scss create mode 100644 libs/agora/genes/src/lib/components/gene-network/gene-network.component.spec.ts.off create mode 100644 libs/agora/genes/src/lib/components/gene-network/gene-network.component.ts create mode 100644 libs/agora/genes/src/lib/components/gene-nominations/gene-nominations.component.html create mode 100644 libs/agora/genes/src/lib/components/gene-nominations/gene-nominations.component.scss create mode 100644 libs/agora/genes/src/lib/components/gene-nominations/gene-nominations.component.spec.ts.off create mode 100644 libs/agora/genes/src/lib/components/gene-nominations/gene-nominations.component.ts create mode 100644 libs/agora/genes/src/lib/components/gene-protein-selector/gene-protein-selector.component.html create mode 100644 libs/agora/genes/src/lib/components/gene-protein-selector/gene-protein-selector.component.scss create mode 100644 libs/agora/genes/src/lib/components/gene-protein-selector/gene-protein-selector.component.spec.ts.off create mode 100644 libs/agora/genes/src/lib/components/gene-protein-selector/gene-protein-selector.component.ts create mode 100644 libs/agora/genes/src/lib/components/gene-resources/gene-resources.component.html create mode 100644 libs/agora/genes/src/lib/components/gene-resources/gene-resources.component.scss create mode 100644 libs/agora/genes/src/lib/components/gene-resources/gene-resources.component.spec.ts.off create mode 100644 libs/agora/genes/src/lib/components/gene-resources/gene-resources.component.ts rename libs/agora/genes/src/lib/components/gene-search/{gene-search.component.spec.ts => gene-search.component.spec.ts.off} (100%) create mode 100644 libs/agora/genes/src/lib/components/gene-soe-charts/gene-soe-charts.component.html create mode 100644 libs/agora/genes/src/lib/components/gene-soe-charts/gene-soe-charts.component.scss create mode 100644 libs/agora/genes/src/lib/components/gene-soe-charts/gene-soe-charts.component.spec.ts.off create mode 100644 libs/agora/genes/src/lib/components/gene-soe-charts/gene-soe-charts.component.ts create mode 100644 libs/agora/genes/src/lib/components/gene-soe-list/gene-soe-list.component.html create mode 100644 libs/agora/genes/src/lib/components/gene-soe-list/gene-soe-list.component.scss create mode 100644 libs/agora/genes/src/lib/components/gene-soe-list/gene-soe-list.component.spec.ts.off create mode 100644 libs/agora/genes/src/lib/components/gene-soe-list/gene-soe-list.component.ts create mode 100644 libs/agora/genes/src/lib/components/gene-soe/gene-soe.component.html create mode 100644 libs/agora/genes/src/lib/components/gene-soe/gene-soe.component.scss create mode 100644 libs/agora/genes/src/lib/components/gene-soe/gene-soe.component.spec.ts.off create mode 100644 libs/agora/genes/src/lib/components/gene-soe/gene-soe.component.ts rename libs/agora/genes/src/lib/components/gene-table/{gene-table.component.spec.ts => gene-table.component.spec.ts.off} (100%) create mode 100644 libs/agora/genes/src/lib/components/overlay-panel-link/overlay-panel-link.component.html create mode 100644 libs/agora/genes/src/lib/components/overlay-panel-link/overlay-panel-link.component.scss create mode 100644 libs/agora/genes/src/lib/components/overlay-panel-link/overlay-panel-link.component.spec.ts.off create mode 100644 libs/agora/genes/src/lib/components/overlay-panel-link/overlay-panel-link.component.ts create mode 100644 libs/agora/genes/src/lib/helpers/GeneHelpers.ts create mode 100644 libs/agora/genes/src/lib/helpers/index.ts create mode 100644 libs/agora/genes/src/lib/models/ExperimentalValidationWithTeamData.ts create mode 100644 libs/agora/genes/src/lib/models/TargetNominationWithTeamData.ts create mode 100644 libs/agora/genes/src/lib/models/index.ts rename libs/agora/home/src/lib/{home.component.spec.ts => home.component.spec.ts.off} (89%) rename libs/agora/nominated-targets/src/lib/nominated-targets/{nominated-targets.component.spec.ts => nominated-targets.component.spec.ts.off} (100%) rename libs/agora/not-found/src/lib/{not-found.component.spec.ts => not-found.component.spec.ts.off} (100%) create mode 100644 libs/agora/services/src/lib/github.service.spec.ts create mode 100644 libs/agora/services/src/lib/github.service.ts create mode 100644 libs/agora/shared/.eslintrc.json create mode 100644 libs/agora/shared/README.md create mode 100644 libs/agora/shared/jest.config.ts create mode 100644 libs/agora/shared/project.json create mode 100644 libs/agora/shared/src/index.ts create mode 100644 libs/agora/shared/src/lib/helpers/functions.ts rename libs/agora/{ui/src/lib/components => shared/src/lib}/loading-icon/loading-icon.component.html (100%) rename libs/agora/{ui/src/lib/components => shared/src/lib}/loading-icon/loading-icon.component.scss (100%) rename libs/agora/{ui/src/lib/components => shared/src/lib}/loading-icon/loading-icon.component.spec.ts (100%) rename libs/agora/{ui/src/lib/components => shared/src/lib}/loading-icon/loading-icon.component.ts (76%) rename libs/agora/{ui/src/lib/components => shared/src/lib}/modal-link/modal-link.component.html (100%) rename libs/agora/{ui/src/lib/components => shared/src/lib}/modal-link/modal-link.component.scss (100%) rename libs/agora/{ui/src/lib/components/modal-link/modal-link.component.spec.ts => shared/src/lib/modal-link/modal-link.component.spec.ts.off} (86%) rename libs/agora/{ui/src/lib/components => shared/src/lib}/modal-link/modal-link.component.ts (90%) rename libs/agora/{ui/src/lib/components => shared/src/lib}/svg-icon/svg-icon.component.html (100%) rename libs/agora/{ui/src/lib/components => shared/src/lib}/svg-icon/svg-icon.component.scss (100%) rename libs/agora/{ui/src/lib/components => shared/src/lib}/svg-icon/svg-icon.component.spec.ts (100%) rename libs/agora/{ui/src/lib/components => shared/src/lib}/svg-icon/svg-icon.component.ts (100%) rename libs/agora/{wiki/src/lib => shared/src/lib/wiki}/wiki.component.html (100%) rename libs/agora/{wiki/src/lib => shared/src/lib/wiki}/wiki.component.scss (94%) rename libs/agora/{wiki/src/lib/wiki.component.spec.ts => shared/src/lib/wiki/wiki.component.spec.ts.off} (97%) rename libs/agora/{wiki/src/lib => shared/src/lib/wiki}/wiki.component.ts (95%) create mode 100644 libs/agora/shared/src/test-setup.ts create mode 100644 libs/agora/shared/tsconfig.json create mode 100644 libs/agora/shared/tsconfig.lib.json rename libs/agora/{wiki => shared}/tsconfig.spec.json (91%) rename libs/agora/ui/src/lib/components/header/{header.component.spec.ts => header.component.spec.ts.off} (100%) create mode 100644 libs/agora/ui/src/lib/components/loading-overlay/loading-overlay.component.html create mode 100644 libs/agora/ui/src/lib/components/loading-overlay/loading-overlay.component.scss create mode 100644 libs/agora/ui/src/lib/components/loading-overlay/loading-overlay.component.spec.ts create mode 100644 libs/agora/ui/src/lib/components/loading-overlay/loading-overlay.component.ts delete mode 100644 libs/agora/wiki/src/index.ts delete mode 100644 libs/agora/wiki/src/lib/wiki.routes.ts delete mode 100644 libs/agora/wiki/src/test-setup.ts diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..d6b0f0ab10 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,22 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to Chrome", + "port": 4200, + "request": "attach", + "type": "chrome", + "webRoot": "${workspaceFolder}" + }, + { + "type": "chrome", + "request": "launch", + "name": "Launch Chrome against localhost", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}" + } + ] +} diff --git a/apps/agora/api/src/components/distribution.ts b/apps/agora/api/src/components/distribution.ts index 0cf08a5084..2b5428ce19 100644 --- a/apps/agora/api/src/components/distribution.ts +++ b/apps/agora/api/src/components/distribution.ts @@ -15,7 +15,6 @@ import { import { RnaDistribution, Distribution, - OverallScores, OverallScoresDistribution, ProteomicsDistribution, } from '@sagebionetworks/agora/api-client-angular'; diff --git a/apps/agora/api/src/models/overall-scores.ts b/apps/agora/api/src/models/overall-scores.ts index 5707a0889b..4993052b2f 100644 --- a/apps/agora/api/src/models/overall-scores.ts +++ b/apps/agora/api/src/models/overall-scores.ts @@ -6,10 +6,7 @@ import { Schema, model } from 'mongoose'; // -------------------------------------------------------------------------- // // Internal // -------------------------------------------------------------------------- // -import { - OverallScores, - OverallScoresDistribution, -} from '@sagebionetworks/agora/api-client-angular'; +import { OverallScores } from '@sagebionetworks/agora/api-client-angular'; // -------------------------------------------------------------------------- // // Schemas diff --git a/apps/agora/app/project.json b/apps/agora/app/project.json index 330911fb9f..eb02af7d4a 100644 --- a/apps/agora/app/project.json +++ b/apps/agora/app/project.json @@ -57,12 +57,12 @@ { "type": "initial", "maximumWarning": "1mb", - "maximumError": "2mb" + "maximumError": "3mb" }, { "type": "anyComponentStyle", "maximumWarning": "2kb", - "maximumError": "10kb" + "maximumError": "20kb" } ], "outputHashing": "all" diff --git a/apps/agora/app/src/app/app.component.html b/apps/agora/app/src/app/app.component.html index d152ac90f4..ede758841c 100644 --- a/apps/agora/app/src/app/app.component.html +++ b/apps/agora/app/src/app/app.component.html @@ -1,3 +1,9 @@ - - - +
+ +
+ +
+ +
+ + diff --git a/apps/agora/app/src/app/app.component.scss b/apps/agora/app/src/app/app.component.scss index e69de29bb2..1404c3b72a 100644 --- a/apps/agora/app/src/app/app.component.scss +++ b/apps/agora/app/src/app/app.component.scss @@ -0,0 +1,9 @@ +#container { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +#main-content { + flex: 1; +} diff --git a/apps/agora/app/src/app/app.component.ts b/apps/agora/app/src/app/app.component.ts index 1ed2c549b7..e0d484a7fb 100644 --- a/apps/agora/app/src/app/app.component.ts +++ b/apps/agora/app/src/app/app.component.ts @@ -1,12 +1,19 @@ import { Component, inject, OnInit } from '@angular/core'; import { Meta, Title } from '@angular/platform-browser'; import { ActivatedRoute, NavigationEnd, Router, RouterModule } from '@angular/router'; -import { FooterComponent, HeaderComponent } from '@sagebionetworks/agora/ui'; +import { + FooterComponent, + HeaderComponent, + LoadingOverlayComponent, +} from '@sagebionetworks/agora/ui'; import { filter } from 'rxjs'; +import { ToastModule } from 'primeng/toast'; +import { MessageService } from 'primeng/api'; @Component({ standalone: true, - imports: [RouterModule, HeaderComponent, FooterComponent], + imports: [RouterModule, HeaderComponent, FooterComponent, LoadingOverlayComponent, ToastModule], + providers: [MessageService], selector: 'app-root', templateUrl: './app.component.html', styleUrl: './app.component.scss', diff --git a/apps/agora/app/src/app/app.routes.ts b/apps/agora/app/src/app/app.routes.ts index 6944ced937..5edb4d2f16 100644 --- a/apps/agora/app/src/app/app.routes.ts +++ b/apps/agora/app/src/app/app.routes.ts @@ -27,6 +27,16 @@ export const routes: Route[] = [ description: "See what's new in Agora, from new features to our latest data updates.", }, }, + { + path: 'genes/comparison', + loadChildren: () => + import('@sagebionetworks/agora/gene-comparison-tool').then((routes) => routes.routes), + data: { + title: 'Gene Comparison | Visual comparison tool for AD genes', + description: + 'Explore high-dimensional omics data with our visual gene comparison tool, then build, share, and download visualizations for your own custom gene lists.', + }, + }, { path: 'genes/nominated-targets', loadChildren: () => @@ -50,6 +60,25 @@ export const routes: Route[] = [ description: 'Nominate a gene as a new candidate for AD treatment or prevention.', }, }, + { + path: 'genes/:id/:tab/:subtab', + loadChildren: () => + import('@sagebionetworks/agora/gene-details').then((routes) => routes.routes), + }, + { + path: 'genes/:id/:tab', + loadChildren: () => + import('@sagebionetworks/agora/gene-details').then((routes) => routes.routes), + }, + { + path: 'genes/:id', + loadChildren: () => + import('@sagebionetworks/agora/gene-details').then((routes) => routes.routes), + data: { + title: 'Agora | Gene Details', + description: "View information and evidence about genes in Alzheimer's disease.", + }, + }, { path: 'not-found', loadChildren: () => import('@sagebionetworks/agora/not-found').then((routes) => routes.routes), @@ -60,7 +89,7 @@ export const routes: Route[] = [ }, { path: 'teams', - loadChildren: () => import('@sagebionetworks/agora/teams').then((routes) => routes.teamsRoutes), + loadChildren: () => import('@sagebionetworks/agora/teams').then((routes) => routes.routes), data: { title: 'Contributing Teams', description: diff --git a/apps/agora/app/src/types/dom-to-image-more.d.ts b/apps/agora/app/src/types/dom-to-image-more.d.ts new file mode 100644 index 0000000000..891808deeb --- /dev/null +++ b/apps/agora/app/src/types/dom-to-image-more.d.ts @@ -0,0 +1,4 @@ +declare module 'dom-to-image-more' { + import domToImage = require('dom-to-image-more'); + export = domToImage; +} diff --git a/libs/agora/about/src/lib/about.component.html b/libs/agora/about/src/lib/about.component.html index 3ad6a7f7bd..e984ba0699 100644 --- a/libs/agora/about/src/lib/about.component.html +++ b/libs/agora/about/src/lib/about.component.html @@ -13,3 +13,11 @@

About

+ + + diff --git a/libs/agora/about/src/lib/about.component.spec.ts b/libs/agora/about/src/lib/about.component.spec.ts.off similarity index 100% rename from libs/agora/about/src/lib/about.component.spec.ts rename to libs/agora/about/src/lib/about.component.spec.ts.off diff --git a/libs/agora/about/src/lib/about.component.ts b/libs/agora/about/src/lib/about.component.ts index 5d4a9ea2ca..18522b4332 100644 --- a/libs/agora/about/src/lib/about.component.ts +++ b/libs/agora/about/src/lib/about.component.ts @@ -1,15 +1,40 @@ import { CommonModule } from '@angular/common'; import { Component } from '@angular/core'; -import { WikiComponent } from 'libs/agora/wiki/src/lib/wiki.component'; +import { ScoreBarChartComponent } from '@sagebionetworks/agora/charts'; +import { WikiComponent } from '@sagebionetworks/agora/shared'; @Component({ selector: 'agora-about', standalone: true, - imports: [CommonModule, WikiComponent], + imports: [CommonModule, WikiComponent, ScoreBarChartComponent], templateUrl: './about.component.html', styleUrls: ['./about.component.scss'], }) export class AboutComponent { wikiId = '612058'; className = 'about-page-content'; + + scoreDistribution = { + distribution: [766, 4804, 4198, 4001, 3172, 2880, 3097, 1562, 323, 19], + bins: [ + [0, 0.5], + [0.5, 1], + [1, 1.5], + [1.5, 2], + [2, 2.5], + [2.5, 3], + [3, 3.5], + [3.5, 4], + [4, 4.5], + [4.5, 5], + ], + min: 0, + max: 4.7438, + mean: 1.9484, + first_quartile: 1, + third_quartile: 3, + name: 'Target Risk Score', + syn_id: 'syn25913473', + wiki_id: '621071', + }; } diff --git a/libs/agora/api-client-angular/src/lib/model/similarGenesNetwork.ts b/libs/agora/api-client-angular/src/lib/model/similarGenesNetwork.ts index 04af68cbff..806783c6f1 100644 --- a/libs/agora/api-client-angular/src/lib/model/similarGenesNetwork.ts +++ b/libs/agora/api-client-angular/src/lib/model/similarGenesNetwork.ts @@ -16,8 +16,8 @@ import { SimilarGenesNetworkNode } from './similarGenesNetworkNode'; * SimilarGenesNetwork */ export interface SimilarGenesNetwork { - nodes?: Array; - links?: Array; - min?: number; - max?: number; + nodes: Array; + links: Array; + min: number; + max: number; } diff --git a/libs/agora/api-client-angular/src/lib/model/similarGenesNetworkLink.ts b/libs/agora/api-client-angular/src/lib/model/similarGenesNetworkLink.ts index eab24f39b6..3bca7c5830 100644 --- a/libs/agora/api-client-angular/src/lib/model/similarGenesNetworkLink.ts +++ b/libs/agora/api-client-angular/src/lib/model/similarGenesNetworkLink.ts @@ -14,9 +14,9 @@ * SimilarGenesNetworkLink */ export interface SimilarGenesNetworkLink { - source?: string; - target?: string; - source_hgnc_symbol?: string; - target_hgnc_symbol?: string; - brain_regions?: Array; + source: string; + target: string; + source_hgnc_symbol: string; + target_hgnc_symbol: string; + brain_regions: Array; } diff --git a/libs/agora/api-client-angular/src/lib/model/similarGenesNetworkNode.ts b/libs/agora/api-client-angular/src/lib/model/similarGenesNetworkNode.ts index 3cf348f16f..0e54f29532 100644 --- a/libs/agora/api-client-angular/src/lib/model/similarGenesNetworkNode.ts +++ b/libs/agora/api-client-angular/src/lib/model/similarGenesNetworkNode.ts @@ -14,7 +14,7 @@ * SimilarGenesNetworkNode */ export interface SimilarGenesNetworkNode { - ensembl_gene_id?: string; - hgnc_symbol?: string; - brain_regions?: Array; + ensembl_gene_id: string; + hgnc_symbol: string; + brain_regions: Array; } diff --git a/libs/agora/api-description/build/openapi.yaml b/libs/agora/api-description/build/openapi.yaml index 700ef0f08d..600450d266 100644 --- a/libs/agora/api-description/build/openapi.yaml +++ b/libs/agora/api-description/build/openapi.yaml @@ -634,6 +634,10 @@ components: type: array items: type: string + required: + - ensembl_gene_id + - hgnc_symbol + - brain_regions SimilarGenesNetworkLink: type: object description: SimilarGenesNetworkLink @@ -650,6 +654,12 @@ components: type: array items: type: string + required: + - source + - target + - source_hgnc_symbol + - target_hgnc_symbol + - brain_regions SimilarGenesNetwork: type: object description: SimilarGenesNetwork @@ -666,6 +676,11 @@ components: type: number max: type: number + required: + - nodes + - links + - min + - max BioDomains: type: object description: BioDomains diff --git a/libs/agora/api-description/src/components/schemas/SimilarGenesNetwork.yaml b/libs/agora/api-description/src/components/schemas/SimilarGenesNetwork.yaml index 4a84e2a691..dc2daa6bab 100644 --- a/libs/agora/api-description/src/components/schemas/SimilarGenesNetwork.yaml +++ b/libs/agora/api-description/src/components/schemas/SimilarGenesNetwork.yaml @@ -13,3 +13,8 @@ properties: type: number max: type: number +required: + - nodes + - links + - min + - max diff --git a/libs/agora/api-description/src/components/schemas/SimilarGenesNetworkLink.yaml b/libs/agora/api-description/src/components/schemas/SimilarGenesNetworkLink.yaml index b66f77f2cc..fe243143a8 100644 --- a/libs/agora/api-description/src/components/schemas/SimilarGenesNetworkLink.yaml +++ b/libs/agora/api-description/src/components/schemas/SimilarGenesNetworkLink.yaml @@ -13,3 +13,9 @@ properties: type: array items: type: string +required: + - source + - target + - source_hgnc_symbol + - target_hgnc_symbol + - brain_regions diff --git a/libs/agora/api-description/src/components/schemas/SimilarGenesNetworkNode.yaml b/libs/agora/api-description/src/components/schemas/SimilarGenesNetworkNode.yaml index 58a267c45e..bacddae205 100644 --- a/libs/agora/api-description/src/components/schemas/SimilarGenesNetworkNode.yaml +++ b/libs/agora/api-description/src/components/schemas/SimilarGenesNetworkNode.yaml @@ -9,3 +9,7 @@ properties: type: array items: type: string +required: + - ensembl_gene_id + - hgnc_symbol + - brain_regions diff --git a/libs/agora/assets/icons/gct.svg b/libs/agora/assets/icons/gct.svg new file mode 100644 index 0000000000..2c728646ad --- /dev/null +++ b/libs/agora/assets/icons/gct.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/libs/agora/wiki/.eslintrc.json b/libs/agora/charts/.eslintrc.json similarity index 100% rename from libs/agora/wiki/.eslintrc.json rename to libs/agora/charts/.eslintrc.json diff --git a/libs/agora/wiki/README.md b/libs/agora/charts/README.md similarity index 53% rename from libs/agora/wiki/README.md rename to libs/agora/charts/README.md index aa8282ed55..0399015e57 100644 --- a/libs/agora/wiki/README.md +++ b/libs/agora/charts/README.md @@ -1,7 +1,7 @@ -# agora-wiki +# agora-charts This library was generated with [Nx](https://nx.dev). ## Running unit tests -Run `nx test agora-wiki` to execute the unit tests. +Run `nx test agora-charts` to execute the unit tests. diff --git a/libs/agora/wiki/jest.config.ts b/libs/agora/charts/jest.config.ts similarity index 78% rename from libs/agora/wiki/jest.config.ts rename to libs/agora/charts/jest.config.ts index 8f82a390af..05bf6a4981 100644 --- a/libs/agora/wiki/jest.config.ts +++ b/libs/agora/charts/jest.config.ts @@ -1,15 +1,10 @@ /* eslint-disable */ export default { - displayName: 'agora-wiki', + displayName: 'agora-charts', preset: '../../../jest.preset.js', setupFilesAfterEnv: ['/src/test-setup.ts'], - globals: { - Request, - Response, - TextEncoder, - TextDecoder, - }, - coverageDirectory: '../../../coverage/libs/agora/wiki', + globals: {}, + coverageDirectory: '../../../coverage/libs/agora/charts', transform: { '^.+\\.(ts|mjs|js|html)$': [ 'jest-preset-angular', diff --git a/libs/agora/wiki/project.json b/libs/agora/charts/project.json similarity index 55% rename from libs/agora/wiki/project.json rename to libs/agora/charts/project.json index d4eff84e94..a9bf3b9db2 100644 --- a/libs/agora/wiki/project.json +++ b/libs/agora/charts/project.json @@ -1,19 +1,25 @@ { - "name": "agora-wiki", + "name": "agora-charts", "$schema": "../../../node_modules/nx/schemas/project-schema.json", "projectType": "library", - "sourceRoot": "libs/agora/wiki/src", + "sourceRoot": "libs/agora/charts/src", "prefix": "agora", "targets": { "test": { "executor": "@nx/jest:jest", - "outputs": ["{workspaceRoot}/coverage/libs/agora/wiki"], + "outputs": ["{workspaceRoot}/coverage/libs/agora/charts"], "options": { - "jestConfig": "libs/agora/wiki/jest.config.ts" + "jestConfig": "libs/agora/charts/jest.config.ts" } }, "lint": { "executor": "@nx/eslint:lint" + }, + "lint-fix": { + "executor": "@nx/eslint:lint", + "options": { + "fix": true + } } }, "tags": ["type:feature", "scope:agora", "language:typescript"], diff --git a/libs/agora/charts/src/index.ts b/libs/agora/charts/src/index.ts new file mode 100644 index 0000000000..2de597f06c --- /dev/null +++ b/libs/agora/charts/src/index.ts @@ -0,0 +1,9 @@ +export * from './lib/biodomains-chart/biodomains-chart.component'; +export * from './lib/box-plot-chart/box-plot-chart.component'; +export * from './lib/candlestick-chart/candlestick-chart.component'; +export * from './lib/median-barchart/median-barchart.component'; +export * from './lib/median-chart/median-chart.component'; +export * from './lib/network-chart/network-chart.component'; +export * from './lib/row-chart/row-chart.component'; +export * from './lib/score-barchart/score-barchart.component'; +export * from './lib/score-chart/score-chart.component'; diff --git a/libs/agora/charts/src/lib/base-chart/base-chart.component.html b/libs/agora/charts/src/lib/base-chart/base-chart.component.html new file mode 100644 index 0000000000..557d55a68f --- /dev/null +++ b/libs/agora/charts/src/lib/base-chart/base-chart.component.html @@ -0,0 +1,15 @@ +
+
+ @if (heading) { +

{{ heading }}

+ } +
+
+
+ +
+
diff --git a/libs/agora/charts/src/lib/base-chart/base-chart.component.scss b/libs/agora/charts/src/lib/base-chart/base-chart.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/agora/charts/src/lib/base-chart/base-chart.component.spec.ts.off b/libs/agora/charts/src/lib/base-chart/base-chart.component.spec.ts.off new file mode 100644 index 0000000000..db13609bac --- /dev/null +++ b/libs/agora/charts/src/lib/base-chart/base-chart.component.spec.ts.off @@ -0,0 +1,24 @@ +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { BaseChartComponent } from './base-chart.component'; +import { provideRouter } from '@angular/router'; + +describe('Component: Chart - Base', () => { + let fixture: ComponentFixture; + let component: BaseChartComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [provideRouter([])], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(BaseChartComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/agora/charts/src/lib/base-chart/base-chart.component.ts b/libs/agora/charts/src/lib/base-chart/base-chart.component.ts new file mode 100644 index 0000000000..1c1bcdb211 --- /dev/null +++ b/libs/agora/charts/src/lib/base-chart/base-chart.component.ts @@ -0,0 +1,168 @@ +/* eslint-disable @typescript-eslint/no-this-alias */ +import { Component, AfterViewInit, OnDestroy, ViewChild, ElementRef, Input } from '@angular/core'; +import * as d3 from 'd3'; +import * as dc from 'dc'; + +@Component({ + selector: 'agora-base-chart', + standalone: true, + templateUrl: './base-chart.component.html', + styleUrls: ['./base-chart.component.scss'], +}) +export class BaseChartComponent implements AfterViewInit, OnDestroy { + @Input() heading = ''; + + name = 'chart'; + chart: any; + + isLoading = false; + isInitialized = false; + + resizeTimer: ReturnType | number = 0; + + tooltips: { + [key: string]: d3.Selection; + } = {}; + + @ViewChild('chartContainer', { static: true }) chartContainer: ElementRef = {} as ElementRef; + + ngAfterViewInit() { + if (!this.isInitialized && !this.isLoading) { + this.init(); + } + } + + ngOnDestroy() { + this.destroy(); + } + + init() { + if (!this.chartContainer?.nativeElement) { + return; + } + + this.isInitialized = true; + } + + destroy() { + if (this.tooltips) { + for (const name in this.tooltips) { + this.tooltips[name].remove(); + } + } + if (this.chart) { + dc.chartRegistry.deregister(this.chart); + } + } + + getTooltip(name: string, className = '', arrowBelow = false) { + if (!this.tooltips[name]) { + this.tooltips[name] = d3 + .select('body') + .append('div') + .attr( + 'class', + `chart-tooltip ${ + arrowBelow ? 'arrow-below' : 'arrow-above' + } chart-${name}-tooltip${className ? ' ' + className : ''}`, + ); + } + + return this.tooltips[name]; + } + + showTooltip(name: string) { + if (!this.tooltips[name]) { + return; + } + + this.tooltips[name] + .transition() + .duration(80) + .style('opacity', 1) + .style('visibility', 'visible'); + } + + hideTooltip(name: string) { + if (!this.tooltips[name]) { + return; + } + + this.tooltips[name] + .transition() + .duration(80) + .style('top', '0') + .style('left', '0') + .style('opacity', 0) + .style('visibility', 'hidden'); + } + + onResize() { + if (!this.chart) { + return; + } + + const self = this; + clearTimeout(this.resizeTimer); + this.resizeTimer = setTimeout(() => { + self.chart + .width(self.chartContainer.nativeElement.parentElement.offsetWidth) + .height(self.chartContainer.nativeElement.offsetHeight); + if (self.chart.rescale) { + self.chart.rescale(); + } + self.chart.redraw(); + }, 100); + } + + getXAxisTooltipText(text: string) { + return text; + } + + addXAxisTooltips() { + const self = this; + const tooltip = this.getTooltip('x-axis', `chart-x-axis-tooltip ${this.name}-x-axis-tooltip`); + + this.chart.selectAll('g.axis.x g.tick').each(function (this: any) { + const tick = d3.select(this); + const tickText = tick.select('text'); + const tickLine = tick.select('line'); + + const text = self.getXAxisTooltipText(tickText?.text()); + + if (text) { + tickText + .on('mouseover', function () { + const tickTextNode = tickText.node() as HTMLElement; + const tickLineNode = tickLine.node() as HTMLElement; + const tooltipNode = tooltip.node() as HTMLElement; + + if (!tooltipNode || !tickTextNode || !tickLineNode) { + return; + } + + const tickTextRect = tickTextNode.getBoundingClientRect() || null; + const tickLineRect = tickLineNode.getBoundingClientRect() || null; + + tooltip + .html(text) + .style( + 'top', + // Position at the bottom on the label + 15px + `${window.pageYOffset + tickTextRect.top + tickTextRect.height + 15}px`, + ) + .style( + 'left', + // Left position of the tick line minus half the tooltip width to center. + `${tickLineRect.left - tooltipNode.offsetWidth / 2}px`, + ); + + self.showTooltip('x-axis'); + }) + .on('mouseout', function () { + self.hideTooltip('x-axis'); + }); + } + }); + } +} diff --git a/libs/agora/charts/src/lib/biodomains-chart/biodomains-chart.component.html b/libs/agora/charts/src/lib/biodomains-chart/biodomains-chart.component.html new file mode 100644 index 0000000000..6aa1c69b3e --- /dev/null +++ b/libs/agora/charts/src/lib/biodomains-chart/biodomains-chart.component.html @@ -0,0 +1,12 @@ +
+
+ + @if (!data) { +
No data is currently available
+ } +
diff --git a/libs/agora/charts/src/lib/biodomains-chart/biodomains-chart.component.scss b/libs/agora/charts/src/lib/biodomains-chart/biodomains-chart.component.scss new file mode 100644 index 0000000000..75855915fb --- /dev/null +++ b/libs/agora/charts/src/lib/biodomains-chart/biodomains-chart.component.scss @@ -0,0 +1,69 @@ +$tooltip-color: #63676c; + +#biodomains-chart { + position: relative; + width: 500px; + height: 560px; + + #biodomains-chart-tooltip { + position: absolute; + text-align: center; + padding: 5px; + font-size: 14px; + background-color: $tooltip-color; + color: white; + display: none; + z-index: 200; + opacity: 0.9; + width: 200px; + cursor: pointer; + border-radius: 5px; + } + + .tooltip-arrow { + &::before { + content: ''; + position: absolute; + left: 50%; + border: 10px solid transparent; + transform: translateX(-50%); + } + + &.arrow-below { + transform: translate(calc(-50%), calc(-100% - 20px)); + + &::before { + bottom: -9px; + border-bottom: 0; + border-top-color: $tooltip-color; + } + } + } + + .negative-bars { + &:hover { + fill: transparent; + cursor: pointer; + } + } + + .bars { + &:hover { + cursor: pointer; + } + } + + .bar-labels { + &:hover { + cursor: pointer; + } + } + + .bar-values { + pointer-events: none; // prevents the flicker associated with the mouseout event of the neg bar. + + &:hover { + cursor: pointer; + } + } +} diff --git a/libs/agora/charts/src/lib/biodomains-chart/biodomains-chart.component.spec.ts.off b/libs/agora/charts/src/lib/biodomains-chart/biodomains-chart.component.spec.ts.off new file mode 100644 index 0000000000..0f31e273d4 --- /dev/null +++ b/libs/agora/charts/src/lib/biodomains-chart/biodomains-chart.component.spec.ts.off @@ -0,0 +1,53 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { BiodomainsChartComponent } from './biodomains-chart.component'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { geneMock1 } from '@sagebionetworks/agora/testing'; +import { getRandomInt } from '@sagebionetworks/agora/shared'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Biodomains Chart', () => { + let fixture: ComponentFixture; + let component: BiodomainsChartComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [BiodomainsChartComponent], + imports: [RouterTestingModule], + providers: [HelperService], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(BiodomainsChartComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have tooltip text if there are linking terms', () => { + component.geneName = geneMock1.hgnc_symbol || geneMock1.ensembl_gene_id; + const linkingTerms = getRandomInt(1, 100); + const expected = 'Click to explore to GO Terms that link this biological domain to MSN'; + expect(component.getToolTipText(linkingTerms)).toBe(expected); + }); + + it('should have tooltip text if there are no linking terms', () => { + component.geneName = geneMock1.hgnc_symbol || geneMock1.ensembl_gene_id; + const linkingTerms = 0; + const expected = 'No GO Terms link this biological domain to MSN'; + expect(component.getToolTipText(linkingTerms)).toBe(expected); + }); +}); diff --git a/libs/agora/charts/src/lib/biodomains-chart/biodomains-chart.component.ts b/libs/agora/charts/src/lib/biodomains-chart/biodomains-chart.component.ts new file mode 100644 index 0000000000..07b4faaa47 --- /dev/null +++ b/libs/agora/charts/src/lib/biodomains-chart/biodomains-chart.component.ts @@ -0,0 +1,377 @@ +import { + Component, + ElementRef, + EventEmitter, + inject, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges, + ViewChild, + ViewEncapsulation, +} from '@angular/core'; +import { BioDomain } from '@sagebionetworks/agora/api-client-angular'; +import { HelperService } from '@sagebionetworks/agora/services'; +import * as d3 from 'd3'; + +@Component({ + selector: 'agora-biodomains-chart', + standalone: true, + providers: [HelperService], + templateUrl: './biodomains-chart.component.html', + styleUrls: ['./biodomains-chart.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class BiodomainsChartComponent implements OnChanges, OnInit, OnDestroy { + helperService = inject(HelperService); + + @Input() data: BioDomain[] | undefined; + @Input() geneName = ''; + + @Output() selectedBioDomainIndex = new EventEmitter(); + + @ViewChild('chart', { static: true }) chartRef: ElementRef = {} as ElementRef; + @ViewChild('tooltip', { static: true }) tooltip: ElementRef = {} as ElementRef; + + highlightColor = '#5081A7'; + + selectedBioDomain = ''; + selectedIndex = 0; + + initialized = false; + private chart!: d3.Selection; + + ngOnChanges(changes: SimpleChanges): void { + if (changes['data'] && !changes['data'].firstChange) { + this.hideChart(); + this.createChart(); + } + } + + ngOnInit(): void { + this.createChart(); + } + + ngOnDestroy(): void { + this.destroyChart(); + } + + hideChart() { + const svg = d3.select(this.chartRef.nativeElement); + svg.selectAll('*').remove(); + svg.style('display', 'none'); + } + + initChart() { + this.selectedIndex = 0; + this.selectedBioDomain = ''; + } + + createChart() { + const width = 500; + const height = 560; + + this.initChart(); + + this.selectedBioDomainIndex.emit(this.selectedIndex); + + if (!this.data) { + // clear the existing chart as the data may have changed due to a new overlaypanel being displayed + this.hideChart(); + } else { + const svg = (this.chart = d3.select(this.chartRef.nativeElement)); + + if (this.selectedIndex >= 0) this.selectedBioDomain = this.data[this.selectedIndex].biodomain; + + svg.attr('width', width).attr('height', height); + + svg.style('display', 'block'); + + const labelWidth = 200; + + const barColor = '#8B8AD1'; + + const xScale = d3 + .scaleLinear() + .domain([0, d3.max(this.data, (d) => d.pct_linking_terms) as number]) + .range([0, 200]); + + const yScale = d3 + .scaleBand() + .domain(this.data.map((d) => d.biodomain)) + .range([0, height]) + .paddingInner(0.4); + + // NEGATIVE SPACE NEXT TO BARS + svg + .selectAll('.negative-bars') + .data(this.data) + .enter() + .append('rect') + .attr('class', 'negative-bars') + .attr('x', (d) => labelWidth + xScale(d.pct_linking_terms)) + .attr('y', (d) => yScale(d.biodomain) || 0) + .attr('width', (d) => width - labelWidth - xScale(d.pct_linking_terms)) + .attr('height', yScale.bandwidth()) + .attr('fill', 'white') + .on('click', (event) => { + const index = svg + .selectAll('.negative-bars') + .nodes() + .indexOf(event.target as HTMLElement); + this.handleClick(bars, labels, barValues, index); + }) + .on('mouseenter', (event) => { + const index = svg + .selectAll('.negative-bars') + .nodes() + .indexOf(event.target as HTMLElement); + this.handleMouseEnter(bars, labels, barValues, index); + }) + .on('mouseleave', (event) => { + const index = svg + .selectAll('.negative-bars') + .nodes() + .indexOf(event.target as HTMLElement); + this.handleMouseLeave(bars, labels, barValues, index); + }); + + // BARS + const bars = svg + .selectAll('.bars') + .data(this.data) + .enter() + .append('rect') + .attr('class', 'bars') + .attr('x', labelWidth) + .attr('y', (d) => yScale(d.biodomain) || 0) + .attr('width', (d) => xScale(d.pct_linking_terms)) + .attr('height', yScale.bandwidth()) + .attr('fill', barColor) + .style('fill-opacity', (d) => (this.selectedBioDomain === d.biodomain ? '100%' : '50%')) + .on('click', (event: MouseEvent) => { + const index = svg + .selectAll('.bars') + .nodes() + .indexOf(event.target as HTMLElement); + this.handleClick(bars, labels, barValues, index); + }) + .on('mouseover', (event) => { + const index = svg + .selectAll('.bars') + .nodes() + .indexOf(event.target as HTMLElement); + this.handleMouseEnter(bars, labels, barValues, index); + }) + .on('mouseleave', (event) => { + const index = svg + .selectAll('.bars') + .nodes() + .indexOf(event.target as HTMLElement); + this.handleMouseLeave(bars, labels, barValues, index); + }); + + // BAR LABELS + const labels = svg + .selectAll('.bar-labels') + .data(this.data) + .enter() + .append('text') + .attr('class', 'bar-labels') + .attr('x', labelWidth - 10) + .attr('y', (d) => (yScale(d.biodomain) || 0) + yScale.bandwidth() / 2) + .attr('dy', '0.35em') + .attr('text-anchor', 'end') + .style('font-size', '12px') + .style('font-weight', (d) => (this.selectedBioDomain === d.biodomain ? 'bold' : 'normal')) + .text((d) => d.biodomain) + .on('click', (event) => { + const index = svg + .selectAll('.bar-labels') + .nodes() + .indexOf(event.target as HTMLElement); + this.handleClick(bars, labels, barValues, index); + }) + .on('mouseover', (event) => { + const index = svg + .selectAll('.bar-labels') + .nodes() + .indexOf(event.target as HTMLElement); + this.handleMouseEnter(bars, labels, barValues, index); + }) + .on('mousemove', (event: MouseEvent, data: BioDomain) => { + const tooltipText = this.getToolTipText(data.pct_linking_terms); + const tooltipCoordinates = this.getTooltipCoordinates( + event.offsetX, + yScale.bandwidth(), + yScale(data.biodomain) || 0, + ); + this.showTooltip(tooltipText, tooltipCoordinates.X, tooltipCoordinates.Y); + }) + .on('mouseleave', (event) => { + const index = svg + .selectAll('.bar-labels') + .nodes() + .indexOf(event.target as HTMLElement); + this.handleMouseLeave(bars, labels, barValues, index); + this.hideTooltip(event); + }); + + // BAR VALUE + const barValues = svg + .selectAll('.bar-values') + .data(this.data) + .enter() + .append('text') + .attr('class', 'bar-values') + .attr('x', (d) => labelWidth + xScale(d.pct_linking_terms) + 4) + .attr('y', (d) => (yScale(d.biodomain) || 0) + yScale.bandwidth() / 2) + .attr('dy', '0.35em') + .attr('text-anchor', 'start') + .style('font-size', '12px') + .text((data) => { + let percentage = this.helperService.roundNumber(data.pct_linking_terms, 1); + if (percentage === '0.0') percentage = '0'; + return `${percentage}%`; + }) + .style('display', (d) => (this.selectedBioDomain === d.biodomain ? 'block' : 'none')); + + this.initialized = true; + } + } + + handleClick( + bars: d3.Selection, + labels: d3.Selection, + barValues: d3.Selection, + index: number, + ) { + this.selectedIndex = index; + // emit change to index to populate GO terms + this.selectedBioDomainIndex.emit(index); + + // reset all elements + bars.style('fill-opacity', '50%'); + labels.style('font-weight', 'normal'); + labels.style('fill', 'black'); + barValues.style('display', 'none'); + + const bar = d3.select(bars.nodes()[index]); + bar.style('fill-opacity', '100%'); + + const label = d3.select(labels.nodes()[index]); + label.style('font-weight', 'bold'); + + const barValue = d3.select(barValues.nodes()[index]); + barValue.style('display', 'block'); + } + + handleMouseEnter( + bars: d3.Selection, + labels: d3.Selection, + barValues: d3.Selection, + index: number, + ) { + const bar = d3.select(bars.nodes()[index]); + this.highlightBar(bar, index); + + const label = d3.select(labels.nodes()[index]); + this.highlightLabel(label, index); + + const barValue = d3.select(barValues.nodes()[index]); + this.showBarValue(barValue, index); + } + + handleMouseLeave( + bars: d3.Selection, + labels: d3.Selection, + barValues: d3.Selection, + index: number, + ) { + const bar = d3.select(bars.nodes()[index]); + this.unhighlightBar(bar, index); + + const label = d3.select(labels.nodes()[index]); + this.unhighlightLabel(label, index); + + const barValue = d3.select(barValues.nodes()[index]); + this.hideBarValue(barValue, index); + } + + showBarValue(barValue: d3.Selection, index: number) { + if (index !== this.selectedIndex) { + barValue.style('display', 'block'); + } + } + + hideBarValue(barValue: d3.Selection, index: number) { + if (index !== this.selectedIndex) { + barValue.style('display', 'none'); + } + } + + highlightBar(bar: d3.Selection, index: number) { + // only highlight if it isn't the selected bar + if (index !== this.selectedIndex) { + bar.style('fill-opacity', '100%'); + } + } + + unhighlightBar(bar: d3.Selection, index: number) { + if (index !== this.selectedIndex) { + bar.style('fill-opacity', '50%'); + } + } + + highlightLabel(label: d3.Selection, index: number) { + // only bold if it isn't the selected bar + if (index !== this.selectedIndex) { + label.style('font-weight', 'bold'); + label.style('fill', this.highlightColor); + } + } + + unhighlightLabel(label: d3.Selection, index: number) { + if (index !== this.selectedIndex) { + label.style('font-weight', 'normal'); + label.style('fill', 'black'); + } + } + + getTooltipCoordinates(xBarPosition: number, yBarWidth: number, yBarPosition: number) { + // x-coordinate would be the left margin + x-barPosition + const x = xBarPosition; + // y-coordinate would be the y-barPosition + top margin + half of the bar width + const y = yBarPosition + yBarWidth / 2; + return { X: x, Y: y }; + } + + getToolTipText(linkingTerms: number) { + if (linkingTerms === 0) return `No GO Terms link this biological domain to ${this.geneName}`; + return `Click to explore to GO Terms that link this biological domain to ${this.geneName}`; + } + + showTooltip(text: string, x: number, y: number) { + const tooltipElement = this.tooltip.nativeElement; + tooltipElement.innerHTML = text; + tooltipElement.style.left = `${x}px`; + tooltipElement.style.top = `${y}px`; + tooltipElement.style.display = 'block'; + } + + hideTooltip(event: MouseEvent) { + const tooltipElement = this.tooltip.nativeElement; + // check whether mouse is over the tooltip otherwise there will be flicker + if (tooltipElement && tooltipElement.contains(event.relatedTarget as Node)) { + return; + } + + if (tooltipElement.style.display === 'block') tooltipElement.style.display = 'none'; + } + + destroyChart() { + if (this.initialized) this.chart.remove(); + } +} diff --git a/libs/agora/charts/src/lib/box-plot-chart/box-plot-chart.component.html b/libs/agora/charts/src/lib/box-plot-chart/box-plot-chart.component.html new file mode 100644 index 0000000000..42a9d8c172 --- /dev/null +++ b/libs/agora/charts/src/lib/box-plot-chart/box-plot-chart.component.html @@ -0,0 +1,31 @@ + + + diff --git a/libs/agora/charts/src/lib/box-plot-chart/box-plot-chart.component.scss b/libs/agora/charts/src/lib/box-plot-chart/box-plot-chart.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/agora/charts/src/lib/box-plot-chart/box-plot-chart.component.spec.ts.off b/libs/agora/charts/src/lib/box-plot-chart/box-plot-chart.component.spec.ts.off new file mode 100644 index 0000000000..7099ed2cab --- /dev/null +++ b/libs/agora/charts/src/lib/box-plot-chart/box-plot-chart.component.spec.ts.off @@ -0,0 +1,73 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { BoxPlotComponent } from './box-plot-chart.component'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { boxPlotChartItemMock } from '@sagebionetworks/agora/testing'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Chart - Box Plot', () => { + let fixture: ComponentFixture; + let component: BoxPlotComponent; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [BoxPlotComponent], + imports: [RouterTestingModule], + providers: [HelperService], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(BoxPlotComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + element = fixture.nativeElement; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display message if not data', () => { + expect(component.data?.length).toEqual(0); + expect(element.querySelector('.chart-no-data')).toBeTruthy(); + }); + + it('should render the chart', () => { + const idSpy = spyOn(component, 'initData').and.callThrough(); + const icSpy = spyOn(component, 'initChart').and.callThrough(); + + component.data = [boxPlotChartItemMock]; + fixture.detectChanges(); + + expect(idSpy).toHaveBeenCalled(); + expect(icSpy).toHaveBeenCalled(); + expect(element.querySelector('svg')).toBeTruthy(); + }); + + it('should have circle', () => { + component.data = [boxPlotChartItemMock]; + component.renderCircles(); + fixture.detectChanges(); + expect(element.querySelectorAll('svg circle')?.length).not.toEqual(0); + }); + + it('should have tooltips', () => { + component.data = [boxPlotChartItemMock]; + component.renderCircles(); + component.addXAxisTooltips(); + fixture.detectChanges(); + expect(document.querySelector('.box-plot-chart-x-axis-tooltip')).toBeTruthy(); + expect(document.querySelector('.box-plot-chart-value-tooltip')).toBeTruthy(); + }); +}); diff --git a/libs/agora/charts/src/lib/box-plot-chart/box-plot-chart.component.ts b/libs/agora/charts/src/lib/box-plot-chart/box-plot-chart.component.ts new file mode 100644 index 0000000000..106d326265 --- /dev/null +++ b/libs/agora/charts/src/lib/box-plot-chart/box-plot-chart.component.ts @@ -0,0 +1,198 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +/* eslint-disable @typescript-eslint/no-this-alias */ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { Component, inject, Input } from '@angular/core'; +import * as d3 from 'd3'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +// import { agoraBoxPlot } from './box-plot'; +import { boxPlotChartItem } from '@sagebionetworks/agora/models'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { BaseChartComponent } from '../base-chart/base-chart.component'; +import { BoxplotDirective } from '@sagebionetworks/shared/charts-angular'; + +// -------------------------------------------------------------------------- // +// Component +// -------------------------------------------------------------------------- // +@Component({ + selector: 'agora-box-plot-chart', + standalone: true, + imports: [BoxplotDirective], + providers: [HelperService], + templateUrl: './box-plot-chart.component.html', + styleUrls: ['./box-plot-chart.component.scss'], +}) +export class BoxPlotComponent extends BaseChartComponent { + helperService = inject(HelperService); + + _data: boxPlotChartItem[] = []; + get data(): boxPlotChartItem[] { + return this._data; + } + @Input() set data(data: boxPlotChartItem[]) { + this._data = data; + this.init(); + } + + @Input() xAxisLabel = ''; + @Input() yAxisLabel = 'LOG 2 FOLD CHANGE'; + @Input() yAxisMin: number | undefined; + @Input() yAxisMax: number | undefined; + @Input() yAxisPadding = 0.2; + @Input() rcRadius = 9; + @Input() rcColor = this.helperService.getColor('secondary'); + + override name = 'box-plot-chart'; + dimension: any; + group: any; + min = 0; + max = 0; + + override init() { + if (!this._data?.length || !this.chartContainer?.nativeElement) { + return; + } + + this.initData(); + + if (!this.chart) { + this.initChart(); + } else { + this.hideCircles(); + this.chart.redraw(); + } + + this.isInitialized = true; + } + + initData() { + const self = this; + + this.group = { + all: () => { + return self._data; + }, + order: () => {}, + top: () => {}, + }; + + this.dimension = { + filter: () => {}, + filterAll: () => {}, + }; + } + + initChart() { + const self = this; + + // this.chart = agoraBoxPlot(this.chartContainer.nativeElement, null, { + // yAxisMin: this.yAxisMin ? this.yAxisMin - this.yAxisPadding : undefined, + // yAxisMax: this.yAxisMax ? this.yAxisMax + this.yAxisPadding : undefined, + // }); + + this.chart.group(this.group).dimension(this.dimension); + + this.chart.elasticX(true).xAxis().tickSizeOuter([0]); + + this.chart + .elasticY(true) + .yAxisLabel(this.yAxisLabel, 20) + .yRangePadding(this.rcRadius * 1.5) + .yAxis() + .ticks(8); + //.tickSizeOuter([0]); + + this.chart + .renderTitle(false) + .showOutliers(0) + .dataWidthPortion(0.1) + .dataOpacity(0) + .colors('transparent') + .tickFormat(() => ''); + + this.chart.margins({ + left: 90, + right: 0, + bottom: 50, + top: 10, + }); + + this.chart.on('renderlet', function () { + self.renderCircles(); + self.addXAxisTooltips(); + }); + + this.chart.filter = () => ''; + this.chart.render(); + } + + renderCircles() { + const self = this; + const tooltip = this.getTooltip('internal', 'chart-value-tooltip box-plot-chart-value-tooltip'); + + const height = this.chartContainer.nativeElement.offsetHeight; + const lineCenter = this.chart.selectAll('line.center'); + const yDomainLength = Math.abs(this.chart.yAxisMax() - this.chart.yAxisMin()); + const mult = (height - 60) / yDomainLength; + + this.chart.selectAll('circle').remove(); + + this.chart.selectAll('g.box').each(function (this: HTMLElement, el: any, i: number) { + if (!self.data[i]['circle']) { + return; + } + + const data = self.data[i]['circle']; + const cy = Math.abs(self.chart.y().domain()[1] - data['value']) * mult; + const circle = d3.select(this).insert('circle', ':last-child'); + + circle + .attr('fill', self.rcColor) + .attr('r', self.rcRadius) + .attr('cx', lineCenter.attr('x1')) + .attr('cy', isNaN(cy) ? 0.0 : cy) + .style('stroke-width', 0) + .style('opacity', 0) + .style('transition', 'all .3s'); + + circle + .on('mouseover', function () { + if (!data['tooltip']) { + return; + } + + const offset = self.helperService.getOffset(this); + + tooltip + .html(data['tooltip']) + .style('left', (offset?.left || 0) + 'px') + .style('top', (offset?.top || 0) + 'px'); + + self.showTooltip('internal'); + }) + .on('mouseout', function () { + self.hideTooltip('internal'); + }); + }); + + setTimeout(() => { + self.showCircles(); + }, 1); + } + + hideCircles() { + this.chart.selectAll('circle').style('opacity', 0); + } + + showCircles() { + this.chart.selectAll('circle').style('opacity', 1); + } + + override getXAxisTooltipText(text: string) { + return this.helperService.getGCTColumnTooltipText(text); + } +} diff --git a/libs/agora/charts/src/lib/box-plot-chart/box-plot.ts b/libs/agora/charts/src/lib/box-plot-chart/box-plot.ts new file mode 100644 index 0000000000..06d25cf77d --- /dev/null +++ b/libs/agora/charts/src/lib/box-plot-chart/box-plot.ts @@ -0,0 +1,955 @@ +/* eslint-disable @typescript-eslint/no-this-alias */ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// @ts-nocheck + +import { scaleBand, scaleLinear } from 'd3'; +import { select } from 'd3'; +import { min, max, ascending, quantile, range } from 'd3'; +import { timerFlush } from 'd3'; + +import { CoordinateGridMixin } from 'dc'; +import { transition } from 'dc'; +import { units } from 'dc'; +import { utils } from 'dc'; +import { d3compat } from 'dc/src/core/config'; + +export const d3Box = function () { + let width = 1; + let height = 1; + let duration = 0; + const delay = 0; + let domain = null; + let value = Number; + let whiskers = boxWhiskers; + let quartiles = boxQuartiles; + let tickFormat = null; + + // Enhanced attributes + let renderDataPoints = false; + const dataRadius = 3; + let dataOpacity = 0.3; + let dataWidthPortion = 0.8; + let renderTitle = false; + let showOutliers = true; + let boldOutlier = false; + + // For each small multiple… + function box(g) { + g.each(function (_data, index) { + const data = _data.map(value).sort(ascending); + const _g = select(this); + const n = data.length; + let min; + let max; + + // Leave if there are no items. + if (data.length === 0) { + return; + } + + // Compute quartiles. Must return exactly 3 elements. + // const quartileData = (data.quartiles = quartiles(data)); + // ** Agora custom code + data.quartiles = quartiles(data); + const quartileData = _data.quartiles ? _data.quartiles : data.quartiles; + // Agora custom code ** + + // Compute whiskers. Must return exactly 2 elements, or null. + const whiskerIndices = whiskers && whiskers.call(this, data, index), + whiskerData = whiskerIndices && whiskerIndices.map((_i) => data[_i]); + + // Compute outliers. If no whiskers are specified, all data are 'outliers'. + // We compute the outliers as indices, so that we can join across transitions! + const outlierIndices = whiskerIndices + ? range(0, whiskerIndices[0]).concat(range(whiskerIndices[1] + 1, n)) + : range(n); + + // Determine the maximum value based on if outliers are shown + if (showOutliers) { + min = data[0]; + max = data[n - 1]; + } else { + min = data[whiskerIndices[0]]; + max = data[whiskerIndices[1]]; + } + const pointIndices = range(whiskerIndices[0], whiskerIndices[1] + 1); + + // Compute the new x-scale. + const x1 = scaleLinear() + .domain((domain && domain.call(this, data, index)) || [min, max]) + .range([height, 0]); + + // Retrieve the old x-scale, if this is an update. + const x0 = this.__chart__ || scaleLinear().domain([0, Infinity]).range(x1.range()); + + // Stash the new scale. + this.__chart__ = x1; + + // Note: the box, median, and box tick elements are fixed in number, + // so we only have to handle enter and update. In contrast, the outliers + // and other elements are variable, so we need to exit them! Variable + // elements also fade in and out. + + // Update center line: the vertical line spanning the whiskers. + const center = _g.selectAll('line.center').data(whiskerData ? [whiskerData] : []); + + center + .enter() + .insert('line', 'rect') + .attr('class', 'center') + .attr('x1', width / 2) + .attr('y1', (d) => x0(d[0])) + .attr('x2', width / 2) + .attr('y2', (d) => x0(d[1])) + .style('opacity', 1e-6) + .transition() + .duration(duration) + .delay(delay) + .style('opacity', 1) + .attr('y1', (d) => x1(d[0])) + .attr('y2', (d) => x1(d[1])); + + center + .transition() + .duration(duration) + .delay(delay) + .style('opacity', 1) + .attr('x1', width / 2) + .attr('x2', width / 2) + .attr('y1', (d) => x1(d[0])) + .attr('y2', (d) => x1(d[1])); + + center + .exit() + .transition() + .duration(duration) + .delay(delay) + .style('opacity', 1e-6) + .attr('y1', (d) => x1(d[0])) + .attr('y2', (d) => x1(d[1])) + .remove(); + + // Update innerquartile box. + const _box = _g.selectAll('rect.box').data([quartileData]); + + _box + .enter() + .append('rect') + .attr('class', 'box') + .attr('x', 0) + .attr('y', (d) => x0(d[2])) + .attr('width', width) + .attr('height', (d) => x0(d[0]) - x0(d[2])) + // ** Agora custom code + .attr('rx', 8) + .style('fill-opacity', renderDataPoints ? 0.1 : 1) + .transition() + .duration(duration) + .delay(delay) + .attr('y', (d) => x1(d[2])) + .attr('height', (d) => x1(d[0]) - x1(d[2])); + + _box + .transition() + .duration(duration) + .delay(delay) + .attr('width', width) + .attr('y', (d) => x1(d[2])) + .attr('height', (d) => x1(d[0]) - x1(d[2])); + + // Update median line. + const medianLine = _g.selectAll('line.median').data([quartileData[1]]); + + medianLine + .enter() + .append('line') + .attr('class', 'median') + .attr('x1', 0) + .attr('y1', x0) + .attr('x2', width) + .attr('y2', x0) + .transition() + .duration(duration) + .delay(delay) + .attr('y1', x1) + .attr('y2', x1); + + medianLine + .transition() + .duration(duration) + .delay(delay) + .attr('x1', 0) + .attr('x2', width) + .attr('y1', x1) + .attr('y2', x1); + + // Update whiskers. + const whisker = _g.selectAll('line.whisker').data(whiskerData || []); + + whisker + .enter() + .insert('line', 'circle, text') + .attr('class', 'whisker') + .attr('x1', 0) + .attr('y1', x0) + .attr('x2', width) + .attr('y2', x0) + .style('opacity', 1e-6) + .transition() + .duration(duration) + .delay(delay) + .attr('y1', x1) + .attr('y2', x1) + .style('opacity', 1); + + whisker + .transition() + .duration(duration) + .delay(delay) + .attr('x1', 0) + .attr('x2', width) + .attr('y1', x1) + .attr('y2', x1) + .style('opacity', 1); + + whisker + .exit() + .transition() + .duration(duration) + .delay(delay) + .attr('y1', x1) + .attr('y2', x1) + .style('opacity', 1e-6) + .remove(); + + // Update outliers. + if (showOutliers) { + const outlierClass = boldOutlier ? 'outlierBold' : 'outlier'; + const outlierSize = boldOutlier ? 3 : 5; + + let outlierX; + if (boldOutlier) { + outlierX = () => { + return Math.floor( + Math.random() * (width * dataWidthPortion) + + 1 + + (width - width * dataWidthPortion) / 2, + ); + }; + } else { + outlierX = () => { + return width / 2; + }; + } + + const outlier = _g.selectAll(`circle.${outlierClass}`).data(outlierIndices, Number); + + outlier + .enter() + .insert('circle', 'text') + .attr('class', outlierClass) + .attr('r', outlierSize) + .attr('cx', outlierX) + .attr('cy', (i) => x0(data[i])) + .style('opacity', 1e-6) + .transition() + .duration(duration) + .delay(delay) + .attr('cy', (i) => x1(data[i])) + .style('opacity', 0.6); + + if (renderTitle) { + outlier.selectAll('title').remove(); + outlier.append('title').text((i) => data[i]); + } + + outlier + .transition() + .duration(duration) + .delay(delay) + .attr('cx', outlierX) + .attr('cy', (i) => x1(data[i])) + .style('opacity', 0.6); + + outlier + .exit() + .transition() + .duration(duration) + .delay(delay) + .attr('cy', 0) //function (i) { return x1(d[i]); }) + .style('opacity', 1e-6) + .remove(); + } + + // Update Values + if (renderDataPoints) { + const point = _g.selectAll('circle.data').data(pointIndices); + + point + .enter() + .insert('circle', 'text') + .attr('class', 'data') + .attr('r', dataRadius) + .attr('cx', () => + Math.floor( + Math.random() * (width * dataWidthPortion) + + 1 + + (width - width * dataWidthPortion) / 2, + ), + ) + .attr('cy', (i) => x0(data[i])) + .style('opacity', 1e-6) + .transition() + .duration(duration) + .delay(delay) + .attr('cy', (i) => x1(data[i])) + .style('opacity', dataOpacity); + + if (renderTitle) { + point.selectAll('title').remove(); + point.append('title').text((i) => data[i]); + } + + point + .transition() + .duration(duration) + .delay(delay) + .attr('cx', () => + Math.floor( + Math.random() * (width * dataWidthPortion) + + 1 + + (width - width * dataWidthPortion) / 2, + ), + ) + .attr('cy', (i) => x1(data[i])) + .style('opacity', dataOpacity); + + point + .exit() + .transition() + .duration(duration) + .delay(delay) + .attr('cy', 0) + .style('opacity', 1e-6) + .remove(); + } + + // Compute the tick format. + const format = tickFormat || x1.tickFormat(8); + + // Update box ticks. + const boxTick = _g.selectAll('text.box').data(quartileData); + + boxTick + .enter() + .append('text') + .attr('class', 'box') + .attr('dy', '.3em') + .attr('dx', (d, i) => (i & 1 ? 6 : -6)) + .attr('x', (d, i) => (i & 1 ? width : 0)) + .attr('y', x0) + .attr('text-anchor', (d, i) => (i & 1 ? 'start' : 'end')) + .text(format) + .transition() + .duration(duration) + .delay(delay) + .attr('y', x1); + + boxTick + .transition() + .duration(duration) + .delay(delay) + .text(format) + .attr('x', (d, i) => (i & 1 ? width : 0)) + .attr('y', x1); + + // Update whisker ticks. These are handled separately from the box + // ticks because they may or may not exist, and we want don't want + // to join box ticks pre-transition with whisker ticks post-. + const whiskerTick = _g.selectAll('text.whisker').data(whiskerData || []); + + whiskerTick + .enter() + .append('text') + .attr('class', 'whisker') + .attr('dy', '.3em') + .attr('dx', 6) + .attr('x', width) + .attr('y', x0) + .text(format) + .style('opacity', 1e-6) + .transition() + .duration(duration) + .delay(delay) + .attr('y', x1) + .style('opacity', 1); + + whiskerTick + .transition() + .duration(duration) + .delay(delay) + .text(format) + .attr('x', width) + .attr('y', x1) + .style('opacity', 1); + + whiskerTick + .exit() + .transition() + .duration(duration) + .delay(delay) + .attr('y', x1) + .style('opacity', 1e-6) + .remove(); + + // Remove temporary quartiles element from within data array. + delete data.quartiles; + }); + timerFlush(); + } + + box.width = function (x) { + if (!arguments.length) { + return width; + } + width = x; + return box; + }; + + box.height = function (x) { + if (!arguments.length) { + return height; + } + height = x; + return box; + }; + + box.tickFormat = function (x) { + if (!arguments.length) { + return tickFormat; + } + tickFormat = x; + return box; + }; + + box.showOutliers = function (x) { + if (!arguments.length) { + return showOutliers; + } + showOutliers = x; + return box; + }; + + box.boldOutlier = function (x) { + if (!arguments.length) { + return boldOutlier; + } + boldOutlier = x; + return box; + }; + + box.renderDataPoints = function (x) { + if (!arguments.length) { + return renderDataPoints; + } + renderDataPoints = x; + return box; + }; + + box.renderTitle = function (x) { + if (!arguments.length) { + return renderTitle; + } + renderTitle = x; + return box; + }; + + box.dataOpacity = function (x) { + if (!arguments.length) { + return dataOpacity; + } + dataOpacity = x; + return box; + }; + + box.dataWidthPortion = function (x) { + if (!arguments.length) { + return dataWidthPortion; + } + dataWidthPortion = x; + return box; + }; + + box.duration = function (x) { + if (!arguments.length) { + return duration; + } + duration = x; + return box; + }; + + box.domain = function (x) { + if (!arguments.length) { + return domain; + } + domain = x === null ? x : typeof x === 'function' ? x : utils.constant(x); + return box; + }; + + box.value = function (x) { + if (!arguments.length) { + return value; + } + value = x; + return box; + }; + + box.whiskers = function (x) { + if (!arguments.length) { + return whiskers; + } + whiskers = x; + return box; + }; + + box.quartiles = function (x) { + if (!arguments.length) { + return quartiles; + } + quartiles = x; + return box; + }; + + return box; +}; + +function boxWhiskers(d) { + return [0, d.length - 1]; +} + +function boxQuartiles(d) { + return [quantile(d, 0.25), quantile(d, 0.5), quantile(d, 0.75)]; +} + +// Returns a function to compute the interquartile range. +function defaultWhiskersIQR(k) { + return (d) => { + const q1 = d.quartiles[0]; + const q3 = d.quartiles[2]; + const iqr = (q3 - q1) * k; + + let i = -1; + let j = d.length; + + do { + ++i; + } while (d[i] < q1 - iqr); + + do { + --j; + } while (d[j] > q3 + iqr); + + return [i, j]; + }; +} + +/** + * A box plot is a chart that depicts numerical data via their quartile ranges. + * + * Examples: + * - {@link http://dc-js.github.io/dc.js/examples/boxplot-basic.html Boxplot Basic example} + * - {@link http://dc-js.github.io/dc.js/examples/boxplot-enhanced.html Boxplot Enhanced example} + * - {@link http://dc-js.github.io/dc.js/examples/boxplot-render-data.html Boxplot Render Data example} + * - {@link http://dc-js.github.io/dc.js/examples/boxplot-time.html Boxplot time example} + * @mixes CoordinateGridMixin + */ +export class AgoraBoxPlot extends CoordinateGridMixin { + /** + * Create a Box Plot. + * + * @example + * // create a box plot under #chart-container1 element using the default global chart group + * var boxPlot1 = new BoxPlot('#chart-container1'); + * // create a box plot under #chart-container2 element using chart group A + * var boxPlot2 = new BoxPlot('#chart-container2', 'chartGroupA'); + * @param {String|node|d3.selection} parent - Any valid + * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying + * a dom block element such as a div; or a dom element or d3 selection. + * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in. + * Interaction with a chart will only trigger events and redraws within the chart's group. + */ + constructor(parent, chartGroup?, options?) { + super(); + + this._whiskerIqrFactor = 1.5; + this._whiskersIqr = defaultWhiskersIQR; + this._whiskers = this._whiskersIqr(this._whiskerIqrFactor); + + this._box = d3Box(); + this._tickFormat = null; + this._renderDataPoints = false; + this._dataOpacity = 0.3; + this._dataWidthPortion = 0.8; + this._showOutliers = true; + this._boldOutlier = false; + + // Used in yAxisMin and yAxisMax to add padding in pixel coordinates + // so the min and max data points/whiskers are within the chart + this._yRangePadding = 8; + + this._yAxisMin = options?.yAxisMin || undefined; + this._yAxisMax = options?.yAxisMax || undefined; + + this._boxWidth = (innerChartWidth, xUnits) => { + if (this.isOrdinal()) { + return this.x().bandwidth(); + } else { + return innerChartWidth / (1 + this.boxPadding()) / xUnits; + } + }; + + // default to ordinal + this.x(scaleBand()); + this.xUnits(units.ordinal); + + // valueAccessor should return an array of values that can be coerced into numbers + // or if data is overloaded for a static array of arrays, it should be `Number`. + // Empty arrays are not included. + this.data((group) => + group + .all() + .map((d) => { + d.map = (accessor) => accessor.call(d, d); + return d; + }) + .filter((d) => { + const values = this.valueAccessor()(d); + return values.length !== 0; + }), + ); + + this.boxPadding(0.8); + this.outerPadding(0.5); + + this.anchor(parent, chartGroup); + } + + /** + * Get or set the spacing between boxes as a fraction of box size. Valid values are within 0-1. + * See the {@link https://github.com/d3/d3-scale/blob/master/README.md#scaleBand d3 docs} + * for a visual description of how the padding is applied. + * @see {@link https://github.com/d3/d3-scale/blob/master/README.md#scaleBand d3.scaleBand} + * @param {Number} [padding=0.8] + * @returns {Number|BoxPlot} + */ + boxPadding(padding) { + if (!arguments.length) { + return this._rangeBandPadding(); + } + return this._rangeBandPadding(padding); + } + + /** + * Get or set the outer padding on an ordinal box chart. This setting has no effect on non-ordinal charts + * or on charts with a custom {@link BoxPlot#boxWidth .boxWidth}. Will pad the width by + * `padding * barWidth` on each side of the chart. + * @param {Number} [padding=0.5] + * @returns {Number|BoxPlot} + */ + outerPadding(padding) { + if (!arguments.length) { + return this._outerRangeBandPadding(); + } + return this._outerRangeBandPadding(padding); + } + + /** + * Get or set the numerical width of the boxplot box. The width may also be a function taking as + * parameters the chart width excluding the right and left margins, as well as the number of x + * units. + * @example + * // Using numerical parameter + * chart.boxWidth(10); + * // Using function + * chart.boxWidth((innerChartWidth, xUnits) { ... }); + * @param {Number|Function} [boxWidth=0.5] + * @returns {Number|Function|BoxPlot} + */ + boxWidth(boxWidth) { + if (!arguments.length) { + return this._boxWidth; + } + this._boxWidth = typeof boxWidth === 'function' ? boxWidth : utils.constant(boxWidth); + return this; + } + + _boxTransform(d, i) { + const xOffset = this.x()(this.keyAccessor()(d, i)); + return `translate(${xOffset}, 0)`; + } + + _preprocessData() { + if (this.elasticX()) { + this.x().domain([]); + } + } + + plotData() { + this._calculatedBoxWidth = this._boxWidth(this.effectiveWidth(), this.xUnitCount()); + + this._box + .whiskers(this._whiskers) + .width(this._calculatedBoxWidth) + .height(this.effectiveHeight()) + .value(this.valueAccessor()) + .domain(this.y().domain()) + .duration(this.transitionDuration()) + .tickFormat(this._tickFormat) + .renderDataPoints(this._renderDataPoints) + .dataOpacity(this._dataOpacity) + .dataWidthPortion(this._dataWidthPortion) + .renderTitle(this.renderTitle()) + .showOutliers(this._showOutliers) + .boldOutlier(this._boldOutlier); + + const boxesG = this.chartBodyG().selectAll('g.box').data(this.data(), this.keyAccessor()); + + const boxesGEnterUpdate = this._renderBoxes(boxesG); + this._updateBoxes(boxesGEnterUpdate); + this._removeBoxes(boxesG); + + this.fadeDeselectedArea(this.filter()); + } + + _renderBoxes(boxesG) { + const boxesGEnter = boxesG.enter().append('g'); + + boxesGEnter + .attr('class', 'box') + .classed('dc-tabbable', this._keyboardAccessible) + .attr('transform', (d, i) => this._boxTransform(d, i)) + .call(this._box) + .on( + 'click', + d3compat.eventHandler((d) => { + this.filter(this.keyAccessor()(d)); + this.redrawGroup(); + }), + ) + .selectAll('circle') + .classed('dc-tabbable', this._keyboardAccessible); + + if (this._keyboardAccessible) { + this._makeKeyboardAccessible(this.onClick); + } + + return boxesGEnter.merge(boxesG); + } + + _updateBoxes(boxesG) { + const chart = this; + transition(boxesG, this.transitionDuration(), this.transitionDelay()) + .attr('transform', (d, i) => this._boxTransform(d, i)) + .call(this._box) + .each(function (d) { + const color = chart.getColor(d, 0); + select(this).select('rect.box').attr('fill', color); + select(this).selectAll('circle.data').attr('fill', color); + }); + } + + _removeBoxes(boxesG) { + boxesG.exit().remove().call(this._box); + } + + _minDataValue() { + return min(this.data(), (e) => min(this.valueAccessor()(e))); + } + + _maxDataValue() { + return max(this.data(), (e) => max(this.valueAccessor()(e))); + } + + _yAxisRangeRatio() { + return (this._maxDataValue() - this._minDataValue()) / this.effectiveHeight(); + } + + onClick(d) { + this.filter(this.keyAccessor()(d)); + this.redrawGroup(); + } + + fadeDeselectedArea(brushSelection) { + const chart = this; + if (this.hasFilter()) { + if (this.isOrdinal()) { + this.g() + .selectAll('g.box') + .each(function (d) { + if (chart.isSelectedNode(d)) { + chart.highlightSelected(this); + } else { + chart.fadeDeselected(this); + } + }); + } else { + if (!(this.brushOn() || this.parentBrushOn())) { + return; + } + const start = brushSelection[0]; + const end = brushSelection[1]; + this.g() + .selectAll('g.box') + .each(function (d) { + const key = chart.keyAccessor()(d); + if (key < start || key >= end) { + chart.fadeDeselected(this); + } else { + chart.highlightSelected(this); + } + }); + } + } else { + this.g() + .selectAll('g.box') + .each(function () { + chart.resetHighlight(this); + }); + } + } + + isSelectedNode(d) { + return this.hasFilter(this.keyAccessor()(d)); + } + + yAxisMin() { + const padding = this._yRangePadding * this._yAxisRangeRatio(); + let min = this._minDataValue(); + min = this._yAxisMin && this._yAxisMin < min ? this._yAxisMin : min - 0.2; + return utils.subtract(min - padding, this.yAxisPadding()); + } + + yAxisMax() { + const padding = this._yRangePadding * this._yAxisRangeRatio(); + let max = this._maxDataValue(); + max = this._yAxisMax && this._yAxisMax > max ? this._yAxisMax : max + 0.2; + return utils.add(max + padding, this.yAxisPadding()); + } + + /** + * Get or set the numerical format of the boxplot median, whiskers and quartile labels. Defaults + * to integer formatting. + * @example + * // format ticks to 2 decimal places + * chart.tickFormat(d3.format('.2f')); + * @param {Function} [tickFormat] + * @returns {Number|Function|BoxPlot} + */ + tickFormat(tickFormat) { + if (!arguments.length) { + return this._tickFormat; + } + this._tickFormat = tickFormat; + return this; + } + + /** + * Get or set the amount of padding to add, in pixel coordinates, to the top and + * bottom of the chart to accommodate box/whisker labels. + * @example + * // allow more space for a bigger whisker font + * chart.yRangePadding(12); + * @param {Function} [yRangePadding = 8] + * @returns {Number|Function|BoxPlot} + */ + yRangePadding(yRangePadding) { + if (!arguments.length) { + return this._yRangePadding; + } + this._yRangePadding = yRangePadding; + return this; + } + + /** + * Get or set whether individual data points will be rendered. + * @example + * // Enable rendering of individual data points + * chart.renderDataPoints(true); + * @param {Boolean} [show=false] + * @returns {Boolean|BoxPlot} + */ + renderDataPoints(show) { + if (!arguments.length) { + return this._renderDataPoints; + } + this._renderDataPoints = show; + return this; + } + + /** + * Get or set the opacity when rendering data. + * @example + * // If individual data points are rendered increase the opacity. + * chart.dataOpacity(70%); + * @param {Number} [opacity=0.3] + * @returns {Number|BoxPlot} + */ + dataOpacity(opacity) { + if (!arguments.length) { + return this._dataOpacity; + } + this._dataOpacity = opacity; + return this; + } + + /** + * Get or set the portion of the width of the box to show data points. + * @example + * // If individual data points are rendered increase the data box. + * chart.dataWidthPortion(0.9); + * @param {Number} [percentage=0.8] + * @returns {Number|BoxPlot} + */ + dataWidthPortion(percentage) { + if (!arguments.length) { + return this._dataWidthPortion; + } + this._dataWidthPortion = percentage; + return this; + } + + /** + * Get or set whether outliers will be rendered. + * @example + * // Disable rendering of outliers + * chart.showOutliers(false); + * @param {Boolean} [show=true] + * @returns {Boolean|BoxPlot} + */ + showOutliers(show) { + if (!arguments.length) { + return this._showOutliers; + } + this._showOutliers = show; + return this; + } + + /** + * Get or set whether outliers will be drawn bold. + * @example + * // If outliers are rendered display as bold + * chart.boldOutlier(true); + * @param {Boolean} [show=false] + * @returns {Boolean|BoxPlot} + */ + boldOutlier(show) { + if (!arguments.length) { + return this._boldOutlier; + } + this._boldOutlier = show; + return this; + } +} + +export const agoraBoxPlot = (parent, chartGroup?, options?) => + new AgoraBoxPlot(parent, chartGroup, options); diff --git a/libs/agora/charts/src/lib/candlestick-chart/candlestick-chart.component.html b/libs/agora/charts/src/lib/candlestick-chart/candlestick-chart.component.html new file mode 100644 index 0000000000..0d1a2d7a12 --- /dev/null +++ b/libs/agora/charts/src/lib/candlestick-chart/candlestick-chart.component.html @@ -0,0 +1,19 @@ +
+
+ @if (heading) { +

{{ heading }}

+ } +
+
+
+ @if (xAxisLabel) { +
{{ xAxisLabel }}
+ } + @if (!chartData.length) { +
+
No data is currently available.
+
+ } + +
+
diff --git a/libs/agora/charts/src/lib/candlestick-chart/candlestick-chart.component.scss b/libs/agora/charts/src/lib/candlestick-chart/candlestick-chart.component.scss new file mode 100644 index 0000000000..149ebe08dc --- /dev/null +++ b/libs/agora/charts/src/lib/candlestick-chart/candlestick-chart.component.scss @@ -0,0 +1,58 @@ +.chart-container { + height: 450px; +} + +@mixin tooltip() { + position: absolute; + padding: 14px; + text-align: center; + line-height: normal; + border: 0; + box-shadow: 0 0 4px #c7c5c5; +} + +candlestick-chart { + display: block; + margin-top: 60px; + + .rc .cc { + svg { + g.axis { + fill: none; + } + } + } +} + +.rna-seq-candlestick .rc-empty-plot-content { + height: 450px; + position: relative; + display: block; + background: rgb(245 245 245 / 50%); +} + +.candlestick-plot-xaxis-tooltip { + background: #fff; + border-radius: 2px; + color: #000; + + @include tooltip; +} + +.candlestick-plot-tooltip { + background: #2f8e94; + border-radius: 8px; + color: #fff; + pointer-events: none; + + @include tooltip; +} + +@media (min-width >= 576px) { + candlestick-chart { + .rc { + flex: 0 0 100%; + max-width: 100%; + } + } +} diff --git a/libs/agora/charts/src/lib/candlestick-chart/candlestick-chart.component.spec.ts.off b/libs/agora/charts/src/lib/candlestick-chart/candlestick-chart.component.spec.ts.off new file mode 100644 index 0000000000..05343596cb --- /dev/null +++ b/libs/agora/charts/src/lib/candlestick-chart/candlestick-chart.component.spec.ts.off @@ -0,0 +1,64 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { CandlestickChartComponent } from './candlestick-chart.component'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { geneMock1 } from '@sagebionetworks/agora/testing'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Chart - Candlestick', () => { + let fixture: ComponentFixture; + let component: CandlestickChartComponent; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [CandlestickChartComponent], + imports: [RouterTestingModule], + providers: [HelperService], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(CandlestickChartComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + element = fixture.nativeElement; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display message if not data', () => { + expect(component.chartData?.length).toEqual(0); + expect(element.querySelector('.chart-no-data')).toBeTruthy(); + }); + + it('should render the chart', () => { + const idSpy = spyOn(component, 'initData').and.callThrough(); + const icSpy = spyOn(component, 'initChart').and.callThrough(); + + component.gene = geneMock1; + fixture.detectChanges(); + + expect(idSpy).toHaveBeenCalled(); + expect(icSpy).toHaveBeenCalled(); + expect(element.querySelector('svg')).toBeTruthy(); + }); + + it('should have tooltips', () => { + component.gene = geneMock1; + fixture.detectChanges(); + expect(document.querySelector('.candlestick-chart-x-axis-tooltip')).toBeTruthy(); + expect(document.querySelector('.candlestick-chart-value-tooltip')).toBeTruthy(); + }); +}); diff --git a/libs/agora/charts/src/lib/candlestick-chart/candlestick-chart.component.ts b/libs/agora/charts/src/lib/candlestick-chart/candlestick-chart.component.ts new file mode 100644 index 0000000000..4668b8bc5b --- /dev/null +++ b/libs/agora/charts/src/lib/candlestick-chart/candlestick-chart.component.ts @@ -0,0 +1,212 @@ +/* eslint-disable @typescript-eslint/no-this-alias */ +import { Component, inject, Input } from '@angular/core'; +import * as d3 from 'd3'; + +import { HelperService } from '@sagebionetworks/agora/services'; +import { Gene } from '@sagebionetworks/agora/api-client-angular'; +import { BaseChartComponent } from '../base-chart/base-chart.component'; + +@Component({ + selector: 'agora-candlestick-chart', + standalone: true, + providers: [HelperService], + templateUrl: './candlestick-chart.component.html', + styleUrls: ['./candlestick-chart.component.scss'], +}) +export class CandlestickChartComponent extends BaseChartComponent { + helperService = inject(HelperService); + + _gene: Gene | undefined; + get gene(): Gene | undefined { + return this._gene; + } + @Input() set gene(gene: Gene | undefined) { + this._gene = gene; + this.init(); + } + + @Input() xAxisLabel = ''; + + override name = 'candlestick-chart'; + chartData: any[] = []; + maxValue = 2.0; + minValue = 0.0; + chartHeight = 500; + + override init() { + if (!this._gene?.neuropathologic_correlations?.length || !this.chartContainer.nativeElement) { + this.chartData = []; + return; + } + + this.initData(); + this.initChart(); + this.addXAxisTooltips(); + + this.isInitialized = true; + } + + override destroy() { + if (this.tooltips) { + for (const name in this.tooltips) { + this.tooltips[name].remove(); + } + } + } + + initData() { + const neuropathCorrelations = + this._gene?.neuropathologic_correlations?.filter( + (item: any) => item.neuropath_type !== 'DCFDX', + ) || []; + + neuropathCorrelations.sort((a: any, b: any) => (a.neuropath_type > b.neuropath_type ? 1 : -1)); + + this.chartData = neuropathCorrelations.map((item: any) => { + const data = { + key: item.neuropath_type, + ensg: item.ensg, + value: { + min: item.ci_lower, + max: item.ci_upper, + mean: item.oddsratio, + pval_adj: item.pval_adj, + }, + }; + return data; + }); + } + + initChart() { + const self = this; + const chartContainerEl = this.chartContainer.nativeElement; + const chartContainer = d3.select(chartContainerEl); + const margin = { top: 100, right: 0, bottom: 10, left: 80 }; + const width = chartContainerEl.offsetWidth - margin.left - margin.right; + const height = this.chartHeight - margin.top - margin.bottom; + const color = this.helperService.getColor('secondary'); + const tooltip = this.getTooltip( + 'internal', + 'chart-value-tooltip candlestick-chart-value-tooltip', + ); + + if (this.chart) { + this.chart.remove(); + } + + const svg = chartContainer.append('svg').attr('width', width).attr('height', height); + + this.chart = svg; + + const group = svg.append('g').attr('transform', `translate(${margin.left}, 10)`); + + // Draw X axis + const x = d3 + .scaleBand() + .range([0, width]) + .domain( + this.chartData.map((item) => { + return item.key; + }), + ) + .paddingInner(1) + .paddingOuter(0.5); + + group + .append('g') + .attr('transform', 'translate(0,' + height + ')') + .attr('class', 'axis x') + .call(d3.axisBottom(x)); + + // Draw Y axis + const y = d3.scaleLinear().domain([this.minValue, this.maxValue]).range([height, 0]); + + group.append('g').attr('class', 'axis y-axis').call(d3.axisLeft(y)); + + // Draw Y axis title + group + .append('text') + .attr('class', 'y-axis-label y-label') + .attr('transform', `rotate(-90) translate(${-height / 2}, ${-margin.left + 20})`) + .style('text-anchor', 'middle') + .style('font-weight', 'bold') + .text('ODDS RATIO'); + + // Draw vertical lines + group + .selectAll('.vertLines') + .data(this.chartData) + .enter() + .append('line') + .attr('class', 'vertLines') + .attr('x1', (d: any): any => x(d.key)) + .attr('x2', (d: any): any => x(d.key)) + .attr('y1', (d: any) => y(d.value.min)) + .attr('y2', (d: any) => y(d.value.max)) + .attr('stroke', color) + .attr('stroke-width', 1.5); + + // Draw mid circle (mean value) + const circle = group + .selectAll('.meanCircle') + .data(this.chartData) + .enter() + .append('circle') + .attr('class', 'meanCircle') + .attr('cx', (d: any): any => x(d.key)) + .attr('cy', (d: any) => y(d.value.mean)) + .attr('r', 9) + .attr('stroke', color) + .style('fill', color); + + //Circle tooltip + circle + .on('mouseover', function (event: any, d: any) { + const isOrNot = d.value.pval_adj <= 0.05 ? 'is' : 'is not'; + const text = `${ + self._gene?.hgnc_symbol || self._gene?.ensembl_gene_id + } ${isOrNot} significantly correlated with ${ + d.key + }, with an odds ratio of ${self.helperService.getSignificantFigures( + d.value.mean, + 3, + )} and an adjusted p-value of ${self.helperService.getSignificantFigures( + d.value.pval_adj, + 3, + )}.`; + const offset = self.helperService.getOffset(this); + + tooltip + .text(text) + .style('left', (offset?.left || 0) + 'px') + .style('top', (offset?.top || 0) + 'px'); + + self.showTooltip('internal'); + }) + .on('mouseout', function () { + self.hideTooltip('internal'); + }); + + // Add red horizontal line + group + .append('g') + .attr('transform', `translate(0,${y(1.0)})`) + .append('line') + .attr('class', 'yAxisGuide') + .attr('x2', width) + .style('stroke', 'red') + .style('stroke-width', '1px'); + } + + override onResize() { + if (!this.chart) { + return; + } + + const self = this; + clearTimeout(this.resizeTimer); + this.resizeTimer = setTimeout(() => { + self.initChart(); + }, 100); + } +} diff --git a/libs/agora/charts/src/lib/median-barchart/median-barchart.component.html b/libs/agora/charts/src/lib/median-barchart/median-barchart.component.html new file mode 100644 index 0000000000..354f10be00 --- /dev/null +++ b/libs/agora/charts/src/lib/median-barchart/median-barchart.component.html @@ -0,0 +1,9 @@ +
+
+ + @if (data.length === 0) { +
+
No data is currently available.
+
+ } +
diff --git a/libs/agora/charts/src/lib/median-barchart/median-barchart.component.scss b/libs/agora/charts/src/lib/median-barchart/median-barchart.component.scss new file mode 100644 index 0000000000..dcb02edea3 --- /dev/null +++ b/libs/agora/charts/src/lib/median-barchart/median-barchart.component.scss @@ -0,0 +1,27 @@ +#median-barchart { + .x-axis-label, + .y-axis-label { + font-size: var(--font-size-lg); + font-weight: 700; + color: var(--color-chart-axis-label); + fill: var(--color-chart-axis-label); + } + + .y-axis .tick { + text { + font-size: var(--font-size-sm); + } + } + + .x-axis .tick { + text { + font-size: var(--font-size-md); + font-weight: 700; + } + } + + .bar-labels { + font-size: var(--font-size-sm); + text-anchor: middle; + } +} diff --git a/libs/agora/charts/src/lib/median-barchart/median-barchart.component.spec.ts.off b/libs/agora/charts/src/lib/median-barchart/median-barchart.component.spec.ts.off new file mode 100644 index 0000000000..793a076795 --- /dev/null +++ b/libs/agora/charts/src/lib/median-barchart/median-barchart.component.spec.ts.off @@ -0,0 +1,203 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture, waitForAsync } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { MedianBarChartComponent } from './median-barchart.component'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { geneMock1 } from '@sagebionetworks/agora/testing'; +import { MedianExpression } from '@sagebionetworks/agora/api-client-angular'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +const XAXIS_LABEL = 'BRAIN REGION'; +const YAXIS_LABEL = 'LOG2CPM'; +const FULL_MOCK_DATA = geneMock1.median_expression; +const TISSUES = ['TCX', 'PHG', 'STG']; +const SMALL_MOCK_DATA = [ + { + min: -2.54173091337051, + first_quartile: -1.03358030635935, + median: 0.483801733963266, + mean: -0.517398199667356, + third_quartile: -0.0800759845652829, + max: 2.32290808871289, + tissue: TISSUES[0], + }, + { + min: -2.44077907413711, + first_quartile: -0.592671867557559, + median: 0.013739530502129, + mean: -0.0324143336865, + third_quartile: 0.49577213202412, + max: 2.23019575245731, + tissue: TISSUES[1], + }, + { + min: -5.03189866356294, + first_quartile: -1.02644563959975, + median: 0.176348063122062, + mean: -0.323038107200895, + third_quartile: 0.391874711168331, + max: 1.9113258251877, + tissue: TISSUES[2], + }, +]; + +describe('Component: BarChart - Median', () => { + let fixture: ComponentFixture; + let component: MedianBarChartComponent; + let element: HTMLElement; + + beforeEach(waitForAsync(async () => { + await TestBed.configureTestingModule({ + declarations: [MedianBarChartComponent], + imports: [RouterTestingModule], + providers: [HelperService], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MedianBarChartComponent); + component = fixture.componentInstance; + }); + + const setUp = ( + data: MedianExpression[] = FULL_MOCK_DATA, + xAxisLabel: string = XAXIS_LABEL, + yAxisLabel: string = YAXIS_LABEL, + ) => { + component.data = data; + component.xAxisLabel = xAxisLabel; + component.yAxisLabel = yAxisLabel; + + fixture.detectChanges(); + element = fixture.nativeElement; + const chart = element.querySelector('svg g'); + return { chart }; + }; + + it('should create', () => { + setUp(); + expect(component).toBeTruthy(); + }); + + it('should not render the chart if there is no data', () => { + const { chart } = setUp([]); + + expect(component.data?.length).toEqual(0); + expect(element.querySelector('.chart-no-data')).toBeTruthy(); + + expect(chart).toBeFalsy(); + }); + + it('should not render the chart if all values are negative', () => { + const { chart } = setUp( + SMALL_MOCK_DATA.map((obj) => { + return { ...obj, median: -1 * obj.median }; + }), + ); + + expect(element.querySelector('.chart-no-data')).toBeTruthy(); + + expect(chart).toBeFalsy(); + }); + + it('should render the chart if there is positive data', () => { + const createChartSpy = spyOn(component, 'createChart').and.callThrough(); + const { chart } = setUp(); + + expect(component.data?.length).toEqual(FULL_MOCK_DATA.length); + expect(createChartSpy).toHaveBeenCalled(); + expect(chart).toBeTruthy(); + + expect(element.querySelector('.chart-no-data')).toBeFalsy(); + }); + + it('should render the chart axes', () => { + const { chart } = setUp(); + + expect(chart?.querySelector('.x-axis')).toBeTruthy(); + expect(chart?.querySelector('.y-axis')).toBeTruthy(); + }); + + it('should render the correct number of bars', () => { + const { chart } = setUp(); + + expect(chart?.querySelectorAll('rect').length).toEqual(FULL_MOCK_DATA.length); + }); + + it('should not render bars for negative values', () => { + const { chart } = setUp([ + { ...SMALL_MOCK_DATA[0], median: -0.01 }, + ...SMALL_MOCK_DATA.slice(1), + ]); + + expect(chart?.querySelectorAll('rect').length).toEqual(SMALL_MOCK_DATA.length - 1); + }); + + it('should render the bar labels', () => { + const { chart } = setUp(); + + expect(chart?.querySelectorAll('text.bar-labels').length).toEqual(FULL_MOCK_DATA.length); + }); + + it('should render the labels for both axes', () => { + const { chart } = setUp(); + + expect(chart?.querySelector('.x-axis-label')?.textContent).toEqual(XAXIS_LABEL); + expect(chart?.querySelector('.y-axis-label')?.textContent).toEqual(YAXIS_LABEL); + }); + + it('should render the meaningful expression threshold when values are larger than threshold', () => { + const { chart } = setUp(); + + expect(chart?.querySelector('.meaningful-expression-threshold-line')).toBeTruthy(); + }); + + it('should not render the meaningful expression threshold when all values are smaller than threshold', () => { + const { chart } = setUp(SMALL_MOCK_DATA); + + expect(chart?.querySelector('.meaningful-expression-threshold-line')).toBeFalsy(); + }); + + it('should alphabetize the x-axis values', () => { + setUp(SMALL_MOCK_DATA); + + const sortedTissues = TISSUES.sort(); + const xAxisTicks = element.querySelector('svg g .x-axis')?.querySelectorAll('.tick'); + xAxisTicks?.forEach((val, index) => { + expect(val.textContent).toEqual(sortedTissues[index]); + }); + }); + + it('should show and hide tooltip', () => { + setUp(); + + const tooltip = element.querySelector('#tooltip'); + expect(tooltip?.textContent).toBeFalsy(); + + const xAxisTick = element.querySelector('svg g .x-axis .tick'); + const mouseEnterEvent = new MouseEvent('mouseenter', { + bubbles: true, + cancelable: true, + }); + xAxisTick?.dispatchEvent(mouseEnterEvent); + + expect(tooltip?.style.display).toEqual('block'); + expect(tooltip?.textContent).toBeTruthy(); + + const mouseLeaveEvent = new MouseEvent('mouseleave', { + bubbles: true, + cancelable: true, + }); + xAxisTick?.dispatchEvent(mouseLeaveEvent); + + expect(tooltip?.style.display).toEqual('none'); + }); +}); diff --git a/libs/agora/charts/src/lib/median-barchart/median-barchart.component.ts b/libs/agora/charts/src/lib/median-barchart/median-barchart.component.ts new file mode 100644 index 0000000000..17b6f3e6b1 --- /dev/null +++ b/libs/agora/charts/src/lib/median-barchart/median-barchart.component.ts @@ -0,0 +1,301 @@ +/* eslint-disable @typescript-eslint/no-this-alias */ +import { + AfterViewInit, + Component, + ElementRef, + HostListener, + inject, + Input, + OnChanges, + OnDestroy, + SimpleChanges, + ViewChild, + ViewEncapsulation, +} from '@angular/core'; +import * as d3 from 'd3'; + +import { HelperService } from '@sagebionetworks/agora/services'; +import { MedianExpression } from '@sagebionetworks/agora/api-client-angular'; + +@Component({ + selector: 'agora-median-barchart', + standalone: true, + providers: [HelperService], + templateUrl: './median-barchart.component.html', + styleUrls: ['./median-barchart.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class MedianBarChartComponent implements OnChanges, AfterViewInit, OnDestroy { + helperService = inject(HelperService); + + private chartInitialized = false; + private tooltipInitialized = false; + private _data: MedianExpression[] = []; + private chart!: d3.Selection; + private tooltip!: d3.Selection; + private MEANINGFUL_EXPRESSION_THRESHOLD = Math.log2(5); + private maxValueY = -1; + + private MIN_CHART_WIDTH = 500; + private CHART_HEIGHT = 350; + private chartMargin = { top: 20, right: 20, bottom: 65, left: 65 }; + private chartXScale!: d3.ScaleBand; + private chartXAxisDrawn!: d3.Selection; + private chartXAxisLabel!: d3.Selection; + private chartBars!: d3.Selection; + private chartScoreLabels!: d3.Selection; + private chartThresholdLine!: d3.Selection; + private shouldShowThresholdLine = false; + + private resizeTimer: ReturnType | number = 0; + + get data() { + return this._data; + } + @Input() set data(data: MedianExpression[]) { + this._data = data + .filter((el) => el.median && el.median > 0) + .sort((a, b) => a.tissue.localeCompare(b.tissue)); + this.maxValueY = d3.max(this._data, (d) => d.median) || 0; + this.shouldShowThresholdLine = this.MEANINGFUL_EXPRESSION_THRESHOLD <= this.maxValueY; + } + + @Input() shouldResize = true; + @Input() xAxisLabel = ''; + @Input() yAxisLabel = 'LOG2 CPM'; + + @ViewChild('medianBarChartContainer') medianBarChartContainer: ElementRef = + {} as ElementRef; + @ViewChild('chart') chartRef: ElementRef = {} as ElementRef; + @ViewChild('tooltip') tooltipRef: ElementRef = {} as ElementRef; + + @HostListener('window:resize', ['$event.target']) + onResize() { + if (this.shouldResize && this.chartInitialized) { + const self = this; + const divSize = this.medianBarChartContainer.nativeElement.getBoundingClientRect().width; + clearTimeout(this.resizeTimer); + this.resizeTimer = setTimeout(() => { + self.resizeChart(divSize); + }, 100); + } + } + + ngOnChanges(changes: SimpleChanges): void { + if ( + (changes['_data'] && !changes['_data'].firstChange) || + (changes['xAxisLabel'] && !changes['xAxisLabel'].firstChange) || + (changes['yAxisLabel'] && !changes['yAxisLabel'].firstChange) + ) { + if (this._data.length === 0) { + this.clearChart(); + this.hideChart(); + } else { + this.clearChart(); + this.showChart(); + this.createChart(); + } + } + } + + ngAfterViewInit(): void { + if (this._data.length === 0) this.hideChart(); + else this.createChart(); + } + + ngOnDestroy(): void { + this.destroyChart(); + } + + clearChart() { + const svg = d3.select(this.chartRef.nativeElement); + svg.selectAll('*').remove(); + } + + hideChart() { + const svg = d3.select(this.chartRef.nativeElement); + svg.style('display', 'none'); + } + + showChart() { + const svg = d3.select(this.chartRef.nativeElement); + svg.style('display', 'block'); + } + + destroyChart() { + if (this.chartInitialized) this.chart.remove(); + if (this.tooltipInitialized) this.tooltip.remove(); + } + + showTooltip(text: string, x: number, y: number): void { + this.tooltip = d3 + .select(this.tooltipRef.nativeElement) + .style('left', `${x}px`) + .style('top', `${y}px`) + .style('display', 'block') + .html(text); + this.tooltipInitialized = true; + } + + hideTooltip() { + if (this.tooltipInitialized) { + this.tooltip.style('display', 'none'); + } + } + + getBarCenterX(tissue: string, xScale: d3.ScaleBand): number { + return (xScale(tissue) || 0) + xScale.bandwidth() / 2; + } + + // get the current width allotted to this chart or default + getChartBoundingWidth(): number { + return ( + d3.select(this.chartRef.nativeElement).node()?.getBoundingClientRect().width || + this.MIN_CHART_WIDTH + ); + } + + createChart() { + if (this._data.length > 0) { + const barColor = this.helperService.getColor('secondary'); + const width = this.getChartBoundingWidth(); + const height = this.CHART_HEIGHT; + const innerWidth = width - this.chartMargin.left - this.chartMargin.right; + const innerHeight = height - this.chartMargin.top - this.chartMargin.bottom; + + this.chart = d3 + .select(this.chartRef.nativeElement) + .attr('width', width) + .attr('height', height) + .append('g') + .attr('transform', `translate(${this.chartMargin.left}, ${this.chartMargin.top})`); + + // SCALES + this.chartXScale = d3 + .scaleBand() + .domain(this._data.map((d) => d.tissue)) + .range([0, innerWidth]) + .padding(0.2); + + const yScale = d3.scaleLinear().domain([0, this.maxValueY]).nice().range([innerHeight, 0]); + + // BARS + this.chartBars = this.chart + .selectAll('.medianbars') + .data(this._data) + .enter() + .append('rect') + .attr('class', 'medianbars') + .attr('x', (d) => this.chartXScale(d.tissue) as number) + .attr('y', (d) => yScale(d.median || 0)) + .attr('width', this.chartXScale.bandwidth()) + .attr('height', (d) => innerHeight - yScale(d.median || 0)) + .attr('fill', barColor); + + // SCORE LABELS + this.chartScoreLabels = this.chart + .selectAll('.bar-labels') + .data(this._data) + .enter() + .append('text') + .attr('class', 'bar-labels') + .attr('x', (d) => this.getBarCenterX(d.tissue, this.chartXScale)) + .attr('y', (d) => yScale(d.median || 0) - 5) + .text((d) => this.helperService.roundNumber(d.median || 0, 2)); + + // X-AXIS + const xAxis = d3.axisBottom(this.chartXScale); + this.chartXAxisDrawn = this.chart + .append('g') + .attr('class', 'x-axis') + .attr('transform', `translate(0, ${innerHeight})`) + .call(xAxis.tickSizeOuter(0)); + this.chartXAxisDrawn + .selectAll('.tick') + .on('mouseenter', (_, tissue) => { + const tooltipText = this.helperService.getGCTColumnTooltipText(tissue as string); + this.showTooltip( + tooltipText, + this.getBarCenterX(tissue as string, this.chartXScale) + this.chartMargin.left, + height - this.chartMargin.top, + ); + }) + .on('mouseleave', () => { + this.hideTooltip(); + }); + + // Y-AXIS + const yAxis = d3.axisLeft(yScale); + this.chart.append('g').attr('class', 'y-axis').call(yAxis); + + // X-AXIS LABEL + this.chartXAxisLabel = this.chart + .append('text') + .attr('class', 'x-axis-label') + .attr('x', innerWidth / 2) + .attr('y', innerHeight + this.chartMargin.bottom) + .attr('text-anchor', 'middle') + .text(this.xAxisLabel); + + // Y-AXIS LABEL + this.chart + .append('text') + .attr('class', 'y-axis-label') + .attr('x', -innerHeight / 2) + .attr('y', -this.chartMargin.left) + .attr('dy', '1em') + .attr('text-anchor', 'middle') + .attr('transform', 'rotate(-90)') + .text(this.yAxisLabel); + + // THRESHOLD LINE + if (this.shouldShowThresholdLine) { + this.chartThresholdLine = this.chart + .append('line') + .attr('class', 'meaningful-expression-threshold-line') + .attr('x1', 0) + .attr('x2', innerWidth) + .attr('y1', yScale(this.MEANINGFUL_EXPRESSION_THRESHOLD)) + .attr('y2', yScale(this.MEANINGFUL_EXPRESSION_THRESHOLD)) + .attr('stroke', 'red'); + } + + this.chartInitialized = true; + } + } + + resizeChart = (divSize: number): void => { + // calculate new width + const width = Math.max(divSize, this.MIN_CHART_WIDTH); + const innerWidth = width - this.chartMargin.left - this.chartMargin.right; + + // update chart size + this.chart.attr('width', width); + + // update chartXScale + this.chartXScale.range([0, innerWidth]); + + // update bars + this.chartBars + .transition() + .attr('x', (d) => this.chartXScale(d.tissue) as number) + .attr('width', this.chartXScale.bandwidth()); + + // update score labels + this.chartScoreLabels + .transition() + .attr('x', (d) => this.getBarCenterX(d.tissue, this.chartXScale)); + + // update drawn x-axis + const xAxis = d3.axisBottom(this.chartXScale); + this.chartXAxisDrawn.transition().call(xAxis.tickSizeOuter(0)); + + // update x-axis label + this.chartXAxisLabel.transition().attr('x', innerWidth / 2); + + // update threshold line + if (this.shouldShowThresholdLine) { + this.chartThresholdLine.transition().attr('x2', innerWidth); + } + }; +} diff --git a/libs/agora/charts/src/lib/median-chart/median-chart.component.html b/libs/agora/charts/src/lib/median-chart/median-chart.component.html new file mode 100644 index 0000000000..065cf5361f --- /dev/null +++ b/libs/agora/charts/src/lib/median-chart/median-chart.component.html @@ -0,0 +1,18 @@ +
+
+ @if (heading) { +

{{ heading }}

+ } +
+
+
+ @if (xAxisLabel) { +
{{ xAxisLabel }}
+ } + @if (!data.length) { +
+
No data is currently available.
+
+ } +
+
diff --git a/libs/agora/charts/src/lib/median-chart/median-chart.component.scss b/libs/agora/charts/src/lib/median-chart/median-chart.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/agora/charts/src/lib/median-chart/median-chart.component.spec.ts.off b/libs/agora/charts/src/lib/median-chart/median-chart.component.spec.ts.off new file mode 100644 index 0000000000..3cb7f57ef2 --- /dev/null +++ b/libs/agora/charts/src/lib/median-chart/median-chart.component.spec.ts.off @@ -0,0 +1,64 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { MedianChartComponent } from './median-chart.component'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { geneMock1 } from '@sagebionetworks/agora/testing'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Chart - Median', () => { + let fixture: ComponentFixture; + let component: MedianChartComponent; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [MedianChartComponent], + imports: [RouterTestingModule], + providers: [HelperService], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(MedianChartComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + element = fixture.nativeElement; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display message if not data', () => { + expect(component.data?.length).toEqual(0); + expect(element.querySelector('.chart-no-data')).toBeTruthy(); + }); + + it('should render the chart', () => { + const idSpy = spyOn(component, 'initData').and.callThrough(); + const icSpy = spyOn(component, 'initChart').and.callThrough(); + + component.data = geneMock1.median_expression; + fixture.detectChanges(); + + expect(idSpy).toHaveBeenCalled(); + expect(icSpy).toHaveBeenCalled(); + expect(element.querySelector('svg')).toBeTruthy(); + }); + + it('should have tooltips', () => { + component.data = geneMock1.median_expression; + component.addXAxisTooltips(); + fixture.detectChanges(); + expect(document.querySelector('.median-chart-x-axis-tooltip')).toBeTruthy(); + }); +}); diff --git a/libs/agora/charts/src/lib/median-chart/median-chart.component.ts b/libs/agora/charts/src/lib/median-chart/median-chart.component.ts new file mode 100644 index 0000000000..5c295ec947 --- /dev/null +++ b/libs/agora/charts/src/lib/median-chart/median-chart.component.ts @@ -0,0 +1,153 @@ +/* eslint-disable @typescript-eslint/no-this-alias */ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { Component, inject, Input } from '@angular/core'; +import * as d3 from 'd3'; +import * as dc from 'dc'; +import crossfilter from 'crossfilter2'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { MedianExpression } from '../../../../models'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { BaseChartComponent } from '../base-chart/base-chart.component'; + +// -------------------------------------------------------------------------- // +// Component +// -------------------------------------------------------------------------- // +@Component({ + selector: 'agora-median-chart', + standalone: true, + templateUrl: './median-chart.component.html', + styleUrls: ['./median-chart.component.scss'], +}) +export class MedianChartComponent extends BaseChartComponent { + helperService = inject(HelperService); + + _data: MedianExpression[] = []; + get data() { + return this._data; + } + @Input() set data(data) { + this._data = data; + this.init(); + } + + @Input() xAxisLabel = ''; + @Input() yAxisLabel = 'LOG2 CPM'; + + override name = 'median-chart'; + dimension: any; + group: any; + + override init() { + if (!this._data?.length || !this.chartContainer.nativeElement) { + return; + } + + this.initData(); + this.initChart(); + + this.isInitialized = true; + } + + initData() { + const ndx = crossfilter(this.data); + this.dimension = ndx.dimension((d: any) => d.tissue); + this.group = this.dimension + .group() + .reduceSum((d: any) => this.helperService.getSignificantFigures(d.median)); + } + + initChart() { + const self = this; + + // Chart + this.chart = dc + .barChart(this.chartContainer.nativeElement) + .dimension(this.dimension) + .group(this.group) + .brushOn(false); + + // X axis + this.chart.x(d3.scaleBand()).xUnits(dc.units.ordinal).xAxis(); + // .tickSizeOuter([0]); + + // Y axis + this.chart + .y(d3.scaleLinear().domain([0, this.group.top(1)[0]?.value || 0])) + .yAxisLabel(this.yAxisLabel) + .yAxis() + .ticks(3); + //.tickSizeOuter([0]); + + // Colors + this.chart.colors([this.helperService.getColor('secondary')]); + + // Spacing + this.chart + .margins({ + left: 70, + right: 0, + bottom: 30, + top: 50, + }) + .barPadding(0.5); + + // Misc + this.chart.renderLabel(true).turnOnControls(false).renderTitle(false); + + // On render + this.chart.on('renderlet', (chart: any) => { + if (chart) { + const yDomainLength = Math.abs(chart.y().domain()[1] - chart.y().domain()[0]); + chart.selectAll('rect').each((el: any, i: number, tree: any) => { + if (el && el.y <= 0) { + tree[i].setAttribute('height', 0); + } + }); + chart.selectAll('rect').attr('pointer-events', 'none'); + chart.selectAll('text').each((el: any, i: number, tree: any) => { + if (el && el['data'] && el['data'].value < 0) { + el['data'].value = ''; + el.y = ''; + tree[i].innerHTML = ''; + } + }); + // const svgEl = (chart.selectAll('g.axis.y').node() as SVGGraphicsElement); + const mult = chart.effectiveHeight() / yDomainLength; + const lefty = 0; + const righty = 0; // use real statistics here! + const extradata = [ + { x: chart.x().range()[0], y: chart.y()(lefty) }, + { x: chart.x().range()[1], y: chart.y()(righty) }, + ]; + const line = d3 + .line() + .x((d: any) => d.x) + .y(() => Math.abs(chart.y().domain()[1] - Math.log2(5)) * mult); + const chartBody = chart.select('g.chart-body'); + let path = chartBody.selectAll('path.extra').data([extradata]); + path = path + .enter() + .append('path') + .attr('class', 'extra') + .attr('stroke', 'red') + .attr('id', 'extra-line') + .merge(path); + path.attr('d', line); + + self.addXAxisTooltips(); + } + }); + + this.chart.filter = () => ''; + this.chart.render(); + } + + override getXAxisTooltipText(text: string) { + return this.helperService.getGCTColumnTooltipText(text); + } +} diff --git a/libs/agora/charts/src/lib/network-chart/network-chart.component.html b/libs/agora/charts/src/lib/network-chart/network-chart.component.html new file mode 100644 index 0000000000..0ad3dbcbc6 --- /dev/null +++ b/libs/agora/charts/src/lib/network-chart/network-chart.component.html @@ -0,0 +1,12 @@ +
+
+
+
+
+ @if (!this._data?.nodes?.length) { +
+
No data is currently available.
+
+ } +
+
diff --git a/libs/agora/charts/src/lib/network-chart/network-chart.component.scss b/libs/agora/charts/src/lib/network-chart/network-chart.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/agora/charts/src/lib/network-chart/network-chart.component.spec.ts.off b/libs/agora/charts/src/lib/network-chart/network-chart.component.spec.ts.off new file mode 100644 index 0000000000..55fd3b27bd --- /dev/null +++ b/libs/agora/charts/src/lib/network-chart/network-chart.component.spec.ts.off @@ -0,0 +1,55 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { NetworkChartComponent } from './network-chart.component'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { networkChartDataMock } from '@sagebionetworks/agora/testing'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Chart - Network', () => { + let fixture: ComponentFixture; + let component: NetworkChartComponent; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [NetworkChartComponent], + imports: [RouterTestingModule], + providers: [HelperService], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(NetworkChartComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + element = fixture.nativeElement; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display message if not data', () => { + expect(component.data).not.toBeDefined(); + expect(element.querySelector('.chart-no-data')).toBeTruthy(); + }); + + it('should render the chart', () => { + const icSpy = spyOn(component, 'initChart').and.callThrough(); + + component.data = networkChartDataMock; + fixture.detectChanges(); + + expect(icSpy).toHaveBeenCalled(); + expect(element.querySelector('svg')).toBeTruthy(); + }); +}); diff --git a/libs/agora/charts/src/lib/network-chart/network-chart.component.ts b/libs/agora/charts/src/lib/network-chart/network-chart.component.ts new file mode 100644 index 0000000000..e6d26be4bd --- /dev/null +++ b/libs/agora/charts/src/lib/network-chart/network-chart.component.ts @@ -0,0 +1,320 @@ +/* eslint-disable @typescript-eslint/no-this-alias */ +/* eslint-disable @angular-eslint/no-output-on-prefix */ +import { + Component, + Input, + Output, + EventEmitter, + ViewChild, + ElementRef, + inject, +} from '@angular/core'; +import * as d3 from 'd3'; + +import { hexagonSymbol } from './symbol-hexagon'; +import { NetworkChartNode, NetworkChartLink, NetworkChartData } from '../../../../models'; +import { HelperService } from '@sagebionetworks/agora/services'; + +@Component({ + selector: 'agora-network-chart', + standalone: true, + templateUrl: './network-chart.component.html', + styleUrls: ['./network-chart.component.scss'], +}) +export class NetworkChartComponent { + helperService = inject(HelperService); + + _data: NetworkChartData | undefined; + get data(): NetworkChartData | undefined { + return this._data; + } + @Input() set data(data: NetworkChartData | undefined) { + this._data = data; + this.init(); + } + + _selectedFilter = 0; + get selectedFilter(): number { + return this._selectedFilter; + } + @Input() set selectedFilter(n: number) { + this._selectedFilter = n; + this.filter(); + } + + width = 800; + height = 800; + + group: any; + nodes: any; + links: any; + mainNode: any; + texts: any; + simulation: any; + + selectedNode: NetworkChartNode | undefined; + zoomHandler: any; + + chart: any; + isInitialized = false; + + resizeTimer: ReturnType | number = 0; + + @ViewChild('chartContainer', { static: true }) chartContainer: ElementRef = {} as ElementRef; + + @Output() onNodeClick: EventEmitter = new EventEmitter(); + + init() { + if (!this._data?.nodes?.length || !this.chartContainer?.nativeElement) { + return; + } + + this.initChart(); + this.isInitialized = true; + } + + initChart() { + if (!this._data?.nodes?.length) { + return; + } + + this.width = this.chartContainer.nativeElement.parentElement.offsetWidth - 30; + this.height = 400 + 700 * (this._data.nodes.length / 100); + + this.chart = d3 + .select(this.chartContainer.nativeElement) + .append('svg') + .attr('width', this.width) + .attr('height', this.height); + + this.group = this.chart.append('g'); + + this.initZoom(); + this.initLinks(); + this.initNodes(); + this.initSimulation(); + + this.filter(); + } + + initZoom() { + this.zoomHandler = d3 + .zoom() + // Don't allow the zoomed area to be bigger than the viewport. + .scaleExtent([1, 1]) + .translateExtent([ + [-200, -300], + [this.width + 200, this.height + 300], + ]) + .on('zoom', (e) => { + // Zoom functions, this in this context is the svg + this.group.attr('transform', e.transform); + }); + this.zoomHandler(this.chart); + this.chart.call(this.zoomHandler); + } + + initLinks() { + if (!this._data?.links?.length) { + return; + } + + this.links = this.group + .selectAll('line') + .data(this._data.links) + .enter() + .append('line') + .attr('stroke-width', 2); + + this.updateLinkClasses(); + } + + getLinkClasses(link: NetworkChartLink) { + const classes = ['network-chart-link']; + + if (link.class) { + classes.push(link.class); + } + + return classes.join(' '); + } + + updateLinkClasses() { + this.links.attr('class', (link: NetworkChartLink) => { + return this.getLinkClasses(link); + }); + } + + initNodes() { + if (!this._data?.nodes?.length) { + return; + } + + this.selectedNode = this._data.nodes[0]; + + this.mainNode = this.group + .selectAll('path') + .data([this._data.nodes[0]]) + .enter() + .append('path') + .attr('d', d3.symbol().size(900).type(hexagonSymbol)) + .on('click', (e: any, NetworkNode: any) => { + this.selectedNode = NetworkNode; + this.updateNodesClasses(); + this.onNodeClick.emit(NetworkNode); + }); + + this.nodes = this.group + .selectAll('circle') + .data(this._data.nodes.slice(1)) + .enter() + .append('circle') + .attr('r', 10) + .on('click', (e: any, node: any) => { + this.selectedNode = node; + this.updateNodesClasses(); + this.onNodeClick.emit(node); + }); + + this.texts = this.group + .selectAll('text') + .data(this._data.nodes) + .enter() + .append('text') + .text((node: any) => node?.label) + .attr('font-size', 12); + + this.updateNodesClasses(); + } + + getNodeClasses(node: NetworkChartNode) { + const classes = ['network-chart-node']; + + if (node.id === this._data?.nodes[0].id) { + classes.push('main'); + } + + if (node.id === this.selectedNode?.id) { + classes.push('selected'); + } + + if (node.class) { + classes.push(node.class); + } + + return classes.join(' '); + } + + updateNodesClasses() { + this.mainNode.attr('class', (node: NetworkChartNode) => { + return this.getNodeClasses(node); + }); + + this.nodes.attr('class', (node: NetworkChartNode) => { + return this.getNodeClasses(node); + }); + } + + initSimulation() { + if (!this._data?.nodes?.length) { + return; + } + + this.simulation = d3 + .forceSimulation(this._data.nodes) + .force( + 'NetworkLink', + d3 + .forceLink() + .id(function (d: any) { + return d.id; + }) + .links(this._data.links), + ) + .force('charge', d3.forceManyBody().strength(-450)) + .force('center', d3.forceCenter(this.width / 2, this.height / 2)) + .force( + 'collision', + d3.forceCollide().radius(() => 35), + ) + .alphaDecay(0.5) + .on('end', () => { + this.refreshPositions(); + }); + } + + refreshPositions() { + this.links + .attr('x1', function (d: any) { + return d.source.x; + }) + .attr('y1', function (d: any) { + return d.source.y; + }) + .attr('x2', function (d: any) { + return d.target.x; + }) + .attr('y2', function (d: any) { + return d.target.y; + }); + + this.nodes + .attr('cx', function (d: any) { + return d.x; + }) + .attr('cy', function (d: any) { + return d.y; + }); + + this.mainNode.style('transform', function (d: any) { + return 'translate(' + d.x + 'px, ' + d.y + 'px) rotate(30deg)'; + }); + + this.texts + .attr('x', function (d: any) { + return d.x; + }) + .attr('dx', (d: any) => { + // A font size of 12 has 16 pixels per letter, so we pick + // half the word and make a negative dx. The anchor is in + // the middle so we half the result again + return (-d.label.length * 16) / 2 / 2; + }) + .attr('y', function (d: any) { + return d.y + 30; + }); + } + + filter() { + const hiddenNodes: string[] = []; + + this.nodes.attr('display', (node: NetworkChartNode) => { + if ((node.value || 0) < this._selectedFilter) { + hiddenNodes.push(node.id); + return 'none'; + } + return 'block'; + }); + + this.texts.attr('display', (node: NetworkChartNode) => + (node.value || 0) < this._selectedFilter ? 'none' : 'block', + ); + + this.links.attr('display', (link: NetworkChartLink) => + hiddenNodes.includes(link.source.id) || hiddenNodes.includes(link.target.id) + ? 'none' + : 'block', + ); + } + + onResize() { + const self = this; + clearTimeout(this.resizeTimer); + this.resizeTimer = setTimeout(() => { + self.width = self.chartContainer.nativeElement.parentElement.offsetWidth - 30; + self.height = 400 + 700 * ((self._data?.nodes?.length || 0) / 100); + self.chart.attr('width', self.width).attr('height', self.height); + self.group.attr('transform', 'translate(0, 0)'); + }, 100); + } +} diff --git a/libs/agora/charts/src/lib/network-chart/symbol-hexagon.ts b/libs/agora/charts/src/lib/network-chart/symbol-hexagon.ts new file mode 100644 index 0000000000..7aa7a59102 --- /dev/null +++ b/libs/agora/charts/src/lib/network-chart/symbol-hexagon.ts @@ -0,0 +1,38 @@ +import * as d3 from 'd3'; + +const a = Math.pow(3, 0.25); +// Given an area, compute the side length of a hexagon with that area. +function sideLength(area: number) { + return a * Math.sqrt(2 * (area / 9)); +} + +// Generate the 6 vertices of a unit hexagon. +const basePoints = d3 + .range(6) + .map((p) => (Math.PI / 3) * p) + .map((p) => ({ + x: Math.cos(p), + y: Math.sin(p), + })); + +export const hexagonSymbol = { + draw: function (context: any, size: number) { + // Scale the unit hexagon's vertices by the desired size of the hexagon. + const len = sideLength(size); + const points = basePoints.map(({ x, y }) => ({ + x: x * len, + y: y * len, + })); + + // Move to the first vertex of the hexagon. + const { x, y } = points[0]; + context.moveTo(x, y); + // Line-to the remaining vertices of the hexagon. + for (let p = 1; p < points.length; p++) { + const { x, y } = points[p]; + context.lineTo(x, y); + } + // Close the path to connect the last vertex back to the first. + context.closePath(); + }, +}; diff --git a/libs/agora/charts/src/lib/row-chart/row-chart.component.html b/libs/agora/charts/src/lib/row-chart/row-chart.component.html new file mode 100644 index 0000000000..14aaa85857 --- /dev/null +++ b/libs/agora/charts/src/lib/row-chart/row-chart.component.html @@ -0,0 +1,19 @@ +
+
+ @if (heading) { +

{{ heading }}

+ } +
+
+
+
+ @if (xAxisLabel) { +
{{ xAxisLabel }}
+ } + @if (!this._data.length) { +
+
No data is currently available.
+
+ } +
+
diff --git a/libs/agora/charts/src/lib/row-chart/row-chart.component.scss b/libs/agora/charts/src/lib/row-chart/row-chart.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/agora/charts/src/lib/row-chart/row-chart.component.spec.ts.off b/libs/agora/charts/src/lib/row-chart/row-chart.component.spec.ts.off new file mode 100644 index 0000000000..c30d24acc8 --- /dev/null +++ b/libs/agora/charts/src/lib/row-chart/row-chart.component.spec.ts.off @@ -0,0 +1,64 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { RowChartComponent } from './row-chart.component'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { rowChartItemMock } from '@sagebionetworks/agora/testing'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Chart - Row', () => { + let fixture: ComponentFixture; + let component: RowChartComponent; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [RowChartComponent], + imports: [RouterTestingModule], + providers: [HelperService], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(RowChartComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + element = fixture.nativeElement; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display message if not data', () => { + expect(component.data?.length).toEqual(0); + expect(element.querySelector('.chart-no-data')).toBeTruthy(); + }); + + it('should render the chart', () => { + const idSpy = spyOn(component, 'initData').and.callThrough(); + const icSpy = spyOn(component, 'initChart').and.callThrough(); + + component.data = [rowChartItemMock]; + fixture.detectChanges(); + + expect(idSpy).toHaveBeenCalled(); + expect(icSpy).toHaveBeenCalled(); + expect(element.querySelector('svg')).toBeTruthy(); + }); + + it('should have tooltips', () => { + component.data = [rowChartItemMock]; + fixture.detectChanges(); + expect(document.querySelector('.row-chart-x-axis-tooltip')).toBeTruthy(); + expect(document.querySelector('.row-chart-value-tooltip')).toBeTruthy(); + }); +}); diff --git a/libs/agora/charts/src/lib/row-chart/row-chart.component.ts b/libs/agora/charts/src/lib/row-chart/row-chart.component.ts new file mode 100644 index 0000000000..19dd424b8c --- /dev/null +++ b/libs/agora/charts/src/lib/row-chart/row-chart.component.ts @@ -0,0 +1,568 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +/* eslint-disable @typescript-eslint/no-this-alias */ +import { Component, ViewChild, ElementRef, Input, inject } from '@angular/core'; + +import * as d3 from 'd3'; +import * as dc from 'dc'; + +import { rowChartItem } from '../../../../models'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { BaseChartComponent } from '../base-chart/base-chart.component'; + +// Using a d3 v4 function to get all nodes +d3.selection.prototype['nodes'] = function () { + const nodes = new Array(this.size()); + let i = -1; + this.each(function (this: any) { + nodes[++i] = this; + }); + return nodes; +}; + +@Component({ + selector: 'agora-row-chart', + standalone: true, + providers: [HelperService], + templateUrl: './row-chart.component.html', + styleUrls: ['./row-chart.component.scss'], +}) +export class RowChartComponent extends BaseChartComponent { + helperService = inject(HelperService); + + _data: rowChartItem[] = []; + get data(): rowChartItem[] { + return this._data; + } + @Input() set data(data: rowChartItem[]) { + this._data = data; + this.init(); + } + + @Input() xAxisLabel = ''; + @Input() paddingLR = 15; + @Input() paddingUD = 0; + + override name = 'row-chart'; + group: any; + dimension: any; + + @ViewChild('leftAxis', { static: true }) leftAxis: ElementRef = {} as ElementRef; + + max = -Infinity; + canDisplay = false; + canResize = false; + colors: string[] = [this.helperService.getColor('secondary')]; + + override init() { + if (!this._data?.length || !this.chartContainer?.nativeElement) { + return; + } + + this.initData(); + this.initChart(); + + this.isInitialized = true; + } + + initData() { + const self = this; + this.group = { + all: () => { + return self._data; + }, + order: () => {}, + top: () => {}, + }; + + this.dimension = { + filter: () => {}, + filterAll: () => {}, + }; + } + + initChart() { + const self = this; + + this.chart = dc.rowChart(this.chartContainer.nativeElement); + + this.chart.group(this.group).dimension(this.dimension); + + //this.chart.xAxis().tickSizeOuter([0]); + + this.chart + .gap(4) + .title(false) + .valueAccessor((d: any) => { + return self.helperService.getSignificantFigures(+d.value.logfc, 3); + }) + .keyAccessor((d: any) => { + return d.key[0]; + }) + .label((d: any) => { + return d.key[0]; + }) + .on('preRender', function (chart: any) { + self.max = -Infinity; + self.updateXDomain(); + if (self.max !== -Infinity) { + self.max *= 1.1; + chart.x( + d3 + .scaleLinear() + .range([0, self.chartContainer.nativeElement.offsetWidth - 40]) + .domain([-self.max, self.max]), + ); + chart.xAxis().scale(chart.x()); + } + }) + .on('preRedraw', function (chart: any) { + self.max = -Infinity; + self.updateXDomain(); + if (self.max !== -Infinity) { + self.max *= 1.1; + chart.x( + d3 + .scaleLinear() + .range([0, self.chartContainer.nativeElement.offsetWidth - 40]) + .domain([-self.max, self.max]), + ); + chart.xAxis().scale(chart.x()); + } + }) + .on('renderlet', function (chart: any) { + // Copy all vertical texts to another div, so they don't get hidden by + // the row chart svg after being translated + self.moveTextToElement(chart, self.leftAxis.nativeElement, 9); + dc.events.trigger(function () { + self.updateChartExtras(chart); + }); + }) + //.othersGrouper(null) + .ordinalColors(self.colors) + + .transitionDuration(0); + + this.chart.render(); + } + + addXLabel(chart: dc.RowChart, text: string, svg?: any, width = 0, height = 0) { + const textSelection = svg || chart.svg(); + if (textSelection !== null) { + const label = textSelection.select('.x-axis-label'); + + if (!label.node()) { + textSelection + .append('text') + .attr('class', 'x-axis-label') + .attr('text-anchor', 'middle') + .attr('x', width / 2) + .attr('y', height - 10) + .text(text); + } else { + label.attr('x', width / 2).attr('y', height - 10); + } + + this.adjustXLabel(chart, textSelection, width, height); + } + } + + adjustXLabel(chart: dc.RowChart, sel: any, width = 0, height = 0) { + const svgEl = (sel.node() || chart.svg()) as SVGGraphicsElement; + if (svgEl !== null) { + const textDims = svgEl.getBBox(); + + // Dynamically adjust positioning after reading text dimension from DOM + // The main svg gets translated by (30, 10) and the flex row has a margin + // of 15 pixels. We subtract them from the svg size, get the middle point + // then add back the left translate to get the correct center + sel.attr('x', (width - 45) / 2 + 30).attr('y', height - Math.ceil(textDims.height) / 2); + } + } + + updateChartExtras(chart: dc.RowChart) { + const self = this; + let rectHeight = !chart.select('g.row rect').empty() + ? parseInt(chart.select('g.row rect').attr('height'), 10) + : 52; + rectHeight = isNaN(rectHeight) ? 52 : rectHeight; + const squareSize = 18; + const lineWidth = 60; + + // Insert a line for each row of the chart + self.insertLinesInRows(chart); + + // Insert the texts for each row of the chart. At first we need to add + // empty texts so that the rowChart redraw does not move out confidence + // texts around + self.insertTextsInRows(chart); + self.insertTextsInRows(chart, 'confidence-text-left'); + self.insertTextsInRows(chart, 'confidence-text-right'); + + // Finally redraw the lines in each row + self.drawLines(chart, rectHeight / 2); + + // Change the row rectangles into small circles, this happens on + // every render or redraw + self.rectToCircles(chart, squareSize, rectHeight); + + // Only show the 0, min and max values on the xAxis ticks + self.updateXTicks(chart); + + // Redraw confidence text next to the lines in each row + self.renderConfidenceTexts(chart, rectHeight / 2, lineWidth, true); + self.renderConfidenceTexts(chart, rectHeight / 2, lineWidth); + } + + updateXDomain() { + //Draw the horizontal lines + this._data.forEach((g: any) => { + if (Math.abs(+g.ci_l) > this.max) { + this.max = Math.abs(+g.ci_l); + } + if (Math.abs(+g.ci_r) > this.max) { + this.max = Math.abs(+g.ci_r); + } + }); + } + + updateXTicks(chart: dc.RowChart) { + const allTicks = chart.selectAll('g.axis g.tick'); + allTicks.each(function (this: any, i: any) { + const el = d3.select(this); + let value = parseFloat(el.select('text').text()); + // Handle UTF-8 characters, if text is not a number, + // then replace all non-numeric values with the empty string + if (isNaN(value)) { + value = parseFloat( + '-' + + el + .select('text') + .text() + .replace(/[^,.0-9]/g, ''), + ); + } + + if (i > 0 && i < allTicks.size() - 1) { + if (value) { + el.selectAll('line').style('opacity', 0); + el.select('text').style('opacity', 0); + } + } else if (value) { + el.selectAll('line').style('opacity', 0); + el.select('text').style('opacity', 1); + } + }); + } + + adjustTextToElement(el: HTMLElement) { + d3.select(el) + .selectAll('g.textGroup text') + .each(function () { + const pRigth = isNaN(parseInt(d3.select(el).style('padding-right'), 10)) + ? 15 + : parseInt(d3.select(el).style('padding-right'), 10); + const transfString = d3.select(this).attr('transform'); + const translateString = transfString.substring( + transfString.indexOf('(') + 1, + transfString.indexOf(')'), + ); + const translate = + translateString.split(',').length > 1 + ? translateString.split(',') + : translateString.split(' '); + const svgWidth = isNaN(parseFloat(d3.select(el).select('svg').style('width'))) + ? 450 + : parseFloat(d3.select(el).select('svg').style('width')); + const transfX = svgWidth - pRigth; + const ftransfx = isNaN(transfX) ? 0.0 : transfX; + d3.select(this).attr('transform', () => { + return 'translate(' + ftransfx + ',' + parseFloat(translate[1]) + ')'; + }); + }); + } + + // Moves all text in textGroups to a new HTML element + moveTextToElement(chart: dc.RowChart, el: HTMLElement, vSpacing = 0) { + const self = this; + const container = d3.select(el).html(''); + const svg = container.append('svg'); + const group = svg.append('g').attr('class', 'textGroup'); + const texts: any = chart.selectAll('g.row > text'); + const tooltip = this.getTooltip('x-axis', 'chart-x-axis-tooltip row-chart-x-axis-tooltip'); + + texts.each(function (this: any) { + this.style.display = 'none'; + group + .append('text') + .html(this.innerHTML) + .attr('x', 0) + .attr('y', this.getAttribute('y')) + .attr('dy', this.getAttribute('dy')); + }); + + // Move the text to the correct position in the new svg + const svgEl = chart.select('g.axis g.tick line.grid-line').node() as SVGGraphicsElement; + + // Need this condition when reloading in Edge + if (svgEl) { + const step = svgEl.getBBox().height / texts.nodes().length; + + svg.selectAll('text').each(function (d, i) { + const currentStep = step * i; + const transfX = + parseFloat(svg.style('width')) - parseFloat(d3.select(el).style('padding-right')); + const ftransfx = isNaN(transfX) ? 0 : transfX; + + const tickText = d3.select(this); + + tickText + .attr('text-anchor', 'end') + .attr('transform', () => { + return 'translate(' + ftransfx + ',' + (currentStep + vSpacing) + ')'; + }) + .on('mouseover', function () { + const tickTextNode = tickText.node() as HTMLElement; + const tooltipNode = tooltip.node() as HTMLElement; + + if (!tooltipNode || !tickTextNode) { + return; + } + + tooltip.html(self.helperService.getGCTColumnTooltipText(tickText.text())); + + const tickTextRect = tickTextNode.getBoundingClientRect() || null; + + tooltip + .style( + 'top', + // Position at the bottom on the label + 15px + `${window.pageYOffset + tickTextRect.top - 15}px`, + ) + .style( + 'left', + // Left position of the tick line minus half the tooltip width to center. + `${tickTextRect.left + tickTextRect.width + 15}px`, + ); + + self.showTooltip('x-axis'); + }) + .on('mouseout', function () { + self.hideTooltip('x-axis'); + }); + }); + } + } + + insertLinesInRows(chart: dc.RowChart) { + chart.selectAll('g.row').each(function (this: any) { + const row = d3.select(this); + row.select('.hline').remove(); + row.insert('g').attr('class', 'hline').insert('line'); + }); + } + + insertTextsInRows(chart: dc.RowChart, textClass?: string) { + chart.selectAll('g.row').each(function (this: any) { + const row = d3.select(this); + row.select('.' + textClass).remove(); + row + .insert('g') + .attr('class', textClass ? textClass : 'confidence-text') + .insert('text'); + }); + } + + // Draw the lines through the chart rows and a vertical line at + // x = 0 + drawLines(chart: dc.RowChart, yPos: number) { + const self = this; + // Hide all vertical lines except one at x = 0 + chart.selectAll('g.axis g.tick').each(function (this: any) { + const el = d3.select(this); + if (parseFloat(el.select('text').text())) { + el.select('.grid-line').style('opacity', 0); + } else { + el.select('.grid-line') + .style('stroke', '#BCC0CA') + .style('stroke-width', '2px') + .style('opacity', 1); + } + }); + + // Draw the horizontal lines + chart + .selectAll('g.row g.hline line') + .attr('stroke-width', 1.5) + .attr('stroke', () => { + return self.colors[0]; + }) + // ES6 method shorthand for object literals + .attr('x1', (d) => { + const data: any = self._data.find((g: any) => { + return d.key[0] === g.tissue; + }); + + if (data) { + const val = chart.x()(data.ci_l); + return isNaN(val) ? 0.0 : val; + } else { + return 0.0; + } + }) + .attr('y1', () => { + return yPos; + }) + .attr('x2', (d) => { + const data: any = self._data.find((g: any) => { + return d.key[0] === g.tissue; + }); + + if (data) { + const val = chart.x()(data.ci_r); + return isNaN(val) ? 0.0 : val; + } else { + return 0.0; + } + }) + .attr('y2', () => { + return yPos; + }); + } + + // Renders the confidence interval values next to the horizontal lines + renderConfidenceTexts(chart: dc.RowChart, yPos: number, lineWidth: number, isNeg?: boolean) { + const self = this; + + // Draw the confidence texts + const posQueryString = isNeg ? '-left' : '-right'; + const queryString = 'g.row g.confidence-text' + posQueryString + ' text'; + chart.group(self.group); + chart + .selectAll(queryString) + // ES6 method shorthand for object literals + .attr('x', (d) => { + const data: any = self._data.find((g: any) => { + return d.key[0] === g.tissue; + }); + + let scaledX = 0; + // Two significant digits + let ciValue = 0.0; + // Move back 0.5 pixel for the dot and 5 for each number + let dotPixels = 0; + const mPixels = 5; + + if (data && data.ci_l && data.ci_r) { + dotPixels = data.ci_l.toPrecision(2).indexOf('.') !== -1 ? 0.5 : 0.0; + ciValue = isNeg ? data.ci_l : data.ci_r; + scaledX = chart.x()(ciValue); + } else { + dotPixels = 0.5; + ciValue = d.value.logfc; + scaledX = chart.x()(d.value.logfc) - lineWidth / 2; + } + + let val = 0.0; + if (ciValue) { + val = isNeg + ? scaledX - (ciValue.toPrecision(2).length * mPixels + dotPixels) + : scaledX + (ciValue.toPrecision(2).length * mPixels + dotPixels); + } + return isNaN(val) ? 0.0 : val; + }) + .attr('y', () => { + return yPos + 5; + }) + .attr('text-anchor', 'middle') + .text((d) => { + const data: any = self._data.find((g: any) => { + return d.key[0] === g.tissue; + }); + + let ciValue = '0.0'; + if (data) { + ciValue = isNeg ? data.ci_l.toPrecision(2) : data.ci_r.toPrecision(2); + } + + return ciValue; + }); + } + + // Compares the current value from a group to the gene expected value + compareAttributeValue(cValue: number, gValue: number): boolean { + return this.helperService.getSignificantFigures(cValue) === gValue; + } + + // Changes the chart row rects into squares of the square size + rectToCircles(chart: dc.RowChart, squareSize: number, rectHeight: number) { + const self = this; + + chart.selectAll('g.row rect').each(function (this: any) { + const circle = d3.select(this); + const tooltip = self.getTooltip('internal', 'chart-value-tooltip row-chart-value-tooltip'); + + circle + .attr('transform', function (d: any) { + const val = isNaN(chart.x()(+d.value.logfc) - squareSize / 2) + ? 0.0 + : chart.x()(+d.value.logfc) - squareSize / 2; + + return ( + 'translate(' + + // X translate + val + + ',' + + // Y translate + (rectHeight / 2 - squareSize / 2) + + ')' + ); + }) + .attr('width', squareSize) + .attr('height', squareSize) + .attr('rx', squareSize / 2) + .attr('ry', squareSize / 2); + + circle + .on('mouseover', function (event: any, d: any) { + const offset = self.helperService.getOffset(this); + const text = `Log Fold Change: ${self.helperService.getSignificantFigures( + +d.value.logfc, + 3, + )}`; + + tooltip + .style('left', (offset?.left || 0) + 'px') + .style('top', (offset?.top + 15 || 0) + 'px') + .html(text); + + self.showTooltip('internal'); + }) + .on('mouseout', function () { + self.hideTooltip('internal'); + }) + .on('click', () => {}); + }); + } + + displayChart(): any { + return { opacity: 1 }; + } + + override onResize() { + if (!this.chart) { + return; + } + + const self = this; + clearTimeout(this.resizeTimer); + this.resizeTimer = setTimeout(() => { + self.chart + .width(self.chartContainer.nativeElement.parentElement.offsetWidth - 100) + .height(self.chartContainer.nativeElement.offsetHeight); + if (self.chart.rescale) { + self.chart.rescale(); + } + self.chart.redraw(); + }, 100); + } +} diff --git a/libs/agora/charts/src/lib/score-barchart/score-barchart.component.html b/libs/agora/charts/src/lib/score-barchart/score-barchart.component.html new file mode 100644 index 0000000000..280f8e7918 --- /dev/null +++ b/libs/agora/charts/src/lib/score-barchart/score-barchart.component.html @@ -0,0 +1,9 @@ +
+
+ + @if (_score === null) { +
+
No data is currently available.
+
+ } +
diff --git a/libs/agora/charts/src/lib/score-barchart/score-barchart.component.scss b/libs/agora/charts/src/lib/score-barchart/score-barchart.component.scss new file mode 100644 index 0000000000..2d49c8c16a --- /dev/null +++ b/libs/agora/charts/src/lib/score-barchart/score-barchart.component.scss @@ -0,0 +1,24 @@ +#score-barchart { + .negative-bars { + &:hover { + fill: transparent; + cursor: pointer; + } + } + + .scorebars { + &:hover { + cursor: pointer; + } + } + + .bar-labels { + &:hover { + cursor: pointer; + + // AG-1113: prevent label text from moving in PrimeNG OverlayPanel + // see: https://sagebionetworks.jira.com/browse/AG-1113 + user-select: none; + } + } +} diff --git a/libs/agora/charts/src/lib/score-barchart/score-barchart.component.spec.ts.off b/libs/agora/charts/src/lib/score-barchart/score-barchart.component.spec.ts.off new file mode 100644 index 0000000000..34b80d1c77 --- /dev/null +++ b/libs/agora/charts/src/lib/score-barchart/score-barchart.component.spec.ts.off @@ -0,0 +1,104 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { ScoreBarChartComponent } from './'; +import { HelperService } from '../../../../core/services'; +import { distributionMock } from '../../../../testing'; +import { ScoreData } from '../../../../models'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Chart - Score', () => { + let fixture: ComponentFixture; + let component: ScoreBarChartComponent; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ScoreBarChartComponent], + imports: [RouterTestingModule], + providers: [HelperService], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(ScoreBarChartComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + element = fixture.nativeElement; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display no data message if score is null', () => { + const validValue1 = 0; + const validValue2 = 1.5; + + expect(component.score).toEqual(null); + expect(element.querySelector('.chart-no-data')).toBeTruthy(); + component.score = validValue1; + fixture.detectChanges(); + expect(element.querySelector('.chart-no-data')).toBeFalsy(); + component.score = validValue2; + fixture.detectChanges(); + expect(element.querySelector('.chart-no-data')).toBeFalsy(); + }); + + it('should render the chart', () => { + const data = distributionMock.overall_scores[0]; + + component.score = 1; + + data.bins.forEach((item, index: number) => { + if (!component._score) + return; + if (component._score >= item[0] && component._score < item[1]) { + component.scoreIndex = index; + } + }); + data.distribution.forEach((item, index: number) => { + component.chartData.push( + { + distribution: item, + bins: component.data?.bins[index] + } as ScoreData + ); + }); + + fixture.detectChanges(); + expect(element.querySelector('svg')).toBeTruthy(); + }); + + it('should set the correct scoreIndex to highlight the correct bar - middle', () => { + const data = distributionMock.overall_scores[0]; + component.score = 1; + component.setScoreIndex(data.bins); + fixture.detectChanges(); + expect(component.scoreIndex).toBe(3); + }); + + it('should set the correct scoreIndex to highlight the correct bar - first bar', () => { + const data = distributionMock.overall_scores[0]; + component.score = 0; + component.setScoreIndex(data.bins); + fixture.detectChanges(); + expect(component.scoreIndex).toBe(0); + }); + + it('should set the correct scoreIndex to highlight the correct bar - last bar', () => { + const data = distributionMock.overall_scores[0]; + component.score = 3; + component.setScoreIndex(data.bins); + fixture.detectChanges(); + expect(component.scoreIndex).toBe(9); + }); +}); diff --git a/libs/agora/charts/src/lib/score-barchart/score-barchart.component.ts b/libs/agora/charts/src/lib/score-barchart/score-barchart.component.ts new file mode 100644 index 0000000000..dbf54b7c54 --- /dev/null +++ b/libs/agora/charts/src/lib/score-barchart/score-barchart.component.ts @@ -0,0 +1,452 @@ +import { + AfterViewInit, + Component, + ElementRef, + HostListener, + inject, + Input, + OnChanges, + OnDestroy, + SimpleChanges, + ViewChild, + ViewEncapsulation, +} from '@angular/core'; +import * as d3 from 'd3'; + +import { HelperService } from '@sagebionetworks/agora/services'; +import { CommonModule } from '@angular/common'; +import { OverallScoresDistribution } from '@sagebionetworks/agora/api-client-angular'; +import { ScoreData } from '@sagebionetworks/agora/models'; + +@Component({ + selector: 'agora-score-barchart', + standalone: true, + imports: [CommonModule], + providers: [HelperService], + templateUrl: './score-barchart.component.html', + styleUrls: ['./score-barchart.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class ScoreBarChartComponent implements OnChanges, AfterViewInit, OnDestroy { + helperService = inject(HelperService); + + _score: number | null = null; + get score(): number | null { + return this._score; + } + @Input() set score(score: number | null) { + this._score = score; + } + + @Input() shouldResize = true; + @Input() barColor = '#8B8AD1'; + @Input() data: OverallScoresDistribution | undefined; + @Input() xAxisLabel = 'Gene score'; + @Input() yAxisLabel = 'Number of genes'; + + @ViewChild('scoreBarChartContainer') scoreBarChartContainer: ElementRef = + {} as ElementRef; + @ViewChild('chart') chartRef: ElementRef = {} as ElementRef; + @ViewChild('tooltip', { static: true }) tooltip: ElementRef = {} as ElementRef; + + initialized = false; + private MIN_CHART_WIDTH = 350; + private CHART_HEIGHT = 350; + private chart!: d3.Selection; + private chartMargin = { top: 20, right: 20, bottom: 40, left: 60 }; + private chartXScale!: d3.ScaleBand; + private chartNegativeBars!: d3.Selection; + private chartScoreBars!: d3.Selection; + private chartBarLabels!: d3.Selection; + private chartXAxisDrawn!: d3.Selection; + private chartXAxisLabel!: d3.Selection; + + private resizeTimer: ReturnType | number = 0; + + chartData: ScoreData[] = []; + scoreIndex = -1; + + @HostListener('window:resize', ['$event.target']) + onResize() { + console.log('sbc-onresize'); + if (this.shouldResize && this.initialized) { + const divSize = this.scoreBarChartContainer.nativeElement.getBoundingClientRect().width; + clearTimeout(this.resizeTimer); + this.resizeTimer = setTimeout(() => { + this.resizeChart(divSize); + }, 100); + } + } + + ngOnChanges(changes: SimpleChanges): void { + console.log('sbc-changes', changes); + + if ( + (changes['data'] && !changes['data'].firstChange) || + (changes['score'] && !changes['score'].firstChange) || + (changes['barColor'] && !changes['barColor'].firstChange) + ) { + if (this.score === null) { + this.clearChart(); + this.hideChart(); + } else { + this.clearChart(); + this.showChart(); + this.createChart(); + } + } + } + + ngAfterViewInit(): void { + console.log('ngAfterViewInit'); + + if (this.score === null) this.hideChart(); + else this.createChart(); + } + + ngOnDestroy(): void { + this.destroyChart(); + } + + setScoreIndex(bins: number[][]) { + bins.forEach((item, index: number) => { + if (this._score === null) return; + if (this._score >= item[0] && this._score < item[1]) { + this.scoreIndex = index; + } + if (index === bins.length - 1) { + // check border case where score is equal to the last bin upper bound + if (this._score === item[1]) this.scoreIndex = index; + } + }); + } + + initData() { + if (!this.data) return; + + this.chartData = []; + + this.setScoreIndex(this.data.bins); + + this.data.distribution.forEach((item, index: number) => { + this.chartData.push({ + distribution: item, + bins: this.data?.bins[index], + } as ScoreData); + }); + } + + clearChart() { + const svg = d3.select(this.chartRef.nativeElement); + svg.selectAll('*').remove(); + } + + hideChart() { + const svg = d3.select(this.chartRef.nativeElement); + svg.style('display', 'none'); + } + + showChart() { + const svg = d3.select(this.chartRef.nativeElement); + svg.style('display', 'block'); + } + + getChartBoundingWidth(): number { + return ( + d3.select(this.chartRef.nativeElement).node()?.getBoundingClientRect().width || + this.MIN_CHART_WIDTH + ); + } + + createChart() { + this.initData(); + if (this.chartData) { + const width = this.getChartBoundingWidth(); + const height = this.CHART_HEIGHT; + const innerWidth = width - this.chartMargin.left - this.chartMargin.right; + const innerHeight = height - this.chartMargin.top - this.chartMargin.bottom; + + const svg = (this.chart = d3 + .select(this.chartRef.nativeElement) + .attr('width', width) + .attr('height', height) + .append('g') + .attr('transform', `translate(${this.chartMargin.left}, ${this.chartMargin.top})`)); + + this.chartXScale = d3 + .scaleBand() + .domain(this.chartData.map((d) => d.bins[0].toString())) + .range([0, innerWidth]) + .padding(0.2); + + const yScale = d3 + .scaleLinear() + .domain([0, d3.max(this.chartData, (d) => d.distribution) as number]) + .range([innerHeight, 0]); + + // NEGATIVE SPACE ABOVE BARS + this.chartNegativeBars = svg + .selectAll('.negative-bars') + .data(this.chartData) + .enter() + .append('rect') + .attr('class', 'negative-bars') + .attr('x', (d) => this.chartXScale(d.bins[0].toString()) as number) + .attr('y', 0) + .attr('width', this.chartXScale.bandwidth()) + .attr('height', (d) => yScale(d.distribution)) + .attr('fill', 'transparent') + .on('mouseenter', (event, d) => { + const index = svg.selectAll('.negative-bars').nodes().indexOf(event.target); + const tooltipText = this.getToolTipText( + d.bins[0] as number, + d.bins[1] as number, + d.distribution, + ); + const tooltipCoordinates = this.getTooltipCoordinates( + this.chartMargin.left, + this.chartMargin.top, + this.chartXScale(d.bins[0].toString()) as number, + this.chartXScale.bandwidth(), + yScale(d.distribution), + ); + const bar = d3.select(this.chartScoreBars.nodes()[index]); + this.handleMouseEnter(bar, index, tooltipText, tooltipCoordinates); + }) + .on('mouseleave', (event) => { + const index = svg.selectAll('.negative-bars').nodes().indexOf(event.target); + const bar = d3.select(this.chartScoreBars.nodes()[index]); + this.handleMouseLeave(bar, index); + }); + + // BARS + this.chartScoreBars = svg + .selectAll('.scorebars') + .data(this.chartData) + .enter() + .append('rect') + .attr('class', 'scorebars') + .attr('x', (d) => this.chartXScale(d.bins[0].toString()) as number) + .attr('y', (d) => yScale(d.distribution)) + .attr('width', this.chartXScale.bandwidth()) + .attr('height', (d) => innerHeight - yScale(d.distribution)) + .attr('fill', this.barColor) + .style('fill-opacity', (_, index) => (this.scoreIndex === index ? '100%' : '50%')) + .on('mouseenter', (event, d) => { + const index = svg.selectAll('.scorebars').nodes().indexOf(event.target); + const tooltipText = this.getToolTipText( + d.bins[0] as number, + d.bins[1] as number, + d.distribution, + ); + const tooltipCoordinates = this.getTooltipCoordinates( + this.chartMargin.left, + this.chartMargin.top, + this.chartXScale(d.bins[0].toString()) as number, + this.chartXScale.bandwidth(), + yScale(d.distribution), + ); + const bar = d3.select(this.chartScoreBars.nodes()[index]); + this.handleMouseEnter(bar, index, tooltipText, tooltipCoordinates); + }) + .on('mouseleave', (event) => { + const index = svg.selectAll('.scorebars').nodes().indexOf(event.target); + const bar = d3.select(this.chartScoreBars.nodes()[index]); + this.handleMouseLeave(bar, index); + }); + + // SCORE LABELS + this.chartBarLabels = svg + .selectAll('.bar-labels') + .data(this.chartData) + .enter() + .append('text') + .attr('class', 'bar-labels') + .attr( + 'x', + (d) => + (this.chartXScale(d.bins[0].toString()) as number) + this.chartXScale.bandwidth() / 2, + ) + .attr('y', (d) => yScale(d.distribution) - 5) + .attr('fill', this.barColor) + .attr('text-anchor', 'middle') + .attr('font-size', '12px') + .style('font-weight', (_, index) => (this.scoreIndex === index ? 'bold' : 'normal')) + .text((_, index) => { + // only show the score on the corresponding bar + if (this.scoreIndex == index) + return this.helperService.roundNumber(this.score as number, 2); + return ''; + }) + .on('mouseenter', (_, d) => { + const index = this.scoreIndex; + const tooltipText = this.getToolTipText( + d.bins[0] as number, + d.bins[1] as number, + d.distribution, + ); + const tooltipCoordinates = this.getTooltipCoordinates( + this.chartMargin.left, + this.chartMargin.top, + this.chartXScale(d.bins[0].toString()) as number, + this.chartXScale.bandwidth(), + yScale(d.distribution), + ); + const bar = d3.select(this.chartScoreBars.nodes()[index]); + this.handleMouseEnter(bar, index, tooltipText, tooltipCoordinates); + }) + .on('mouseleave', () => { + const index = this.scoreIndex; + const bar = d3.select(this.chartScoreBars.nodes()[index]); + this.handleMouseLeave(bar, index); + }); + + // X-AXIS + const xAxis = d3.axisBottom(this.chartXScale); + this.chartXAxisDrawn = svg + .append('g') + .attr('class', 'x-axis') + .attr('transform', `translate(0, ${innerHeight})`) + .call(xAxis); + + // Y-AXIS + const yAxis = d3.axisLeft(yScale); + svg.append('g').attr('class', 'y-axis').call(yAxis); + + // X-AXIS LABEL + this.chartXAxisLabel = svg + .append('text') + .attr('class', 'x-axis-label') + .attr('x', innerWidth / 2) + .attr('y', innerHeight + this.chartMargin.bottom) + .attr('text-anchor', 'middle') + .text('GENE SCORE'); + + // Y-AXIS LABEL + svg + .append('text') + .attr('class', 'y-axis-label') + .attr('x', -innerHeight / 2) + .attr('y', -this.chartMargin.left) + .attr('dy', '1em') + .attr('text-anchor', 'middle') + .attr('transform', 'rotate(-90)') + .text('NUMBER OF GENES'); + + this.initialized = true; + } + } + + handleMouseEnter( + bar: d3.Selection, + index: number, + tooltipText: string, + tooltipCoordinates: { X: number; Y: number }, + ) { + this.highlightBar(bar, index); + this.showTooltip(tooltipText, tooltipCoordinates.X, tooltipCoordinates.Y, index); + } + + handleMouseLeave(bar: d3.Selection, index: number) { + this.unhighlightBar(bar, index); + this.hideTooltip(); + } + + highlightBar(bar: d3.Selection, index: number) { + if (index === this.scoreIndex) { + // when user mouses over score bar, change the opacity so it is clear they have moused over + bar.style('fill-opacity', '80%'); + } else { + bar.style('fill-opacity', '100%'); + } + } + + unhighlightBar(bar: d3.Selection, index: number) { + if (index === this.scoreIndex) { + // score bar should be 100% on mouseout + bar.style('fill-opacity', '100%'); + } else { + // non-score bars should be 50% + bar.style('fill-opacity', '50%'); + } + } + + getTooltipCoordinates( + leftMargin: number, + topMargin: number, + xBarPosition: number, + xBarWidth: number, + yBarPosition: number, + ) { + // x-coordinate would be the left margin + x-barPosition + half of the bar width + const x = leftMargin + xBarPosition + xBarWidth / 2; + // y-coordinate would be the y-barPosition + top margin + const y = topMargin + yBarPosition; + return { X: x, Y: y }; + } + + getToolTipText(scoreRangeStart: number, scoreRangeEnd: number, geneCount: number) { + const leftBoundCharacter = this.scoreIndex == 0 ? '[' : '('; + return `Score Range: ${leftBoundCharacter}${scoreRangeStart}, ${scoreRangeEnd}] +
+ Gene Count: ${geneCount}`; + } + + showTooltip(text: string, x: number, y: number, index: number) { + const tooltipElement = this.tooltip.nativeElement; + tooltipElement.innerHTML = text; + tooltipElement.style.left = `${x}px`; + if (index === this.scoreIndex) y -= 14; // account for height of score + tooltipElement.style.top = `${y}px`; + tooltipElement.style.display = 'block'; + } + + hideTooltip() { + const tooltipElement = this.tooltip.nativeElement; + if (tooltipElement.style.display === 'block') tooltipElement.style.display = 'none'; + } + + destroyChart() { + if (this.initialized) this.chart.remove(); + } + + resizeChart = (divSize: number): void => { + // calculate new width + const width = Math.max(divSize, this.MIN_CHART_WIDTH); + const innerWidth = width - this.chartMargin.left - this.chartMargin.right; + + // update chart size + this.chart.attr('width', width); + + // update chartXScale + this.chartXScale.range([0, innerWidth]); + + // update negative bars + this.chartNegativeBars + .transition() + .attr('x', (d) => this.chartXScale(d.bins[0].toString()) as number); + + // update score bars + this.chartScoreBars + .transition() + .attr('x', (d) => this.chartXScale(d.bins[0].toString()) as number) + .attr('width', this.chartXScale.bandwidth()); + + // update score bar labels + this.chartBarLabels + .transition() + .attr( + 'x', + (d) => + (this.chartXScale(d.bins[0].toString()) as number) + this.chartXScale.bandwidth() / 2, + ) + .attr('width', this.chartXScale.bandwidth()); + + // update drawn x-axis + const xAxis = d3.axisBottom(this.chartXScale); + this.chartXAxisDrawn.transition().call(xAxis); + + // update x-axis label + this.chartXAxisLabel.transition().attr('x', innerWidth / 2); + }; +} diff --git a/libs/agora/charts/src/lib/score-chart/score-chart.component.html b/libs/agora/charts/src/lib/score-chart/score-chart.component.html new file mode 100644 index 0000000000..73f28caf94 --- /dev/null +++ b/libs/agora/charts/src/lib/score-chart/score-chart.component.html @@ -0,0 +1,15 @@ +
+
+ @if (heading) { +

{{ heading }}

+ } +
+
+
+ @if (_score === null) { +
+
No data is currently available.
+
+ } +
+
diff --git a/libs/agora/charts/src/lib/score-chart/score-chart.component.scss b/libs/agora/charts/src/lib/score-chart/score-chart.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/agora/charts/src/lib/score-chart/score-chart.component.spec.ts.off b/libs/agora/charts/src/lib/score-chart/score-chart.component.spec.ts.off new file mode 100644 index 0000000000..a029632a88 --- /dev/null +++ b/libs/agora/charts/src/lib/score-chart/score-chart.component.spec.ts.off @@ -0,0 +1,77 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { ScoreChartComponent } from './score-chart.component'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { distributionMock } from '@sagebionetworks/agora/testing'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Chart - Score', () => { + let fixture: ComponentFixture; + let component: ScoreChartComponent; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ScoreChartComponent], + imports: [RouterTestingModule], + providers: [HelperService], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(ScoreChartComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + element = fixture.nativeElement; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display no data message if score is null', () => { + const validValue1 = 0; + const validValue2 = 1.5; + + expect(component.score).toEqual(null); + expect(element.querySelector('.chart-no-data')).toBeTruthy(); + component.score = validValue1; + fixture.detectChanges(); + expect(element.querySelector('.chart-no-data')).toBeFalsy(); + component.score = validValue2; + fixture.detectChanges(); + expect(element.querySelector('.chart-no-data')).toBeFalsy(); + }); + + it('should render the chart', () => { + const idSpy = spyOn(component, 'initData').and.callThrough(); + const icSpy = spyOn(component, 'initChart').and.callThrough(); + + const distribution: any = []; + + distributionMock.overall_scores[0].bins.forEach((bin: number[], i: number) => { + distribution.push({ + key: bin[0].toFixed(2), + value: distributionMock.overall_scores[0].distribution[i], + range: [bin[0], bin[1]], + }); + }); + + component.distribution = distribution; + component.score = 1; + fixture.detectChanges(); + + expect(idSpy).toHaveBeenCalled(); + expect(icSpy).toHaveBeenCalled(); + expect(element.querySelector('svg')).toBeTruthy(); + }); +}); diff --git a/libs/agora/charts/src/lib/score-chart/score-chart.component.ts b/libs/agora/charts/src/lib/score-chart/score-chart.component.ts new file mode 100644 index 0000000000..0341599dee --- /dev/null +++ b/libs/agora/charts/src/lib/score-chart/score-chart.component.ts @@ -0,0 +1,221 @@ +/* eslint-disable @typescript-eslint/no-this-alias */ +import { Component, inject, Input } from '@angular/core'; +import * as d3 from 'd3'; +import * as dc from 'dc'; +import crossfilter from 'crossfilter2'; + +import { HelperService } from '@sagebionetworks/agora/services'; +import { BaseChartComponent } from '../base-chart/base-chart.component'; + +@Component({ + selector: 'agora-score-chart', + standalone: true, + templateUrl: './score-chart.component.html', + styleUrls: ['./score-chart.component.scss'], +}) +export class ScoreChartComponent extends BaseChartComponent { + helperService = inject(HelperService); + + _score: number | null = null; + get score(): number | null { + return this._score; + } + @Input() set score(score: number | null) { + this._score = score; + this.init(); + } + + @Input() barColor = '#8b8ad1'; + + @Input() distribution: any = []; + @Input() xAxisLabel = 'Gene score'; + @Input() yAxisLabel = 'Number of genes'; + + override name = 'score-chart'; + dimension: any; + group: any; + scoreIndex = -1; + break: any = {}; + + override init() { + if (!this.distribution?.length || !this.chartContainer?.nativeElement) { + return; + } + + this.initData(); + this.initChart(); + + this.chart.render(); + + this.isInitialized = true; + } + + initData() { + this.distribution.forEach((item: any, i: number) => { + if (this._score !== null) { + if (this._score >= item.range[0] && this._score < item.range[1]) { + this.scoreIndex = i; + } + + // Introduce a y-axis break if this bar is huge relative to other bars + const minDiff = this.getMinDiff(item.value, this.distribution); + if (minDiff > 2000) { + this.break = { + index: i, + upper: Math.floor(item.value / 1000) * 1000, + lower: Math.ceil((item.value - minDiff) / 1000) * 1000 + 1000, + }; + + this.distribution[i].truncated = this.break.lower + (item.value - this.break.upper); + } + } + }); + + const ndx = crossfilter(this.distribution); + this.dimension = ndx.dimension((d: any) => d.key); + this.group = this.dimension.group().reduceSum((d: any) => { + return d.truncated || d.value; + }); + } + + // Returns the smallest positive difference between the provided + // bucket value and all other bucket values in the distribution + getMinDiff(value: number, distribution: any[]) { + const arr = distribution.map((d) => value - d.value).filter((v) => v > 0); + const min = Math.min(...arr); + return min === Infinity ? 0 : min; + } + + initChart() { + const max: any = d3.max(this.distribution.map((d: any) => d.truncated || d.value)); + const yTickCount = Math.ceil(max / 1000); + + // Chart + this.chart = dc + .barChart(this.chartContainer.nativeElement) + .dimension(this.dimension) + .group(this.group) + .brushOn(false); + + // X axis + this.chart + .x(d3.scaleBand()) + .xAxisLabel(this.xAxisLabel) + .xUnits(dc.units.ordinal) + .xAxis() + .ticks(2) + .tickFormat(d3.format('d')); + + // Y axis + this.chart + .y(d3.scaleLinear().domain([0, this.group.top(1)[0].value])) + .yAxisLabel(this.yAxisLabel) + .yAxis() + .ticks(yTickCount) + .tickFormat((v: number) => d3.format('.1s')(v == this.break?.lower ? this.break.upper : v)); + + // Colors + this.chart.colors([this.barColor]); + + // Spacing + this.chart + .margins({ + left: 50, + right: 0, + bottom: 50, + top: 0, + }) + .barPadding(0.2); + + // Misc + this.chart.renderTitle(false).renderHorizontalGridLines(true); + + // On render + this.chart.on('renderlet', (chart: any) => { + const bars = chart.selectAll('rect'); + + bars.each((d: any, i: number, bars: any) => { + const barBox = bars[i].getBBox(); + + if (i == this.scoreIndex) { + bars[i].classList.add('score-bar'); + const label = chart.select('g.chart-body').append('text'); + + label + .attr('x', barBox.x) + .attr('y', barBox.y - 6) + .attr('font-size', '12px') + .attr('fill', this.barColor); + + if (this._score !== null) label.text(this.helperService.roundNumber(this._score, 2)); + + const labelBox = label.node().getBBox(); + const widthDiff = labelBox.width - barBox.width; + label.attr('x', barBox.x - (widthDiff > 0 ? widthDiff : widthDiff * -1) / 2); + } + + if (i == this.break.index) { + const topLine = chart.select('.grid-line line:last-child').node(); + const bottomLine = topLine.previousSibling; + const topY = topLine.getBBox().y; + const bottomY = bottomLine.getBBox().y; + const y = bottomY - ((topY - bottomY - 14) / 2) * -1; + + const breakContainer = chart + .select('.chart-body') + .append('g') + .attr('class', 'chart-break') + .style('transform', `translate(${barBox.x - 4}px, ${y}px) skew(0, -15deg)`); + + breakContainer + .append('rect') + .attr('width', barBox.width + 8) + .attr('height', 14) + .attr('fill', this.barColor); + + breakContainer + .append('rect') + .attr('width', barBox.width + 10) + .attr('height', 10) + .attr('x', -1) + .attr('y', 2) + .attr('fill', '#fff'); + } + + this.addTooltip(bars[i], i); + }); + }); + + this.chart.filter = () => ''; + } + + addTooltip(bar: HTMLElement, i: number) { + const self = this; + const tooltip = this.getTooltip('internal', 'score-chart-tooltip', true); + const distribution: any = this.distribution[i]; + // only the first bin has an inclusive left bound + const leftBoundCharacter = i == 0 ? '[' : '('; + + d3.select(bar) + .on('mouseover', function () { + const barBox = bar.getBoundingClientRect(); + + const lowerRange = parseFloat(distribution.range[0]).toFixed(2); + const upperRange = parseFloat(distribution.range[1]).toFixed(2); + + const text = `Score Range: ${leftBoundCharacter} ${lowerRange}, ${upperRange}] +
+ Gene Count: ${distribution.value}`; + + tooltip + .html(text) + .style('top', window.pageYOffset + barBox.top - 40 + 'px') + .style('left', barBox.left + barBox.width - 20 + 'px'); + + self.showTooltip('internal'); + }) + .on('mouseout', function () { + self.hideTooltip('internal'); + }); + } +} diff --git a/libs/agora/charts/src/test-setup.ts b/libs/agora/charts/src/test-setup.ts new file mode 100644 index 0000000000..1100b3e8a6 --- /dev/null +++ b/libs/agora/charts/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/agora/wiki/tsconfig.json b/libs/agora/charts/tsconfig.json similarity index 100% rename from libs/agora/wiki/tsconfig.json rename to libs/agora/charts/tsconfig.json diff --git a/libs/agora/charts/tsconfig.lib.json b/libs/agora/charts/tsconfig.lib.json new file mode 100644 index 0000000000..3a887931fd --- /dev/null +++ b/libs/agora/charts/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": ["src/test-setup.ts", "**/*.spec.ts", "**/*.test.ts", "jest.config.ts"], + "include": ["**/*.ts", "src/lib/median-barchart/median-barchart.component.spec.ts.off"] +} diff --git a/libs/agora/charts/tsconfig.spec.json b/libs/agora/charts/tsconfig.spec.json new file mode 100644 index 0000000000..e775977d59 --- /dev/null +++ b/libs/agora/charts/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts", + "jest.config.ts", + "src/lib/median-barchart/median-barchart.component.spec.ts.off" + ] +} diff --git a/libs/agora/gene-comparison-tool/.eslintrc.json b/libs/agora/gene-comparison-tool/.eslintrc.json new file mode 100644 index 0000000000..5d8c12012f --- /dev/null +++ b/libs/agora/gene-comparison-tool/.eslintrc.json @@ -0,0 +1,40 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "env": { + "jest": true + }, + "overrides": [ + { + "files": ["*.ts"], + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates", + "plugin:jest/recommended" + ], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "agora", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "agora", + "style": "kebab-case" + } + ] + } + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/agora/gene-comparison-tool/README.md b/libs/agora/gene-comparison-tool/README.md new file mode 100644 index 0000000000..e38d4e1384 --- /dev/null +++ b/libs/agora/gene-comparison-tool/README.md @@ -0,0 +1,7 @@ +# agora-gene-comparison-tool + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test agora-gene-comparison-tool` to execute the unit tests. diff --git a/libs/agora/gene-comparison-tool/jest.config.ts b/libs/agora/gene-comparison-tool/jest.config.ts new file mode 100644 index 0000000000..3692cb1235 --- /dev/null +++ b/libs/agora/gene-comparison-tool/jest.config.ts @@ -0,0 +1,23 @@ +/* eslint-disable */ +export default { + displayName: 'agora-gene-comparison-tool', + preset: '../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + globals: {}, + coverageDirectory: '../../../coverage/libs/agora/gene-comparison-tool', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/agora/gene-comparison-tool/project.json b/libs/agora/gene-comparison-tool/project.json new file mode 100644 index 0000000000..9879d807db --- /dev/null +++ b/libs/agora/gene-comparison-tool/project.json @@ -0,0 +1,27 @@ +{ + "name": "agora-gene-comparison-tool", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/agora/gene-comparison-tool/src", + "prefix": "agora", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/libs/agora/gene-comparison-tool"], + "options": { + "jestConfig": "libs/agora/gene-comparison-tool/jest.config.ts" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "lint-fix": { + "executor": "@nx/eslint:lint", + "options": { + "fix": true + } + } + }, + "tags": ["type:feature", "scope:agora", "language:typescript"], + "implicitDependencies": [] +} diff --git a/libs/agora/gene-comparison-tool/src/index.ts b/libs/agora/gene-comparison-tool/src/index.ts new file mode 100644 index 0000000000..8154664adb --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/index.ts @@ -0,0 +1 @@ +export * from './lib/gene-comparison-tool.routes'; diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.html b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.html new file mode 100644 index 0000000000..0fe55ea857 --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.html @@ -0,0 +1,89 @@ + + @if (data) { + @if (data.label) { +
+ {{ data.label }} +
+ } + + @if (data.heading) { +
+ {{ data.heading }} +
+ } + + @if (data.subHeading) { +
+ {{ data.subHeading }} +
+ } + +
+
+
{{ data.valueLabel }}
+
P-Value
+
+
+
+ {{ getSignificantFigures(data.value, 3) }} +
+
+ {{ getSignificantFigures(data.pValue, 3) }} +
+
+
+ +
+
+
{{ data.min }}
+
+
+
+
+
+
+ {{ getSignificantFigures(data.intervalMin, 3) }} +
+
+
+
+ {{ getSignificantFigures(data.intervalMax, 3) }} +
+
+
+
+
+ {{ getSignificantFigures(data.value, 3) }} +
+
+
+
+
+
{{ data.max }}
+
+
+ + @if (data.footer) { + + } + + + } +
+ + + + + + diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.scss b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.scss new file mode 100644 index 0000000000..f3a970b527 --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.scss @@ -0,0 +1,262 @@ +/* stylelint-disable no-descending-specificity */ + +@import 'libs/agora/styles/src/lib/variables'; +@import 'libs/agora/styles/src/lib/mixins'; + +.gct-details-panel { + .p-overlaypanel { + width: 610px; + max-width: 100%; + background: #fff; + box-shadow: 0 0 10px 0 rgb(0 0 0 / 15%); + } + + .p-overlaypanel-content { + padding: 20px; + } + + .gct-details-panel-label { + font-size: var(--font-size-xs); + color: var(--color-gray-600); + margin-bottom: 12px; + font-weight: 700; + } + + .gct-details-panel-heading { + font-size: var(--font-size-h2); + font-weight: 700; + margin-bottom: 12px; + } + + .gct-details-panel-sub-heading { + color: var(--color-text); + font-weight: 700; + text-transform: uppercase; + margin-bottom: 15px; + } + + .gct-details-panel-data { + margin-bottom: var(--spacing-md); + + > div { + &:first-child { + display: flex; + font-size: 12px; + + > div { + flex-grow: 1; + + &:last-child:not(:first-child) { + text-align: right; + } + } + } + + &:last-child { + display: flex; + font-size: 21px; + font-weight: 700; + + > div { + flex-grow: 1; + + &:last-child:not(:first-child) { + text-align: right; + color: var(--color-text); + } + } + } + } + } + + .gct-details-panel-chart { + display: flex; + align-items: center; + padding-top: 20px; + padding-bottom: 20px; + margin-bottom: var(--spacing-md); + + > div { + &:first-child { + padding-right: 16px; + } + + &:nth-child(2) { + flex-grow: 1; + } + + &:last-child { + padding-left: 16px; + } + } + + .gct-details-panel-chart-axis { + position: relative; + height: 2px; + background-color: var(--color-gray-300); + + &::before { + content: ' '; + display: block; + position: absolute; + top: -2px; + left: 50%; + width: 2px; + height: 6px; + margin-left: -1px; + background-color: var(--color-secondary); + border-radius: 1px; + } + } + + .gct-details-panel-chart-interval { + position: absolute; + top: 0; + left: 20%; + right: 20%; + height: 2px; + background-color: var(--color-secondary); + + > div { + position: absolute; + + &::before { + content: ' '; + display: block; + position: absolute; + top: 0; + width: 2px; + height: 6px; + background-color: var(--color-secondary); + border-radius: 1px; + } + + > div { + position: absolute; + font-size: var(--font-size-sm); + top: 100%; + left: 50%; + + &.gct-details-panel-chart-interval-left { + transform: translate(-95%, 14px); + } + + &.gct-details-panel-chart-interval-right { + transform: translate(-5%, 14px); + } + } + + &:first-child { + left: 0; + } + + &:last-child { + right: 0; + } + } + } + + .gct-details-panel-chart-value { + position: absolute; + top: -7px; + left: 50%; + margin-left: -9px; + width: 18px; + height: 18px; + background-color: var(--color-secondary); + border-radius: 50%; + + > div { + position: absolute; + font-size: var(--font-size-sm); + font-weight: 700; + bottom: 100%; + left: 50%; + transform: translate(-50%, -4px); + } + } + } + + .gct-details-panel-range { + margin-bottom: 15px; + + > div { + &:first-child { + padding: 15px 0; + + > div { + position: relative; + border-top: 1px solid #ddd; + + > div { + position: absolute; + top: -10px; + left: 50%; + margin-left: -10px; + width: 20px; + height: 20px; + background-color: var(--color-action-primary); + border-radius: 50%; + z-index: 100; + } + + &::before, + &::after { + content: ' '; + display: block; + position: absolute; + top: 0; + height: 8px; + border-left: 1px solid #ddd; + } + + &::after { + right: 0; + } + } + } + + &:last-child { + display: flex; + font-size: 12px; + margin-bottom: 10px; + + > div { + flex-grow: 1; + + &:last-child { + text-align: right; + } + } + } + } + } + + .gct-details-panel-footer { + font-size: 15px; + color: var(--color-text-secondary); + margin-bottom: var(--spacing-lg); + } + + .gct-details-panel-links { + display: flex; + + > div:not(:last-child) { + padding-right: 13px; + margin-right: 13px; + border-right: 1px solid var(--color-gray-300); + } + + a { + @include link; + + display: block; + font-size: var(--font-size-sm); + font-weight: 700; + + &:hover { + text-decoration: underline; + } + } + } +} diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.spec.ts.off b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.spec.ts.off new file mode 100644 index 0000000000..d5899b9c2d --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.spec.ts.off @@ -0,0 +1,72 @@ +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { GeneComparisonToolDetailsPanelComponent } from './gene-comparison-tool-details-panel.component'; +import { provideRouter } from '@angular/router'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { gctDetailsPanelDataMock } from '@sagebionetworks/agora/testing'; + +describe('Component: Gene Comparison Tool - Details Panel', () => { + let fixture: ComponentFixture; + let component: GeneComparisonToolDetailsPanelComponent; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BrowserAnimationsModule], + providers: [provideRouter([]), HelperService], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(GeneComparisonToolDetailsPanelComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + element = fixture.nativeElement; + component.show({}, JSON.parse(JSON.stringify(gctDetailsPanelDataMock))); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have data', () => { + expect(component.data).toEqual(gctDetailsPanelDataMock); + }); + + it('should have label', () => { + const label = element.querySelector('.gct-details-panel-label') as HTMLElement; + + expect(label).toBeTruthy(); + expect(label?.innerHTML.trim()).toEqual(gctDetailsPanelDataMock.label as string); + }); + + it('should have heading', () => { + const heading = element.querySelector('.gct-details-panel-heading') as HTMLElement; + + expect(heading).toBeTruthy(); + expect(heading?.innerHTML.trim()).toEqual(gctDetailsPanelDataMock.heading as string); + }); + + it('should have sub heading', () => { + const subHeading = element.querySelector('.gct-details-panel-sub-heading') as HTMLElement; + + expect(subHeading).toBeTruthy(); + expect(subHeading?.innerHTML.trim()).toEqual(gctDetailsPanelDataMock.subHeading as string); + }); + + it('should have links', () => { + expect(element.querySelector('.gct-details-panel-links')).toBeTruthy(); + }); + + it('should have values', () => { + const elements = fixture.debugElement.nativeElement.querySelectorAll( + '.gct-details-panel-data > div > div', + ); + expect(elements?.length).toEqual(4); + + expect(elements[0]?.innerHTML.trim()).toEqual(gctDetailsPanelDataMock.valueLabel?.toString()); + expect(elements[2]?.innerHTML.trim()).toEqual(gctDetailsPanelDataMock.value?.toString()); + expect(elements[3]?.innerHTML.trim()).toEqual(gctDetailsPanelDataMock.pValue?.toString()); + }); +}); diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.ts b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.ts new file mode 100644 index 0000000000..9b7ff4fcae --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component.ts @@ -0,0 +1,105 @@ +/* eslint-disable @angular-eslint/no-output-on-prefix */ +import { CommonModule } from '@angular/common'; +import { + Component, + Input, + Output, + ViewChildren, + ViewEncapsulation, + EventEmitter, + inject, +} from '@angular/core'; +import { GCTDetailsPanelData } from '@sagebionetworks/agora/models'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { OverlayPanel, OverlayPanelModule } from 'primeng/overlaypanel'; + +@Component({ + selector: 'agora-gene-comparison-tool-details-panel', + standalone: true, + imports: [CommonModule, OverlayPanelModule], + templateUrl: './gene-comparison-tool-details-panel.component.html', + styleUrls: ['./gene-comparison-tool-details-panel.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class GeneComparisonToolDetailsPanelComponent { + helperService = inject(HelperService); + + event: any = null; + dataIndex = 1; + _data: GCTDetailsPanelData[] = []; + + get data() { + return this._data[this.dataIndex]; + } + + @Input() set data(data: GCTDetailsPanelData) { + if (data && JSON.stringify(data) !== JSON.stringify(this._data[this.dataIndex])) { + this.dataIndex = 0 === this.dataIndex ? 1 : 0; + this._data[this.dataIndex] = data; + } + } + + @Output() onShowLegend: EventEmitter = new EventEmitter(); + @Output() onNavigateToConsistencyOfChange: EventEmitter = new EventEmitter(); + + @ViewChildren(OverlayPanel) panels: any = {} as OverlayPanel; + + getValuePosition(data: any) { + const percentage = Math.round(((data.value - data.min) / (data.max - data.min)) * 100); + return { left: percentage + '%' }; + } + + getIntervalPositions(data: any) { + const minPercentage = Math.round(((data.intervalMin - data.min) / (data.max - data.min)) * 100); + + const maxPercentage = + 100 - Math.round(((data.intervalMax - data.min) / (data.max - data.min)) * 100); + + return { left: minPercentage + '%', right: maxPercentage + '%' }; + } + + show(event: any, data?: GCTDetailsPanelData) { + this.event = event; + this.data = data || {}; + this.panels[0 === this.dataIndex ? 'last' : 'first'].hide(); + + if (this.event?.target) { + this.panels[0 === this.dataIndex ? 'first' : 'last'].show(this.event); + } else { + const target = document.createElement('span'); + this.panels[0 === this.dataIndex ? 'first' : 'last'].show(new Event('click'), target); + } + } + + hide() { + this.panels['first'].hide(); + this.panels['last'].hide(); + } + + toggle(event: any, data?: GCTDetailsPanelData) { + if ( + event.target === this.event?.target && + (this.panels.first.overlayVisible || this.panels.last.overlayVisible) + ) { + this.hide(); + } else { + this.show(event, data); + } + } + + getSignificantFigures(n: any, b: any) { + const emdash = '\u2014'; // Shift+Option+Hyphen + if (n === null || n === undefined) return emdash; + return this.helperService.getSignificantFigures(n, b); + } + + showLegend() { + this.hide(); + this.onShowLegend.emit(); + } + + navigateToConsistencyOfChange() { + this.hide(); + this.onNavigateToConsistencyOfChange.emit(this.data); + } +} diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list-item/gene-comparison-tool-filter-list-item.component.html b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list-item/gene-comparison-tool-filter-list-item.component.html new file mode 100644 index 0000000000..e23ffc270b --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list-item/gene-comparison-tool-filter-list-item.component.html @@ -0,0 +1,25 @@ +@if (isVisible) { +
+
+ @if (title !== '') { + {{ title }}:  + } + @if (description !== '') { + {{ description }} + } +
+
+ +
+
+} diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list-item/gene-comparison-tool-filter-list-item.component.scss b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list-item/gene-comparison-tool-filter-list-item.component.scss new file mode 100644 index 0000000000..f70eece810 --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list-item/gene-comparison-tool-filter-list-item.component.scss @@ -0,0 +1,39 @@ +@use 'sass:map'; +@import 'libs/agora/styles/src/lib/variables'; +@import 'libs/agora/styles/src/lib/mixins'; + +.gct-filter-list-item { + display: flex; + padding: 8px 15px; + background-color: rgba(map.get($main-colors, 'action-primary'), 0.2); + border-radius: 40px; + margin-left: 8px; + margin-bottom: 8px; + font-size: 12px; + font-weight: 400; + float: left; + align-items: center; + + b { + font-weight: 700; + } + + > div { + display: flex; + align-items: center; + } + + .gct-filter-list-item-text > *:hover { + cursor: default; + } + + .gct-filter-list-item-clear { + cursor: pointer; + margin-left: 12px; + color: var(--color-action-primary); + + svg path { + transition: $transition-duration; + } + } +} diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list-item/gene-comparison-tool-filter-list-item.component.spec.ts.off b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list-item/gene-comparison-tool-filter-list-item.component.spec.ts.off new file mode 100644 index 0000000000..bc2a0416d4 --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list-item/gene-comparison-tool-filter-list-item.component.spec.ts.off @@ -0,0 +1,50 @@ +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { GeneComparisonToolFilterListItemComponent } from './gene-comparison-tool-filter-list-item.component'; + +const MOCK_TITLE = '1234'; +const MOCK_DESCRIPTION = '5678'; +const TEXT_CLASS = '.gct-filter-list-item-text'; + +describe('Component: Gene Comparison Tool - Filter List Item', () => { + let fixture: ComponentFixture; + let component: GeneComparisonToolFilterListItemComponent; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(GeneComparisonToolFilterListItemComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + element = fixture.nativeElement; + component.item = { label: 'some option', selected: true }; + component.title = MOCK_TITLE; + component.description = MOCK_DESCRIPTION; + component.isVisible = true; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display text', () => { + const text = element.querySelector(TEXT_CLASS); + expect(text).toBeTruthy(); + expect(text?.textContent).toContain(MOCK_TITLE); + expect(text?.textContent).toContain(MOCK_DESCRIPTION); + }); + + it('should remove filter item', () => { + const clearButton = element.querySelector('.gct-filter-list-item-clear') as HTMLElement; + clearButton.click(); + fixture.detectChanges(); + + expect(component.isVisible).toBeFalsy(); + expect(element.querySelector(TEXT_CLASS)).toBeFalsy(); + }); +}); diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list-item/gene-comparison-tool-filter-list-item.component.ts b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list-item/gene-comparison-tool-filter-list-item.component.ts new file mode 100644 index 0000000000..32bb10fc2f --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list-item/gene-comparison-tool-filter-list-item.component.ts @@ -0,0 +1,21 @@ +import { Component, Input, Output, EventEmitter, ViewEncapsulation } from '@angular/core'; + +@Component({ + selector: 'agora-gene-comparison-tool-filter-list-item', + standalone: true, + templateUrl: './gene-comparison-tool-filter-list-item.component.html', + styleUrls: ['./gene-comparison-tool-filter-list-item.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class GeneComparisonToolFilterListItemComponent { + @Input() item: any; + @Input() isVisible = false; + @Input() title = ''; + @Input() description = ''; + @Output() clearEvent: EventEmitter = new EventEmitter(); + + clearWasClicked() { + this.isVisible = false; + this.clearEvent.emit(this.item); + } +} diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list/gene-comparison-tool-filter-list.component.html b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list/gene-comparison-tool-filter-list.component.html new file mode 100644 index 0000000000..c91b4876fa --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list/gene-comparison-tool-filter-list.component.html @@ -0,0 +1,34 @@ +
+ @if (shouldShowList()) { +
+
+ +
+
+ + + + + + +
+
+ } +
diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list/gene-comparison-tool-filter-list.component.scss b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list/gene-comparison-tool-filter-list.component.scss new file mode 100644 index 0000000000..9ec0f2d3f7 --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list/gene-comparison-tool-filter-list.component.scss @@ -0,0 +1,37 @@ +@use 'sass:map'; +@import 'libs/agora/styles/src/lib/variables'; +@import 'libs/agora/styles/src/lib/mixins'; + +.gct-filter-list { + white-space: nowrap; + + button { + @include reset-button; + } + + .gct-filter-list-inner { + display: flex; + padding: 28px 44px 20px; + border-bottom: 1px solid var(--color-border); + } + + .gct-filter-list-clear-all { + margin-top: 7px; + margin-right: 20px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + cursor: pointer; + transition: $transition-duration; + + .svg-icon { + height: 12px; + margin-right: 8px; + transform: translateY(1px); + } + + &:hover { + @include link-hover; + } + } +} diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list/gene-comparison-tool-filter-list.component.spec.ts.off b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list/gene-comparison-tool-filter-list.component.spec.ts.off new file mode 100644 index 0000000000..ada4074fcb --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list/gene-comparison-tool-filter-list.component.spec.ts.off @@ -0,0 +1,71 @@ +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { GeneComparisonToolFilterListComponent } from './gene-comparison-tool-filter-list.component'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { provideRouter } from '@angular/router'; +import { gctFiltersMocks } from '@sagebionetworks/agora/testing'; + +describe('Component: Gene Comparison Tool - Filter List', () => { + let fixture: ComponentFixture; + let component: GeneComparisonToolFilterListComponent; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BrowserAnimationsModule], + providers: [provideRouter([]), HelperService], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(GeneComparisonToolFilterListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + element = fixture.nativeElement; + component.significanceThreshold = 0.05; + component.significanceThresholdActive = true; + component.filters = JSON.parse(JSON.stringify(gctFiltersMocks)); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have data', () => { + expect(component.filters).toEqual(gctFiltersMocks); + expect(component.significanceThresholdActive).toBeTruthy(); + }); + + it('should display filters', () => { + expect(element.querySelectorAll('.gct-filter-list-item').length).not.toEqual(0); + }); + + it('should remove significance threshold filter', () => { + const clearButton = element.querySelectorAll('.gct-filter-list-item-clear')[0] as HTMLElement; + clearButton.click(); + fixture.detectChanges(); + + expect(component.significanceThresholdActive).toBeFalsy(); + }); + + it('should remove filter', () => { + const clearButton = element.querySelectorAll('.gct-filter-list-item-clear')[1] as HTMLElement; + clearButton.click(); + fixture.detectChanges(); + + expect(component.filters[0].options[0].selected).toEqual(false); + }); + + it('should remove all filters', () => { + const clearButton = element.querySelector('.gct-filter-list-clear-all') as HTMLElement; + clearButton.click(); + fixture.detectChanges(); + + for (const filter of component.filters) { + for (const option of filter.options) { + expect(option.selected).toEqual(false); + } + } + }); +}); diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list/gene-comparison-tool-filter-list.component.ts b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list/gene-comparison-tool-filter-list.component.ts new file mode 100644 index 0000000000..00c08e7062 --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-list/gene-comparison-tool-filter-list.component.ts @@ -0,0 +1,69 @@ +import { Component, Input, Output, EventEmitter, ViewEncapsulation } from '@angular/core'; +import { GCTFilter, GCTFilterOption } from '@sagebionetworks/agora/models'; +import { CommonModule } from '@angular/common'; +import { GeneComparisonToolFilterListItemComponent } from '../gene-comparison-tool-filter-list-item/gene-comparison-tool-filter-list-item.component'; +import { SvgIconComponent } from '@sagebionetworks/agora/shared'; + +@Component({ + selector: 'agora-gene-comparison-tool-filter-list', + standalone: true, + imports: [CommonModule, GeneComparisonToolFilterListItemComponent, SvgIconComponent], + templateUrl: './gene-comparison-tool-filter-list.component.html', + styleUrls: ['./gene-comparison-tool-filter-list.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class GeneComparisonToolFilterListComponent { + /* Filters ------------------------------------------------------------------ */ + @Input() filters: GCTFilter[] = [] as GCTFilter[]; + @Output() changeEvent: EventEmitter = new EventEmitter(); + + /* Significance Threshold --------------------------------------------------- */ + @Input() significanceThresholdActive = false; + @Input() significanceThreshold = -1; + @Output() onremoveSignificanceThresholdFilter: EventEmitter = new EventEmitter(); + + /* ----------------------------------------------------------------------- */ + /* All + /* ----------------------------------------------------------------------- */ + shouldShowList() { + return this.hasSelectedFilters() || this.significanceThresholdActive; + } + + clearList() { + this.removeSignificanceThresholdFilter(); + this.clearSelectedFilters(); + } + + /* ----------------------------------------------------------------------- */ + /* Filters + /* ----------------------------------------------------------------------- */ + hasSelectedFilters() { + for (const filter of this.filters) { + if (filter.options.filter((option) => option.selected).length > 0) { + return true; + } + } + return false; + } + + clearSelectedFilters(option?: GCTFilterOption) { + if (option) { + option.selected = false; + } else { + for (const filter of this.filters) { + for (const o of filter.options) { + o.selected = false; + } + } + } + this.changeEvent.emit(this.filters); + } + + /* ----------------------------------------------------------------------- */ + /* Significance Threshold + /* ----------------------------------------------------------------------- */ + removeSignificanceThresholdFilter(): void { + this.significanceThresholdActive = false; + this.onremoveSignificanceThresholdFilter.emit(this.significanceThresholdActive); + } +} diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-panel/gene-comparison-tool-filter-panel.component.html b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-panel/gene-comparison-tool-filter-panel.component.html new file mode 100644 index 0000000000..9ea072cf3b --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-panel/gene-comparison-tool-filter-panel.component.html @@ -0,0 +1,114 @@ +
+
+
+
+
+ +
+
Filter Genes By
+
+
    +
  • + +
  • +
+
+
+
+ +
+
+
+
+ +
+
+ {{ filter.label }} +
+ +
+
+
    +
  • + +
  • +
+
+
+
+
+
diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-panel/gene-comparison-tool-filter-panel.component.scss b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-panel/gene-comparison-tool-filter-panel.component.scss new file mode 100644 index 0000000000..ae7e7ebb5d --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-panel/gene-comparison-tool-filter-panel.component.scss @@ -0,0 +1,317 @@ +/* stylelint-disable no-descending-specificity */ + +@import 'libs/agora/styles/src/lib/variables'; +@import 'libs/agora/styles/src/lib/mixins'; + +.gct-filter-panel { + position: absolute; + top: 0; + left: 0; + bottom: 0; + opacity: 0; + visibility: hidden; + transform: translateX(-200px); + transition: $transition-duration; + z-index: 500; + + .p-overlaypanel { + background-color: #fff; + border-radius: 0; + box-shadow: 0 0 10px 3px rgb(0 0 0 / 15%); + } + + .p-overlaypanel-content { + padding: 30px; + } + + .gct-filter-panel-inner { + height: 100%; + } + + &.open { + opacity: 1; + visibility: visible; + transform: translateX(0); + } + + .gct-filter-panel-close { + cursor: pointer; + + svg path { + transition: $transition-duration; + } + + &:hover { + svg path { + stroke: var(--color-action-primary); + } + } + } +} + +.gct-filter-panel-main { + position: relative; + width: 344px; + height: 100%; + background-color: #fff; + border-right: 1px solid var(--color-border); + z-index: 100; + + .gct-filter-panel-main-inner { + padding: 30px 30px 30px 44px; + } + + .gct-filter-panel-main-top { + height: 60px; + display: flex; + align-items: flex-start; + justify-content: flex-end; + } + + .gct-filter-panel-main-heading { + height: 80px; + font-size: 24px; + font-weight: 700; + } + + ul { + list-style: none; + padding: 0; + margin: 0; + + li { + margin-left: -15px; + margin-right: 0; + + button { + display: flex; + width: 100%; + padding: 10px 15px; + transition: $transition-duration; + cursor: pointer; + font-size: 18px; + + > div { + display: flex; + align-items: center; + + &:first-child { + flex-grow: 1; + } + } + + svg { + margin-top: 4px; + color: var(--color-gray-500); + } + + &.active, + &:hover { + background-color: var(--color-action-primary); + + &, + svg { + color: #fff; + } + } + } + + &.active { + button { + background-color: var(--color-action-primary); + color: #fff; + } + } + } + } + + .gct-filter-panel-main-menu-item-presets { + position: relative; + margin-bottom: 60px; + + &::after { + content: ' '; + display: block; + position: absolute; + left: 15px; + right: 15px; + bottom: -35px; + border-bottom: 1px solid var(--color-gray-300); + } + } +} + +.active .gct-filter-panel-main .gct-filter-panel-close { + display: none; +} + +.gct-filter-panel-panes { + position: absolute; + top: 0; + left: 100%; + bottom: 0; + z-index: 50; +} + +.gct-filter-panel-pane { + position: absolute; + top: 0; + left: 0; + bottom: 0; + min-width: 380px; + border-right: 1px solid var(--color-border); + background-color: #fff; + opacity: 0; + visibility: hidden; + transform: translateX(-100%); + transition: $transition-duration; + overflow: auto; + box-shadow: 0 0 10px 0 rgb(0 0 0 / 15%); + + .p-checkbox-box { + width: 15px; + height: 15px; + font-size: 12px; + border: 1px solid var(--color-border); + border-radius: 2px; + transform: translateY(-1px); + transition: $transition-duration; + + .p-checkbox-icon { + transition: $transition-duration; + + &::before { + display: block; + font-size: 10px; + line-height: 10px; + } + } + + &.p-focus { + background-color: transparent !important; + border-color: var(--color-action-primary) !important; + } + + &.p-highlight { + background-color: var(--color-action-primary) !important; + border-color: var(--color-action-primary) !important; + + .p-checkbox-icon { + color: #fff; + + &, + &::before { + margin: 0; + transform: translate(0, 0); + } + } + } + } + + &.open { + opacity: 1; + visibility: visible; + transform: translateX(0%); + } + + .gct-filter-panel-pane-inner { + padding: 30px; + } + + .gct-filter-panel-pane-top { + height: 60px; + display: flex; + align-items: flex-start; + justify-content: flex-end; + } + + .gct-filter-panel-pane-heading { + height: 80px; + font-size: 24px; + font-weight: 700; + } + + .gct-filter-panel-pane-heading-info { + display: inline-block; + margin-left: 6px; + transform: translateY(1px); + + .svg-icon { + height: 20px; + } + } + + ul { + list-style: none; + padding: 0; + margin: 0; + + li { + padding: 9px 0; + + label { + display: flex; + align-items: center; + cursor: pointer; + transition: $transition-duration; + + > div { + display: flex; + align-items: center; + + &:first-child { + padding-right: 10px; + } + } + + &:hover { + color: var(--color-action-primary); + + .p-checkbox-box { + border-color: var(--color-action-primary); + } + } + } + } + } + + &.gct-filter-panel-pane-presets { + ul li { + padding: 0; + margin-left: -15px; + margin-right: -15px; + transition: var(--transition-duration); + + label { + padding: 11px 15px; + transition: var(--transition-duration); + + > div:first-child { + display: none; + } + } + + &:hover { + background-color: var(--color-action-primary); + + &, + label { + color: #fff; + } + } + } + } + + .gct-filter-panel-pane-controls { + display: flex; + border-top: 1px solid var(--color-gray-300); + padding: 15px; + + button { + background-color: var(--color-primary); + color: #fff; + padding: 10px 20px; + border-radius: 30px; + } + } +} diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-panel/gene-comparison-tool-filter-panel.component.spec.ts.off b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-panel/gene-comparison-tool-filter-panel.component.spec.ts.off new file mode 100644 index 0000000000..18cf63c835 --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-panel/gene-comparison-tool-filter-panel.component.spec.ts.off @@ -0,0 +1,150 @@ +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { GeneComparisonToolFilterPanelComponent } from './gene-comparison-tool-filter-panel.component'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { provideRouter } from '@angular/router'; +import { gctFiltersMocks } from '@sagebionetworks/agora/testing'; + +describe('Component: Gene Comparison Tool - Filter Panel', () => { + let fixture: ComponentFixture; + let component: GeneComparisonToolFilterPanelComponent; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BrowserAnimationsModule], + providers: [provideRouter([]), HelperService], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(GeneComparisonToolFilterPanelComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + element = fixture.nativeElement; + component.filters = JSON.parse(JSON.stringify(gctFiltersMocks)); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have data', () => { + expect(component.filters).toEqual(gctFiltersMocks); + }); + + it('should display filters', () => { + expect(element.querySelectorAll('.gct-filter-panel-pane').length).toEqual( + gctFiltersMocks.length, + ); + expect(element.querySelectorAll('.gct-filter-panel-pane:first-child li').length).toEqual( + gctFiltersMocks[0].options.length, + ); + }); + + it('should have close button', () => { + expect(element.querySelector('.gct-filter-panel-close')).toBeTruthy(); + }); + + it('should open', () => { + // Set to close + component.isOpen = false; + + // Open programmatically + component.open(); + expect(component.isOpen).toEqual(true); + + fixture.detectChanges(); + + // Make sure panel is open + const panel = element.querySelector('.gct-filter-panel'); + expect(panel?.classList?.contains('open')).toEqual(true); + }); + + it('should close', () => { + component.isOpen = true; + component.open(); + + // Close programmatically + component.close(); + expect(component.isOpen).toEqual(false); + + // Set to open + component.isOpen = true; + + fixture.detectChanges(); + + // Close with click event + const closeButton = element.querySelector('.gct-filter-panel-close') as HTMLElement; + closeButton.click(); + expect(component.isOpen).toEqual(false); + + fixture.detectChanges(); + + // Make sure panel is close + const panel = element.querySelector('.gct-filter-panel'); + expect(panel?.classList?.contains('open')).toEqual(false); + }); + + it('should toggle', () => { + // Set to open + component.isOpen = true; + + // Toggle (close) programmatically + component.toggle(); + expect(component.isOpen).toEqual(false); + + fixture.detectChanges(); + + // Make sure panel is close + const panel = element.querySelector('.gct-filter-panel'); + expect(panel?.classList?.contains('open')).toEqual(false); + }); + + it('should open pane', () => { + // Set to all close + component.activePane = -1; + + // Open first pane programmatically + component.openPane(0); + expect(component.activePane).toEqual(0); + + fixture.detectChanges(); + + // Make sure first pane is open + const pane = element.querySelector('.gct-filter-panel-pane:first-child'); + expect(pane?.classList?.contains('open')).toEqual(true); + }); + + it('should close pane', () => { + /// Set to first pane + component.activePane = 0; + + // Open first pane programmatically + component.closePanes(); + expect(component.activePane).toEqual(-1); + + fixture.detectChanges(); + + // Make sure first pane is open + const pane = element.querySelector('.gct-filter-panel-pane:first-child'); + expect(pane?.classList?.contains('open')).toEqual(false); + }); + + it('should toggle option', () => { + // Toggle (check) first option with click event + const checkbox = element.querySelector( + '.gct-filter-panel-pane:first-child li:first-child .ui-chkbox-box', + ) as HTMLElement | null; + checkbox?.click(); + + fixture.detectChanges(); + + // Make sure input reflects changes + const input = element.querySelector( + '.gct-filter-panel-pane:first-child li:first-child input', + ) as HTMLInputElement; + expect(input?.checked).toBe(true); + }); +}); diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-panel/gene-comparison-tool-filter-panel.component.ts b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-panel/gene-comparison-tool-filter-panel.component.ts new file mode 100644 index 0000000000..40f5a89a2e --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-filter-panel/gene-comparison-tool-filter-panel.component.ts @@ -0,0 +1,53 @@ +import { Component, Input, Output, ViewEncapsulation, EventEmitter } from '@angular/core'; + +import { CommonModule } from '@angular/common'; +import { GCTFilter } from '@sagebionetworks/agora/models'; +import { CheckboxModule } from 'primeng/checkbox'; +import { FormsModule } from '@angular/forms'; +import { TooltipModule } from 'primeng/tooltip'; +import { SvgIconComponent } from '@sagebionetworks/agora/shared'; + +@Component({ + selector: 'agora-gene-comparison-tool-filter-panel', + standalone: true, + imports: [CommonModule, FormsModule, TooltipModule, CheckboxModule, SvgIconComponent], + templateUrl: './gene-comparison-tool-filter-panel.component.html', + styleUrls: ['./gene-comparison-tool-filter-panel.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class GeneComparisonToolFilterPanelComponent { + @Input() filters: GCTFilter[] = [] as GCTFilter[]; + isOpen = false; + activePane = -1; + @Output() changeEvent: EventEmitter = new EventEmitter(); + handleChange(option: any) { + if (option.preset) { + this.filters.forEach((filter) => { + filter.options.forEach((o) => { + if (option.preset[filter.name] && option.preset[filter.name].includes(o.value)) { + o.selected = true; + } else { + o.selected = false; + } + }); + }); + } + this.changeEvent.emit(this.filters); + } + openPane(index: number) { + this.activePane = index; + } + closePanes() { + this.activePane = -1; + } + open() { + this.isOpen = true; + } + close() { + this.closePanes(); + this.isOpen = false; + } + toggle() { + this.isOpen ? this.close() : this.open(); + } +} diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-how-to-panel/gene-comparison-tool-how-to-panel.component.html b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-how-to-panel/gene-comparison-tool-how-to-panel.component.html new file mode 100644 index 0000000000..f0d7770c1c --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-how-to-panel/gene-comparison-tool-how-to-panel.component.html @@ -0,0 +1,50 @@ + +
+
+
+ + +
+ +
+
+ @if (activePane > 0) { + + } + @if (activePane !== panes.length - 1) { + + } + @if (activePane === panes.length - 1) { + + } +
+
+ + @if (loading) { +
+ +
+ } +
diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-how-to-panel/gene-comparison-tool-how-to-panel.component.scss b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-how-to-panel/gene-comparison-tool-how-to-panel.component.scss new file mode 100644 index 0000000000..e700b69f84 --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-how-to-panel/gene-comparison-tool-how-to-panel.component.scss @@ -0,0 +1,104 @@ +@import 'libs/agora/styles/src/lib/variables'; +@import 'libs/agora/styles/src/lib/mixins'; + +.gct-how-to-panel { + max-height: 95% !important; + color: var(--color-text); + + .p-dialog-title:not(:empty) { + padding: 40px 40px 20px; + margin-bottom: -40px; + text-align: left; + font-size: var(--font-size-xl); + color: var(--color-text); + font-weight: 700; + } + + .p-dialog-header-icons { + top: 45px; + right: 40px; + } + + .p-dialog-content { + padding: 40px 40px 0; + min-height: 120px; + font-size: var(--font-size-sm); + line-height: 19px; + } + + .p-dialog-footer { + padding: 30px 40px; + display: flex; + width: 100%; + align-items: center; + + > div:first-child { + flex-grow: 1; + } + + > div:last-child { + display: flex; + } + + .checkbox { + display: flex; + align-items: center; + + span { + font-size: var(--font-size-sm); + } + } + + button { + display: flex; + border-radius: 15px; + border: 1px solid var(--color-action-primary); + background-color: var(--color-action-primary); + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + font-size: var(--font-size-sm); + color: #fff; + cursor: pointer; + transition: var(--transition-duration); + padding: var(--spacing-sm) var(--spacing-md); + + &:not(:last-child) { + margin-right: 12px; + } + + &.gct-how-to-panel-previous { + background-color: #fff; + color: var(--color-action-primary); + } + + &:hover { + opacity: 0.8; + } + } + } + + img { + max-width: 100%; + height: auto; + } +} + +.gct-how-to-panel-loading { + position: absolute; + display: flex; + inset: 0; + align-items: center; + justify-content: center; + background-color: #fff; +} + +.gct-how-to-panel-panes { + .gct-how-to-panel-pane { + display: none; + + &.active { + display: block; + } + } +} diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-how-to-panel/gene-comparison-tool-how-to-panel.component.spec.ts.off b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-how-to-panel/gene-comparison-tool-how-to-panel.component.spec.ts.off new file mode 100644 index 0000000000..3523c77af4 --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-how-to-panel/gene-comparison-tool-how-to-panel.component.spec.ts.off @@ -0,0 +1,26 @@ +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { GeneComparisonToolHowToPanelComponent } from './gene-comparison-tool-how-to-panel.component'; +import { provideRouter } from '@angular/router'; + +describe('Component: Gene Comparison Tool - How To Panel', () => { + let fixture: ComponentFixture; + let component: GeneComparisonToolHowToPanelComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BrowserAnimationsModule], + providers: [provideRouter([])], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(GeneComparisonToolHowToPanelComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-how-to-panel/gene-comparison-tool-how-to-panel.component.ts b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-how-to-panel/gene-comparison-tool-how-to-panel.component.ts new file mode 100644 index 0000000000..2fe696abc2 --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-how-to-panel/gene-comparison-tool-how-to-panel.component.ts @@ -0,0 +1,147 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { Component, inject, OnInit, ViewEncapsulation } from '@angular/core'; +import { CookieService } from 'ngx-cookie-service'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { CheckboxModule } from 'primeng/checkbox'; +import { DialogModule } from 'primeng/dialog'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { LoadingIconComponent } from '@sagebionetworks/agora/shared'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +// import { SynapseApiService } from '../../../../../../core/services'; + +interface Pane { + heading: string; + content: SafeHtml; +} + +// -------------------------------------------------------------------------- // +// Component +// -------------------------------------------------------------------------- // +@Component({ + selector: 'agora-gene-comparison-tool-how-to-panel', + standalone: true, + imports: [CommonModule, FormsModule, CheckboxModule, DialogModule, LoadingIconComponent], + providers: [CookieService], + templateUrl: './gene-comparison-tool-how-to-panel.component.html', + styleUrls: ['./gene-comparison-tool-how-to-panel.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class GeneComparisonToolHowToPanelComponent implements OnInit { + cookieService = inject(CookieService); + sanitizer = inject(DomSanitizer); + + isActive = false; + willHide = false; + willHideCookieName = 'gct_hide_how_to'; + + panes: Pane[] = [ + { + heading: 'Error', + content: '
No data found...
', + }, + ]; + activePane = 0; + + loading = false; + + ngOnInit() { + if (this.cookieService.get(this.willHideCookieName) !== '1') { + this.isActive = true; + } else { + this.willHide = true; + } + + this.loadContent(); + } + + loadContent() { + this.panes = [ + { + heading: 'Gene Comparison Overview', + content: this.sanitizer.bypassSecurityTrustHtml( + `

Welcome to Agora’s Gene Comparison Tool. This overview demonstrates how to use the tool to explore results about genes related to AD. You can revisit this walkthrough by clicking the Visualization Overview link at the bottom of the page.

+

Click on the Legend link at the bottom of the page to view the legend for the current visualization.

+ `, + ), + }, + { + heading: 'View Detailed Expression Info', + content: this.sanitizer.bypassSecurityTrustHtml( + `

Click on a circle to show detailed information about a result for a specific brain region.

+ `, + ), + }, + { + heading: 'Compare Multiple Genes', + content: this.sanitizer.bypassSecurityTrustHtml( + `

You can pin several genes to visually compare them together. Then export the data about your pinned genes as a CSV file for further analysis.

+ `, + ), + }, + { + heading: 'Filter Gene Selection', + content: this.sanitizer.bypassSecurityTrustHtml( + `

Filter genes by Nomination, Association with AD, Study and more. Or simply use the search bar to quickly find the genes you are interested in.

+ `, + ), + }, + ]; + + // Uncomment to use wiki page + // this.loading = true; + + // this.synapseApiService.getWiki('syn25913473', '618351').subscribe( + // (wiki: any) => { + // if (!wiki) { + // this.loading = false; + // return; + // } + + // const sanitized = this.synapseApiService.renderHtml(wiki.markdown); + // const panes = sanitized.split('
'); + + // this.panes = panes.map((html: string) => { + // const headings = html.match('

(.*?)

'); + // const content = html.replace(/

(.*?)<\/h4>/, ''); + + // return { + // heading: headings?.length ? headings[1] : '', + // content: this.sanitizer.bypassSecurityTrustHtml(content), + // }; + // }); + + // this.loading = false; + // }, + // () => { + // this.loading = false; + // } + // ); + } + + previous() { + if (this.activePane > 0) { + this.activePane--; + } + } + + next() { + if (this.activePane < this.panes.length - 1) { + this.activePane++; + } + } + + onHide() { + this.cookieService.set(this.willHideCookieName, this.willHide ? '1' : '0'); + this.activePane = 0; + } + + toggle() { + this.isActive = !this.isActive; + } +} diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-legend-panel/gene-comparison-tool-legend-panel.component.html b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-legend-panel/gene-comparison-tool-legend-panel.component.html new file mode 100644 index 0000000000..e4e6f795b5 --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-legend-panel/gene-comparison-tool-legend-panel.component.html @@ -0,0 +1,15 @@ + + + + Visualization Overview + + diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-legend-panel/gene-comparison-tool-legend-panel.component.scss b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-legend-panel/gene-comparison-tool-legend-panel.component.scss new file mode 100644 index 0000000000..577a7855cc --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-legend-panel/gene-comparison-tool-legend-panel.component.scss @@ -0,0 +1,46 @@ +@import 'libs/agora/styles/src/lib/variables'; +@import 'libs/agora/styles/src/lib/mixins'; + +.gct-legend-panel { + color: var(--color-text); + + .p-dialog-title:not(:empty) { + padding: 40px 40px 20px; + margin-bottom: -40px; + text-align: left; + font-size: var(--font-size-xl); + color: var(--color-text); + font-weight: 700; + } + + .p-dialog-header-icons { + top: 45px; + right: 40px; + } + + .p-dialog-content { + padding: 20px 40px 0; + } + + .p-dialog-footer { + padding: 30px 40px; + text-align: right; + + a { + color: var(--color-action-primary); + font-size: var(--font-size-sm); + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + } + + img, + svg { + display: block; + width: 100%; + height: auto; + } +} diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-legend-panel/gene-comparison-tool-legend-panel.component.spec.ts.off b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-legend-panel/gene-comparison-tool-legend-panel.component.spec.ts.off new file mode 100644 index 0000000000..d0fb25144d --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-legend-panel/gene-comparison-tool-legend-panel.component.spec.ts.off @@ -0,0 +1,25 @@ +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { GeneComparisonToolLegendPanelComponent } from './gene-comparison-tool-legend-panel.component'; +import { provideRouter } from '@angular/router'; + +describe('Component: Gene Comparison Tool - Legend Panel', () => { + let fixture: ComponentFixture; + let component: GeneComparisonToolLegendPanelComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [], + providers: [provideRouter([])], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(GeneComparisonToolLegendPanelComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-legend-panel/gene-comparison-tool-legend-panel.component.ts b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-legend-panel/gene-comparison-tool-legend-panel.component.ts new file mode 100644 index 0000000000..884d7fc728 --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-legend-panel/gene-comparison-tool-legend-panel.component.ts @@ -0,0 +1,24 @@ +import { Component, Output, EventEmitter, ViewEncapsulation } from '@angular/core'; +import { DialogModule } from 'primeng/dialog'; + +@Component({ + selector: 'agora-gene-comparison-tool-legend-panel', + standalone: true, + imports: [DialogModule], + templateUrl: './gene-comparison-tool-legend-panel.component.html', + styleUrls: ['./gene-comparison-tool-legend-panel.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class GeneComparisonToolLegendPanelComponent { + isActive = false; + + @Output() howToClick: EventEmitter = new EventEmitter(); + + toggle() { + this.isActive = !this.isActive; + } + + onHowToClick() { + this.howToClick.emit(null); + } +} diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-pinned-genes-modal/gene-comparison-tool-pinned-genes-modal.component.html b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-pinned-genes-modal/gene-comparison-tool-pinned-genes-modal.component.html new file mode 100644 index 0000000000..1b4ed4baae --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-pinned-genes-modal/gene-comparison-tool-pinned-genes-modal.component.html @@ -0,0 +1,21 @@ + + You have {{ pinnedGenes.length }} genes pinned, but there are + {{ pendingPinnedGenes.length }} protein results for those genes. Since a maximum of + {{ maxPinnedGenes }} results can be pinned, you will lose some of your pins if you proceed to the + Protein - Differential Expression view. + +
+ + +
+
+
diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-pinned-genes-modal/gene-comparison-tool-pinned-genes-modal.component.scss b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-pinned-genes-modal/gene-comparison-tool-pinned-genes-modal.component.scss new file mode 100644 index 0000000000..f288ff0d48 --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-pinned-genes-modal/gene-comparison-tool-pinned-genes-modal.component.scss @@ -0,0 +1,68 @@ +@import 'libs/agora/styles/src/lib/variables'; +@import 'libs/agora/styles/src/lib/mixins'; + +.gct-pinned-genes-conversion-modal { + max-height: 95% !important; + color: var(--color-text); + + .p-dialog-title:not(:empty) { + padding: 40px 40px 20px; + margin-bottom: -40px; + text-align: left; + font-size: var(--font-size-xl); + color: var(--color-text); + font-weight: 700; + } + + .p-dialog-header-icons { + top: 45px; + right: 40px; + } + + .p-dialog-content { + padding: 40px 40px 0; + min-height: 120px; + } + + .p-dialog-footer { + padding: 30px 40px; + width: 100%; + + > div:first-child { + flex-grow: 1; + } + + > div:last-child { + display: flex; + justify-content: end; + } + + button { + display: flex; + height: 36px; + border-radius: 15px; + padding: 0 20px; + border: 1px solid var(--color-action-primary); + background-color: var(--color-action-primary); + align-items: center; + justify-content: center; + font-size: var(--font-size-xs); + color: #fff; + cursor: pointer; + transition: var(--transition-duration); + + &:not(:last-child) { + margin-right: 12px; + } + + &.gct-pinned-genes-conversion-cancel { + background-color: #fff; + color: var(--color-action-primary); + } + + &:hover { + opacity: 0.8; + } + } + } +} diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-pinned-genes-modal/gene-comparison-tool-pinned-genes-modal.component.spec.ts.off b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-pinned-genes-modal/gene-comparison-tool-pinned-genes-modal.component.spec.ts.off new file mode 100644 index 0000000000..3ead7b403f --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-pinned-genes-modal/gene-comparison-tool-pinned-genes-modal.component.spec.ts.off @@ -0,0 +1,25 @@ +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { GeneComparisonToolPinnedGenesModalComponent } from './gene-comparison-tool-pinned-genes-modal.component'; +import { provideRouter } from '@angular/router'; + +describe('Component: Gene Comparison Tool - Pinned Genes Modal', () => { + let fixture: ComponentFixture; + let component: GeneComparisonToolPinnedGenesModalComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [], + providers: [provideRouter([])], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(GeneComparisonToolPinnedGenesModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-pinned-genes-modal/gene-comparison-tool-pinned-genes-modal.component.ts b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-pinned-genes-modal/gene-comparison-tool-pinned-genes-modal.component.ts new file mode 100644 index 0000000000..0b0c9a3038 --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-pinned-genes-modal/gene-comparison-tool-pinned-genes-modal.component.ts @@ -0,0 +1,40 @@ +/* eslint-disable @angular-eslint/no-output-on-prefix */ +import { Component, Output, EventEmitter, ViewEncapsulation, Input } from '@angular/core'; +import { GCTGene } from '@sagebionetworks/agora/api-client-angular'; +import { DialogModule } from 'primeng/dialog'; + +@Component({ + selector: 'agora-gene-comparison-tool-pinned-genes-modal', + standalone: true, + imports: [DialogModule], + templateUrl: './gene-comparison-tool-pinned-genes-modal.component.html', + styleUrls: ['./gene-comparison-tool-pinned-genes-modal.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class GeneComparisonToolPinnedGenesModalComponent { + @Input() pinnedGenes: GCTGene[] = []; + @Input() pendingPinnedGenes: GCTGene[] = []; + @Input() maxPinnedGenes = 5; + + isActive = false; + + @Output() onChange: EventEmitter = new EventEmitter(); + + show() { + this.isActive = true; + } + + hide() { + this.isActive = false; + } + + cancel() { + this.onChange.emit(false); + this.hide(); + } + + proceed() { + this.onChange.emit(true); + this.hide(); + } +} diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-score-panel/gene-comparison-tool-score-panel.component.html b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-score-panel/gene-comparison-tool-score-panel.component.html new file mode 100644 index 0000000000..c5c314c647 --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-score-panel/gene-comparison-tool-score-panel.component.html @@ -0,0 +1,39 @@ + + @if (data) { + @if (data.geneLabel) { +
+ {{ data.geneLabel }} +
+ } + + @if (data.scoreName) { +
+ {{ data.scoreName }} +
+ } + + + + + @if (scoreDistribution) { +
+ +
+ } + + + } +
diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-score-panel/gene-comparison-tool-score-panel.component.scss b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-score-panel/gene-comparison-tool-score-panel.component.scss new file mode 100644 index 0000000000..cd690353e7 --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-score-panel/gene-comparison-tool-score-panel.component.scss @@ -0,0 +1,44 @@ +@import 'libs/agora/styles/src/lib/variables'; +@import 'libs/agora/styles/src/lib/mixins'; + +.gct-score-panel { + .p-overlaypanel { + width: 390px; + background: #fff; + box-shadow: 0 0 10px 0 rgb(0 0 0 / 15%); + } + + .p-overlaypanel-content { + padding: 20px; + } + + .gct-score-panel-gene-label { + font-size: var(--font-size-xs); + color: var(--color-gray-600); + margin-bottom: 12px; + font-weight: 700; + } + + .gct-score-panel-header { + font-size: var(--font-size-h2); + font-weight: 700; + margin-bottom: 12px; + } + + .wiki .wiki-inner { + margin: 24px 0; + font-size: var(--font-size-sm); + } + + .gct-score-panel-links { + font-size: var(--font-size-sm); + + span { + margin-right: 10px; + } + + a { + @include link; + } + } +} diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-score-panel/gene-comparison-tool-score-panel.component.spec.ts.off b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-score-panel/gene-comparison-tool-score-panel.component.spec.ts.off new file mode 100644 index 0000000000..d40392904a --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-score-panel/gene-comparison-tool-score-panel.component.spec.ts.off @@ -0,0 +1,41 @@ +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +import { HelperService } from '@sagebionetworks/agora/services'; +import { provideRouter } from '@angular/router'; +import { gctScorePanelDataMock } from '@sagebionetworks/agora/testing'; +import { GeneComparisonToolScorePanelComponent } from './gene-comparison-tool-score-panel.component'; + +describe('Component: Gene Comparison Tool - Details Panel', () => { + let fixture: ComponentFixture; + let component: GeneComparisonToolScorePanelComponent; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BrowserAnimationsModule], + providers: [HelperService, provideRouter([])], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(GeneComparisonToolScorePanelComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + element = fixture.nativeElement; + component.show(new Event('click'), JSON.parse(JSON.stringify(gctScorePanelDataMock))); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have data', () => { + expect(component.data).toEqual(gctScorePanelDataMock); + }); + + it('should have links', () => { + expect(element.querySelector('.gct-score-panel-links')).toBeTruthy(); + }); +}); diff --git a/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-score-panel/gene-comparison-tool-score-panel.component.ts b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-score-panel/gene-comparison-tool-score-panel.component.ts new file mode 100644 index 0000000000..d3409003c4 --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/components/gene-comparison-tool-score-panel/gene-comparison-tool-score-panel.component.ts @@ -0,0 +1,77 @@ +import { + Component, + Input, + Output, + ViewEncapsulation, + EventEmitter, + ViewChild, +} from '@angular/core'; +import * as helpers from '../../gene-comparison-tool.helpers'; +import { GCTScorePanelData } from '@sagebionetworks/agora/models'; +import { OverallScoresDistribution } from '@sagebionetworks/agora/api-client-angular'; +import { OverlayPanel, OverlayPanelModule } from 'primeng/overlaypanel'; +import { ScoreBarChartComponent } from '@sagebionetworks/agora/charts'; +import { WikiComponent } from '@sagebionetworks/agora/shared'; + +@Component({ + selector: 'agora-gene-comparison-tool-score-panel', + standalone: true, + imports: [OverlayPanelModule, WikiComponent, ScoreBarChartComponent], + templateUrl: './gene-comparison-tool-score-panel.component.html', + styleUrls: ['./gene-comparison-tool-score-panel.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class GeneComparisonToolScorePanelComponent { + event: any = null; + dataIndex = 1; + + @Input() data: GCTScorePanelData | undefined; + + barColor = '#8B8AD1'; + + @Output() navigateToMethodologyEvent: EventEmitter = new EventEmitter(); + @Output() navigateToFeedbackEvent: EventEmitter = new EventEmitter(); + + @ViewChild('overlayPanel') overlayPanel!: OverlayPanel; + + scoreDistribution: OverallScoresDistribution | undefined; + + getValuePosition(data: any) { + const percentage = Math.round(((data.value - data.min) / (data.max - data.min)) * 100); + return { left: percentage + '%' }; + } + + getIntervalPositions(data: any) { + const minPercentage = Math.round(((data.intervalMin - data.min) / (data.max - data.min)) * 100); + + const maxPercentage = + 100 - Math.round(((data.intervalMax - data.min) / (data.max - data.min)) * 100); + + return { left: minPercentage + '%', right: maxPercentage + '%' }; + } + + show(event: Event, data?: GCTScorePanelData) { + this.event = event; + + if (data && data.distributions) { + this.data = data; + const dataKey = helpers.lookupScoreDataKey(data.columnName); + this.scoreDistribution = data.distributions.find( + (scores) => scores.name.toUpperCase() === dataKey, + ); + this.overlayPanel.toggle(event); + } + } + + hide() { + this.overlayPanel.hide(); + } + + toggle(event: Event, data?: GCTScorePanelData) { + if (event.target === this.event?.target && this.overlayPanel.overlayVisible) { + this.hide(); + } else { + this.show(event, data); + } + } +} diff --git a/libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.component.html b/libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.component.html new file mode 100644 index 0000000000..6a3ddd629a --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.component.html @@ -0,0 +1,658 @@ +
+
+
+
+
+ +
+
+

Gene Comparison Tool

+
+
+ +
+
+
+ + + +
+
+
+
+
+
DISPLAYED GENES
+
+ {{ (genesTable?._totalRecords || 0) + (pinnedTable?.dataToRender?.length || 0) }} +
+ +
+
+
+
+
+ +
+
+ + +
+
+
+
Hide insignificant
+
+ +
+
+ + +

+ Statistical significance +

+

Enter the P-value to use for the significance cutoff.

+ +
+
+ +
+
+ + +
    +
  • +
    +
    + +
    +
    + {{ column.header }} +
    +
    +
  • +
+
    +
  • + +
    +
    + +
    +
    + {{ column.header }} +
    +
    +
    +
  • +
+
+
+ +
+
+
+ +
+
{{ subCategoryLabel }}
+
+ +
+
+
+
+
+ +
+
+ + + + + +
+
+ + {{ column }} + +
+
+ +
+
+ + +
+
+ +
+
+
+ + Pinned Genes ({{ pinnedItems.length }}/50) + +
+
Pinned Genes ({{ uniquePinnedGenesCount }}/50)   
+
+ {{ pinnedItems.length }} Proteins +
+
+
+
+ +
+
+ +
+
+
+ + + + +
+
+ {{ gene.hgnc_symbol || gene.ensembl_gene_id }} + + ({{ gene.uniprotid }}) + +
+ +
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + +
+
+ +
+
+
Matching Genes
+
Filtered Genes
+
+ +
+
+
+
+
+
All Genes
+
+
+ + +
No results found...
+
+ + + + +
+
+ {{ gene.hgnc_symbol || gene.ensembl_gene_id }} + + ({{ gene.uniprotid }}) + +
+
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + +
+
+ + + + +
+
+
+
+ + + + + + +
+
diff --git a/libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.component.scss b/libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.component.scss new file mode 100644 index 0000000000..aec2741bc0 --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.component.scss @@ -0,0 +1,826 @@ +/* stylelint-disable no-descending-specificity */ + +@import 'libs/agora/styles/src/lib/variables'; +@import 'libs/agora/styles/src/lib/mixins'; + +.gct-tooltip-filter { + max-width: 350px; + + .p-tooltip-text { + white-space: normal; + } +} + +.gct { + @include container(lg); + + color: var(--color-text); + margin: 0 auto; + border: 1px solid var(--color-gray-300); + + .gct-inner { + min-width: 1300px; + } +} + +@media (width <= 1299px) { + .gct { + overflow: auto hidden; + } +} + +.gct-header { + border-bottom: 1px solid var(--color-gray-300); + + .gct-header-inner { + display: flex; + padding: 28px 44px; + + > div { + display: flex; + align-items: center; + } + } + + .gct-header-middle { + flex-grow: 1; + justify-content: center; + } + + .gct-heading { + margin: 0; + display: flex; + align-items: center; + text-align: center; + text-transform: capitalize; + } + + .gct-header-left { + width: 150px; + + .btn { + svg { + width: 12px; + height: 12px; + margin-right: 6px; + transform: translateY(1px); + } + } + } + + .gct-header-right { + width: 150px; + justify-content: flex-end; + } +} + +.gct-body { + .gct-body-inner { + position: relative; + overflow: hidden; + } + + button { + @include reset-button; + } +} + +.gct-controls { + .gct-controls-inner { + display: flex; + } + + .gct-controls-left { + width: 300px; + border-right: 1px solid var(--color-gray-300); + padding: 25px 44px; + box-sizing: border-box; + } + + .gct-controls-right { + flex-grow: 1; + padding: 20px 0 10px 44px; + margin-right: 50px; + margin-bottom: 20px; + + .gct-filter-label { + display: block; + font-size: 10px; + font-weight: 900; + } + + .p-dropdown { + padding: 0; + border: none; + background-color: transparent; + + .p-dropdown-label { + padding-left: 0; + font-size: 14px; + font-weight: 400; + color: var(--color-text); + } + + .p-dropdown-trigger { + margin-left: 8px; + } + + .p-dropdown-trigger-icon { + font-size: 14px; + color: var(--color-text); + } + + .p-dropdown-panel { + background-color: #fff; + box-shadow: 0 1px 10px 0 rgb(0 0 0 / 15%); + border-radius: 5px; + padding: 10px 0; + + ul { + padding: 0; + + li { + padding: 5px 15px; + transition: $transition-duration; + + span { + white-space: nowrap; + color: var(--color-primary); + font-size: 12px; + font-weight: 900; + } + + &.p-disabled { + span { + color: #a6a6a6; + } + } + + &.p-highlight { + background-color: var(--color-action-primary); + + span { + color: #fff; + } + } + + &:not(.p-disabled):hover { + background-color: var(--color-action-primary); + + span { + color: #fff; + } + } + } + } + } + + &:hover { + .p-dropdown-trigger-icon { + color: var(--color-action-primary); + } + } + } + } + + .gct-controls-right-top { + display: flex; + width: 100%; + border-bottom: 1px solid var(--color-border); + margin-bottom: 8px; + + > div { + &:first-child { + flex-grow: 1; + } + } + } + + .gct-category-selector { + display: flex; + align-items: center; + padding-bottom: 4px; + + > div { + &:last-child { + padding-left: 10px; + } + } + + .p-dropdown-label { + text-transform: uppercase; + font-weight: 700 !important; + } + + .p-dropdown-panel { + ul { + li { + span { + text-transform: uppercase; + } + } + } + } + } + + .gct-significance-control { + display: flex; + font-size: 14px; + color: #797979; + height: 20px; + margin-bottom: 8px; + + button { + cursor: pointer; + } + + > div { + padding-left: 10px; + + &:last-child { + border-left: 1px solid var(--color-gray-300); + margin-left: 10px; + } + } + } + + .gct-sub-category-selector { + display: flex; + } + + .gene-label { + font-size: 13px; + font-weight: 700; + margin-bottom: 7px; + } + + .gene-count { + font-size: var(--font-size-sm); + } + + .gct-search { + position: absolute; + margin-top: 40px; + + .gct-search-icon { + position: absolute; + top: 50%; + left: 12px; + margin-top: -6px; + } + + input { + border: 1px solid var(--color-gray-300); + padding: 12px 40px 12px 34px; + width: 100%; + transition: $transition-duration; + box-sizing: border-box; + font-size: var(--font-size-xs); + outline: none; + + &:focus { + border-color: var(--color-action-primary); + } + } + + button { + position: absolute; + top: 0; + right: 0; + width: 40px; + height: 100%; + cursor: pointer; + + svg path { + transition: $transition-duration; + } + + &:hover { + svg path { + stroke: var(--color-primary); + } + } + } + } +} + +.gct-tables { + .gct-tables-inner { + min-height: 600px; + } + + .no-results { + color: #a6a6a6; + text-align: center; + padding: 15px 20px 20px; + } + + .p-datatable-thead, + .p-datatable-tbody { + tr { + display: flex; + } + + td, + th { + &:first-child { + width: 300px; + border-right: 1px solid var(--color-gray-300); + padding-left: 10px; + padding-right: 10px; + } + } + } + + .p-datatable-thead { + th { + font-size: 16px; + border-bottom: 1px solid var(--color-gray-300); + display: flex; + align-items: center; + justify-content: center; + + .column-header { + display: flex; + align-items: center; + justify-content: center; + + .column-header-text { + text-align: right; + padding-right: 3px; + } + } + + span:hover { + color: var(--color-action-primary); + } + } + + .p-sortable-column-icon { + font-size: 14px; + transform: translateY(-2px); + + &:hover { + color: var(--color-action-primary); + } + } + } + + .p-datatable-tbody { + td { + display: flex; + align-items: center; + justify-content: end; + height: 56px; + } + + td.cell-numeric { + height: 56px; + padding-top: 4px !important; + padding-bottom: 4px !important; + display: flex; + align-items: center; + justify-content: center; + + button { + padding: 10px; + cursor: pointer; + + &:hover { + color: var(--color-action-primary); + } + } + } + + td.cell-default { + position: relative; + height: 56px; + padding-top: 4px !important; + padding-bottom: 4px !important; + + &:not(:first-child) { + &::before, + &::after { + content: ''; + position: absolute; + background: var(--color-gray-300); + } + + &::before { + left: 50%; + width: 1px; + margin-left: 0%; + height: 100%; + } + + &::after { + top: 50%; + height: 1px; + margin-top: 0%; + width: 100%; + } + } + } + } + + .p-paginator-bottom { + display: block; + padding: 20px 30px; + text-align: right; + border-top: 1px solid var(--color-gray-300); + + .p-paginator-pages { + display: none; + } + + .p-paginator-current { + margin-right: 15px; + font-size: 15px; + cursor: default; + color: var(--color-text); + } + + .p-paginator-icon { + color: var(--color-action-primary); + cursor: pointer; + transition: $transition-duration; + } + + .p-link.p-disabled { + opacity: 0.5; + } + + button { + transform: translateY(3px); + } + } + + .gene-controls { + display: flex; + justify-content: end; + align-items: stretch; + flex: 1; + background-color: transparent; + transition: $transition-duration; + padding: 6px 15px 6px 7px; + margin-right: 0; + font-size: var(--font-size-md); + font-weight: 700; + + .gene-control-icons { + display: none; + } + + > div:first-child { + display: flex; + justify-content: center; + align-items: center; + } + + &:not(:hover) { + > div:not(:first-child) { + display: none; + } + } + + &:hover { + background-color: var(--color-action-primary); + color: #fff; + + .gene-control-icons { + margin-left: 14px; + display: flex; + } + + > div:first-child { + padding-right: 6px; + } + } + + button { + display: flex; + width: 30px; + height: 30px; + align-items: center; + justify-content: center; + cursor: pointer; + + .svg-icon { + height: 14px; + } + + &.disabled { + opacity: 0.5; + cursor: default; + } + } + } + + .gene-indicator { + display: block; + position: relative; + width: 40px; + height: 40px; + margin: 0 auto; + border-radius: 50%; + z-index: 100; + border: 1px solid transparent; + cursor: pointer; + transition: $transition-duration; + + > span { + display: block; + width: 100%; + height: 100%; + border-radius: 50%; + border: 2px solid transparent; + opacity: 0; + visibility: hidden; + background-color: transparent; + transition: $transition-duration; + } + + &.plus { + &, + > span { + border-color: #245299; + } + } + + &.minus { + &, + > span { + border-color: #d72247; + } + } + + &:hover { + box-shadow: 2px 2px 4px 0 rgb(0 0 0 / 25%); + + > span { + opacity: 1; + visibility: visible; + } + } + + .gene-indicator-text { + position: absolute; + top: 50%; + left: 50%; + font-size: 12px; + transform: translate(-50%, -50%); + white-space: nowrap; + } + } +} + +#table-header { + pointer-events: none; + left: 44px; + right: 44px; + + tr { + display: flex; + min-height: 50px; + } + + th:first-child { + width: 300px; + border-right: 1px solid var(--color-border); + justify-content: start; + padding-left: 44px; + } + + th:not(:first-child) { + pointer-events: auto; + } +} + +.table-divider { + background-color: #fbfbfc; + margin-left: -44px; + margin-right: -44px; + font-size: var(--font-size-sm); + + .table-divider-inner { + display: flex; + padding: 17px 44px; + margin-left: 30px; + margin-right: 30px; + + > div { + display: flex; + align-items: center; + + &:first-child { + color: #1a1c29; + text-transform: uppercase; + font-size: 15px; + font-weight: 700; + flex-grow: 1; + } + } + + #pinned-proteins { + font-weight: normal; + padding: 5px 0; + font-size: 14px; + } + } + + button { + cursor: pointer; + + &:hover { + color: var(--color-primary); + transition: $transition-duration; + } + } + + .pin-all-button { + display: flex; + align-items: center; + + .svg-icon { + height: 14px; + margin-right: 8px; + transform: translateY(2px); + } + + &:not(.disabled) { + &:hover { + @include link-hover; + } + } + + &.disabled { + opacity: 0.5; + cursor: default; + + .svg-icon { + color: inherit; + + .svg-icon-bg { + display: none; + } + } + } + } +} + +.p-paginator-bottom { + &::before { + content: ' '; + } +} + +.csv-download-button { + margin-right: 40px; + + button { + display: flex; + align-items: center; + + .svg-icon { + height: 14px; + margin-right: 8px; + transform: translateY(2px); + } + + &:hover { + @include link-hover; + } + } +} + +.clear-all-button { + button { + &:hover { + @include link-hover; + } + } +} + +.gct-category-panel { + width: 425px; + background: #fff; + font-size: var(--font-size-xs); + border-radius: 3px; + box-shadow: 0 0 10px 0 rgb(0 0 0 / 15%); + + .p-overlaypanel-content { + padding: 20px; + } +} + +.gct-significance-control-panel { + max-width: 425px; + background: #fff; + box-shadow: 0 0 10px 0 rgb(0 0 0 / 15%); + + .p-overlaypanel-content { + padding: 40px; + } + + .gct-significance-control-panel-heading { + font-size: 21px; + font-weight: 700; + } + + input { + width: 100%; + background-color: #f1f3f5; + border: none; + padding: 8px; + font-size: 14px; + outline: none; + } +} + +.gct-help-links { + position: relative; + margin-top: -40px; + margin-bottom: 24px; + padding-left: 300px; + z-index: 100; + pointer-events: none; + + .gct-help-links-inner { + display: flex; + + > div:not(:last-child) { + padding-right: 13px; + margin-right: 13px; + border-right: 1px solid var(--color-gray-300); + } + } + + .gct-help-link { + display: block; + color: var(--color-text); + font-weight: 700; + cursor: pointer; + pointer-events: all; + + &:hover { + text-decoration: underline; + color: var(--color-action-primary); + } + } +} + +.gct-spacing-column { + &::after { + opacity: 0 !important; + } +} + +.gct-toggle-columns { + background: #fff; + box-shadow: 0 0 10px 0 rgb(0 0 0 / 15%); + + .p-overlaypanel-content { + ul#scores-columns { + &::after { + content: ' '; + display: block; + margin: 5px 0; + border-bottom: 1px solid var(--color-gray-300); + } + } + + ul { + list-style-type: none; + margin: 0; + padding: 0; + + li { + .column-toggle { + display: flex; + padding: 11px; + font-size: 14px; + + &:hover { + background-color: var(--color-action-primary); + color: white; + cursor: pointer; + } + + &:hover .column-toggle-status span { + color: white; + } + + .column-toggle-status { + width: 20px; + margin-right: 5px; + + span { + color: var(--color-action-primary); + font-weight: bold; + } + } + } + } + } + } +} diff --git a/libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.component.spec.ts.off b/libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.component.spec.ts.off new file mode 100644 index 0000000000..c70c962028 --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.component.spec.ts.off @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { GeneComparisonToolComponent } from './gene-comparison-tool.component'; + +describe('GeneSearchComponent', () => { + let component: GeneComparisonToolComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [], + }).compileComponents(); + + fixture = TestBed.createComponent(GeneComparisonToolComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.component.ts b/libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.component.ts new file mode 100644 index 0000000000..ca61b947f4 --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.component.ts @@ -0,0 +1,1318 @@ +import { CommonModule } from '@angular/common'; +import { AfterViewInit, Component, inject, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { + DistributionService, + GCTGene, + GCTGeneTissue, + GenesService, + OverallScoresDistribution, +} from '@sagebionetworks/agora/api-client-angular'; +import { + GCTColumn, + GCTDetailsPanelData, + GCTFilter, + GCTSelectOption, + GCTSortEvent, +} from '@sagebionetworks/agora/models'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { cloneDeep } from 'lodash'; +import { FilterService, MessageService, SortEvent } from 'primeng/api'; +import { Table, TableModule } from 'primeng/table'; +import { TooltipModule } from 'primeng/tooltip'; +import { combineLatest, Subscription } from 'rxjs'; + +import * as variables from './gene-comparison-tool.variables'; +import * as helpers from './gene-comparison-tool.helpers'; + +// import { +// GeneComparisonToolScorePanelComponent as ScorePanelComponent, +// GeneComparisonToolDetailsPanelComponent as DetailsPanelComponent, +// GeneComparisonToolFilterPanelComponent as FilterPanelComponent, +// GeneComparisonToolPinnedGenesModalComponent as PinnedGenesModalComponent, +// GeneComparisonToolFilterListComponent, +// GeneComparisonToolFilterPanelComponent, +// GeneComparisonToolDetailsPanelComponent, +// GeneComparisonToolScorePanelComponent, +// GeneComparisonToolHowToPanelComponent, +// GeneComparisonToolLegendPanelComponent, +// GeneComparisonToolPinnedGenesModalComponent, +// } from './components'; +import { GeneComparisonToolScorePanelComponent as ScorePanelComponent } from './components/gene-comparison-tool-score-panel/gene-comparison-tool-score-panel.component'; +import { GeneComparisonToolDetailsPanelComponent as DetailsPanelComponent } from './components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component'; +import { GeneComparisonToolFilterPanelComponent as FilterPanelComponent } from './components/gene-comparison-tool-filter-panel/gene-comparison-tool-filter-panel.component'; +import { GeneComparisonToolPinnedGenesModalComponent as PinnedGenesModalComponent } from './components/gene-comparison-tool-pinned-genes-modal/gene-comparison-tool-pinned-genes-modal.component'; + +import { GeneComparisonToolScorePanelComponent } from './components/gene-comparison-tool-score-panel/gene-comparison-tool-score-panel.component'; +import { GeneComparisonToolDetailsPanelComponent } from './components/gene-comparison-tool-details-panel/gene-comparison-tool-details-panel.component'; +import { GeneComparisonToolFilterPanelComponent } from './components/gene-comparison-tool-filter-panel/gene-comparison-tool-filter-panel.component'; +import { GeneComparisonToolPinnedGenesModalComponent } from './components/gene-comparison-tool-pinned-genes-modal/gene-comparison-tool-pinned-genes-modal.component'; + +import { DropdownModule } from 'primeng/dropdown'; +import { InputSwitchModule } from 'primeng/inputswitch'; +import { OverlayPanelModule } from 'primeng/overlaypanel'; +import { FormsModule } from '@angular/forms'; +import { GeneComparisonToolHowToPanelComponent } from './components/gene-comparison-tool-how-to-panel/gene-comparison-tool-how-to-panel.component'; +import { GeneComparisonToolLegendPanelComponent } from './components/gene-comparison-tool-legend-panel/gene-comparison-tool-legend-panel.component'; +import { GeneComparisonToolFilterListComponent } from './components/gene-comparison-tool-filter-list/gene-comparison-tool-filter-list.component'; +import { OverlayPanelLinkComponent } from '@sagebionetworks/agora/genes'; +import { SvgIconComponent } from '@sagebionetworks/agora/shared'; + +@Component({ + selector: 'agora-gene-comparison-tool', + standalone: true, + imports: [ + CommonModule, + FormsModule, + RouterModule, + TableModule, + TooltipModule, + DropdownModule, + InputSwitchModule, + OverlayPanelLinkComponent, + OverlayPanelModule, + SvgIconComponent, + GeneComparisonToolHowToPanelComponent, + GeneComparisonToolLegendPanelComponent, + GeneComparisonToolFilterListComponent, + GeneComparisonToolScorePanelComponent, + GeneComparisonToolDetailsPanelComponent, + GeneComparisonToolFilterPanelComponent, + GeneComparisonToolPinnedGenesModalComponent, + ], + providers: [GenesService, DistributionService, HelperService, MessageService, FilterService], + templateUrl: './gene-comparison-tool.component.html', + styleUrls: ['./gene-comparison-tool.component.scss'], +}) +export class GeneComparisonToolComponent implements OnInit, AfterViewInit, OnDestroy { + router = inject(Router); + route = inject(ActivatedRoute); + geneService = inject(GenesService); + distributionService = inject(DistributionService); + helperService = inject(HelperService); + messageService = inject(MessageService); + filterService = inject(FilterService); + + /* Genes ----------------------------------------------------------------- */ + genes: GCTGene[] = []; + + /* Categories ------------------------------------------------------------ */ + categories: GCTSelectOption[] = cloneDeep(variables.categories); + category: 'RNA - Differential Expression' | 'Protein - Differential Expression'; + subCategories: GCTSelectOption[] = []; + subCategory = ''; + subCategoryLabel = ''; + + /* Columns --------------------------------------------------------------- */ + columns: string[] = []; + columnWidth = 'auto'; + + scoresColumns: GCTColumn[] = [ + { field: 'RISK SCORE', header: 'AD Risk Score', selected: true, visible: true }, + { field: 'GENETIC', header: 'Genetic Risk Score', selected: true, visible: true }, + { field: 'MULTI-OMIC', header: 'Multi-omic Risk Score', selected: true, visible: true }, + ]; + + brainRegionsColumns: GCTColumn[] = [ + { field: 'ACC', header: 'ACC - Anterior Cingulate Cortex', selected: true, visible: true }, + { field: 'CBE', header: 'CBE - Cerebellum', selected: true, visible: true }, + { + field: 'DLPFC', + header: 'DLPFC - Dorsolateral Prefrontal Cortex', + selected: true, + visible: true, + }, + { field: 'FP', header: 'FP - Frontal Pole', selected: true, visible: true }, + { field: 'IFG', header: 'IFG - Inferior Frontal Gyrus', selected: true, visible: true }, + { field: 'PCC', header: 'PCC - Posterior Cingulate Cortex', selected: true, visible: true }, + { field: 'PHG', header: 'PHG - Parahippocampal Gyrus', selected: true, visible: true }, + { field: 'STG', header: 'STG - Superior Temporal Gyrus', selected: true, visible: true }, + { field: 'TCX', header: 'TCX - Temporal Cortex', selected: true, visible: true }, + ]; + + scoresDistribution: OverallScoresDistribution[] = []; + + /* Sort ------------------------------------------------------------------ */ + sortField = ''; + sortOrder = -1; + + /* Filters --------------------------------------------------------------- */ + filters: GCTFilter[] = cloneDeep(variables.filters); + searchTerm = ''; + + // /* URL ------------------------------------------------------------------- */ + urlParams: { [key: string]: any } | undefined; + urlParamsSubscription: Subscription | undefined; + + // /* Pinned ---------------------------------------------------------------- */ + initialLoad = true; + + lastPinnedCategory = ''; + lastPinnedSubCategory = ''; + + pinnedItems: GCTGene[] = []; + uniquePinnedGenesCount = 0; + pinnedItemsCache: GCTGene[] = []; + pendingPinnedItems: GCTGene[] = []; + maxPinnedGenes = 50; + + /* ----------------------------------------------------------------------- */ + private DEFAULT_SIGNIFICANCE_THRESHOLD = 0.05; + significanceThreshold = this.DEFAULT_SIGNIFICANCE_THRESHOLD; + significanceThresholdActive = false; + + /* Components ------------------------------------------------------------ */ + @ViewChild('headerTable', { static: true }) headerTable!: Table; + @ViewChild('pinnedTable', { static: true }) pinnedTable!: Table; + @ViewChild('genesTable', { static: true }) genesTable!: Table; + + @ViewChild('filterPanel') filterPanel!: FilterPanelComponent; + @ViewChild('detailsPanel') detailsPanel!: DetailsPanelComponent; + @ViewChild('scorePanel') scorePanel!: ScorePanelComponent; + @ViewChild('pinnedGenesModal') pinnedGenesModal!: PinnedGenesModalComponent; + + constructor() { + this.category = 'RNA - Differential Expression'; + } + + ngOnInit() { + this.urlParamsSubscription = this.route.queryParams.subscribe((params) => { + this.urlParams = params || {}; + + this.category = this.urlParams['category'] || this.categories[0].value; + this.subCategory = this.urlParams['subCategory'] || ''; + this.updateSubCategories(); + + this.sortField = this.urlParams['sortField'] || ''; + this.sortOrder = '1' === this.urlParams['sortOrder'] ? 1 : -1; + + this.significanceThreshold = + this.urlParams['significance'] || this.DEFAULT_SIGNIFICANCE_THRESHOLD; + this.significanceThresholdActive = !!this.urlParams['significance']; + + this.loadGenes(); + }); + + this.filterService.register('intersect', helpers.intersectFilterCallback); + this.filterService.register( + 'exclude_ensembl_gene_id', + helpers.excludeEnsemblGeneIdFilterCallback, + ); + this.filterService.register('exclude_uniprotid', helpers.excludeUniprotIdCallback); + } + + ngAfterViewInit() { + setTimeout(() => { + this.updateColumnWidth(); + }, 1); + } + + ngOnDestroy() { + this.urlParamsSubscription?.unsubscribe(); + } + + isScoresColumn(column: string) { + const isScore = this.scoresColumns.find((c) => c.field === column); + return isScore !== undefined; + } + + toggleGCTColumn(column: GCTColumn) { + column.selected = !column.selected; + this.updateVisibleColumns(); + this.onResize(); + } + + updateVisibleColumns() { + const visibleScoresColumns: string[] = this.scoresColumns + .filter((c) => c.visible && c.selected) + .map((c) => c.field); + const visibleBrainRegionColumns: string[] = this.brainRegionsColumns + .filter((c) => c.visible && c.selected) + .map((c) => c.field); + this.columns = visibleScoresColumns.concat(visibleBrainRegionColumns); + } + + public isNumber(value: string | number): boolean { + return value != null && value !== '' && !isNaN(Number(value.toString())); + } + + /* ----------------------------------------------------------------------- */ + /* Genes + /* ----------------------------------------------------------------------- */ + + getScoreForNumericColumn(columnName: string, gene: GCTGene) { + if (columnName === this.scoresColumns[0].field) { + return gene.target_risk_score; + } + if (columnName === this.scoresColumns[1].field) { + return gene.genetics_score; + } + if (columnName === this.scoresColumns[2].field) { + return gene.multi_omics_score; + } + return null; + } + + loadGenes() { + this.helperService.setLoading(true); + this.genes = []; + + const genesApi$ = this.geneService.getComparisonGenes(this.category, this.subCategory); + const distributionApi$ = this.distributionService.getDistribution(); + + combineLatest([genesApi$, distributionApi$]).subscribe(([genesResult, distributionResult]) => { + if (genesResult.items) { + this.initData(genesResult.items); + this.sortTable(this.headerTable); + this.refresh(); + + this.scoresDistribution = distributionResult.overall_scores; + + this.helperService.setLoading(false); + } + }); + } + + getGeneProperty(gene: GCTGene, property: string) { + return property.split('.').reduce((o: any, i: any) => o[i], gene); + } + + getUid(item: GCTGene) { + // rna is just the ensembl gene id + // protein is a combination of ensembl gene id and uniprotid + if (this.category === 'RNA - Differential Expression') return item.ensembl_gene_id; + else return item.ensembl_gene_id + item.uniprotid; + } + + // when current category is RNA, this method will always return a list of ensembl gene ids, regardless of previous category + // when current category is Protein, this method will return either ensg (if previous category was RNA) or ensg + uniprot (if previous category was Protein) + getPreviousPins() { + // if the last pinned category is blank, then this means this is the initial load + // note: we only need to check the category for blank but subcategory will also be blank + // In this scenario, we check the url to see if this was a shared url with pinned genes/proteins + if (this.lastPinnedCategory === '') { + // check the url for pinned genes/proteins + this.setLastPinnedCategories(); + return this.getUrlParam('pinned', true); + } + + if (this.currentCategoriesMatchLastPinnedCategories()) { + // load from cache since it has been previously cached + // uid works for both proten and rna cases + return this.pinnedItemsCache.map((g: GCTGene) => g.uid); + } else { + // categories don't match, so grab it from the cache and format it + if (this.category === 'RNA - Differential Expression') { + // if the current category is RNA, we only need the previous ensgs + // instead of getting the uid, we need to get the ensg + return this.pinnedItemsCache.map((g: GCTGene) => g.ensembl_gene_id); + } else { + // if the current category is Protein, we need the uid + // because the previous category and subcategory may or may not match + // e.g. same category and different subcategory OR different category + return this.pinnedItemsCache.map((g: GCTGene) => g.uid); + } + } + } + + initData(items: GCTGene[]) { + // hide brain region columns initially + this.brainRegionsColumns.forEach((c) => (c.visible = false)); + + const itemsToPin: GCTGene[] = []; + + // load the previous pins and format previousPins + const previousPins = this.getPreviousPins(); + + items.forEach((item: GCTGene) => { + item.uid = this.getUid(item); + item.search_array = [item.ensembl_gene_id.toLowerCase(), item.hgnc_symbol.toLowerCase()]; + + if (this.category === 'Protein - Differential Expression') { + item.search_array.push(item.uniprotid?.toLowerCase() || ''); + + // if there is a match on uid or ensembl_gene_id, add it to pinnedGenes + // if it wasn't added already + if (this.lastPinnedCategory === 'RNA - Differential Expression') { + // previousPins would be a list of ensg + if (previousPins.includes(item.ensembl_gene_id)) itemsToPin.push(item); + } else { + // previousPins would be a list of ensg+uniprotids + if (previousPins.includes(item.uid)) itemsToPin.push(item); + } + } else { + if (previousPins.includes(item.uid)) { + itemsToPin.push(item); + } + } + + item.search_string = item.search_array.join(); + + // apply filters + this.filters.forEach((filter: GCTFilter) => { + if (!filter.field) { + return; + } + + const value = this.getGeneProperty(item, filter.field); + + if (value) { + if (Array.isArray(value)) { + value.forEach((v: any) => { + this.setFilterOption(filter.name, v); + }); + } else { + this.setFilterOption(filter.name, value); + } + } + }); + + // add tissue columns + item.tissues?.forEach((tissue: GCTGeneTissue) => { + const column = this.brainRegionsColumns.find((c) => c.field === tissue.name); + if (column) column.visible = true; + }); + }); + + // on initial load, we want to cache any items + if (this.initialLoad) { + this.initialLoad = false; + this.setPinnedItemsCache(itemsToPin); + } + + this.uniquePinnedGenesCount = this.getCountOfUniqueGenes(); + + this.updateVisibleColumns(); + + if (!this.sortField || !this.columns.includes(this.sortField)) { + this.sortField = this.columns[0]; + this.sortOrder = -1; + } + + const preSelection = this.helperService.getGCTSelection(); + this.helperService.deleteGCTSelection(); + if (preSelection?.length) { + this.searchTerm = preSelection.join(','); + } + + if (itemsToPin.length) { + itemsToPin.sort((a, b) => (a.ensembl_gene_id > b.ensembl_gene_id ? 1 : -1)); + + if ( + 'Protein - Differential Expression' === this.category && + this.uniquePinnedGenesCount > this.maxPinnedGenes + ) { + this.pendingPinnedItems = itemsToPin; + this.pinnedGenesModal.show(); + } else { + this.pinnedItems = []; + this.pendingPinnedItems = []; + this.uniquePinnedGenesCount = this.getCountOfUniqueGenes(); + this.pinGenes(itemsToPin); + } + } else { + this.pinnedItems = []; + } + + this.genes = items; + } + + /* ----------------------------------------------------------------------- */ + /* Categories + /* ----------------------------------------------------------------------- */ + + updateSubCategories() { + // update subcategory label text + if ('Protein - Differential Expression' === this.category) { + this.subCategoryLabel = 'Profiling Method'; + } else { + this.subCategoryLabel = 'Models'; + } + + this.subCategories = cloneDeep(variables.subCategories)[this.category]; + + // default to first option if subcategory not defined/found + if ( + !this.subCategory || + !this.subCategories.find((c: GCTSelectOption) => c.value === this.subCategory) + ) { + this.subCategory = this.subCategories[0]?.value; + } + } + + onCategoryChange() { + this.updateSubCategories(); + this.loadGenes(); + } + + onSubCategoryChange() { + this.loadGenes(); + } + + /* ----------------------------------------------------------------------- */ + /* Significance Threshold + /* ----------------------------------------------------------------------- */ + + setSignificanceThresholdActive(significanceThresholdActive: boolean) { + this.significanceThresholdActive = significanceThresholdActive; + this.filter(); + this.updateUrl(); + } + + /* ----------------------------------------------------------------------- */ + /* Filters + /* ----------------------------------------------------------------------- */ + + setFilters(filters: any) { + this.filters = filters; + this.filter(); + this.updateUrl(); + } + + getFilterValues() { + const values: { [key: string]: string | number | string[] | number[] } = {}; + + for (const filter of this.filters) { + const value: string[] = []; + for (const option of filter.options.filter((o) => o.selected)) { + value.push(option.value); + } + if (value.length) { + values[filter.name] = value; + } + } + + return values; + } + + setFilterOption(name: string, value: string | number | string[] | number[]) { + const filter = this.filters.find((f) => f.name === name); + + if (!filter || !value) { + return; + } + + const option = filter?.options.find((option) => value === option.value); + const urlParam = filter ? this.getUrlParam(filter.name, true) : []; + const isSelected = + urlParam && urlParam.indexOf(typeof value === 'string' ? value : String(value)) !== -1; + + if (!option) { + filter.options.push({ + label: helpers.filterOptionLabel(value), + value, + selected: isSelected, + }); + + filter.options.sort((a, b) => { + if (a.label < b.label) { + return -1; + } else if (a.label > b.label) { + return 1; + } + return 0; + }); + + if (filter.order === 'DESC') { + filter.options.reverse(); + } + } else if (isSelected) { + option.selected = isSelected; + } + } + + hasSelectedFilters() { + for (const filter of this.filters) { + if (filter.options.find((option) => option.selected)) { + return true; + } + } + return false; + } + + setSearchTerm(term: string) { + this.searchTerm = term; + this.filter(); + } + + clearSearch() { + this.searchTerm = ''; + this.filter(); + } + + filter() { + let filters: { [key: string]: any }; + + if (this.category === 'RNA - Differential Expression') { + filters = { + ensembl_gene_id: { + value: this.getPinnedEnsemblGeneIds(), + matchMode: 'exclude_ensembl_gene_id', + }, + }; + } else { + filters = { + uniprotid: { + value: this.getPinnedUniProtIds(), + matchMode: 'exclude_uniprotid', + }, + }; + } + + if (this.searchTerm) { + if (this.searchTerm.indexOf(',') !== -1) { + const terms = this.searchTerm + .toLowerCase() + .split(',') + .map((t: string) => t.trim()); + filters['search_array'] = { + value: terms, + matchMode: 'intersect', + }; + } else { + filters['search_string'] = { + value: this.searchTerm.toLowerCase(), + matchMode: 'contains', + }; + } + } + + this.filters.forEach((filter) => { + if (!filter.field) { + return; + } + + const values = filter.options + .filter((option) => option.selected) + .map((selected) => selected.value); + + if (values.length) { + filters[filter.field] = { + value: values, + matchMode: filter.matchMode || 'equals', + }; + } + }); + + const filterChanged = + JSON.stringify({ ...filters, ...{ ensembl_gene_id: '' } }) !== + JSON.stringify({ + ...this.genesTable.filters, + ...{ ensembl_gene_id: '' }, + }); + + const currentPage = this.genesTable._first; + + this.genesTable.filters = filters; + this.genesTable._filter(); + + // Restoring current pagination if filters didn't change + if (!filterChanged) { + this.genesTable._first = currentPage; + } + } + + /* ----------------------------------------------------------------------- */ + /* Sort + /* ----------------------------------------------------------------------- */ + + setSort(event: GCTSortEvent) { + this.sortField = event.field; + this.sortOrder = event.order; + this.sort(); + this.updateUrl(); + } + + sortCallback(event: SortEvent) { + const order = event.order || 1; + if (!event.field || !event.data) { + return; + } + const isScoresColumnSort = this.scoresColumns.find((c) => c.field === event.field); + if (isScoresColumnSort) { + // if it is one of the numeric scores + event.data.sort((a, b) => { + const value1 = this.getScoreForNumericColumn(event.field as string, a); + const value2 = this.getScoreForNumericColumn(event.field as string, b); + + if (value1 === value2) return 0; // equal so don't do anything + if (value1 === null) return 1; // sort null after everything + if (value2 === null) return -1; // sort null after everything + + const result = value1 < value2 ? -1 : 1; + return order * result; + }); + } else { + //it's one of the tissues + event.data.sort((a, b) => { + let result = null; + + a = a.tissues.find((tissue: GCTGeneTissue) => tissue.name === event.field)?.logfc; + + b = b.tissues.find((tissue: GCTGeneTissue) => tissue.name === event.field)?.logfc; + + if (a == null && b != null) result = 1 * order; + else if (a != null && b == null) result = -1 * order; + else if (a == null && b == null) result = 0; + else result = a < b ? -1 : a > b ? 1 : 0; + + return order * result; + }); + } + } + + sortTable(table: Table) { + table.sortField = ''; + table.defaultSortOrder = this.sortOrder; + table.sort({ field: this.sortField }); + } + + sort() { + this.sortTable(this.pinnedTable || null); + this.sortTable(this.genesTable || null); + } + + /* ----------------------------------------------------------------------- */ + /* Pin/Unpin + /* ----------------------------------------------------------------------- */ + + currentCategoriesMatchLastPinnedCategories() { + // returns if the current categories match the last pinned categories + return ( + this.lastPinnedCategory === this.category && this.lastPinnedSubCategory === this.subCategory + ); + } + + setLastPinnedCategories() { + this.lastPinnedCategory = this.category; + this.lastPinnedSubCategory = this.subCategory; + } + + getPinnedGenesCacheKey(category: string, subCategory?: string) { + return (category + (subCategory ? '-' + subCategory : '')) + .replace(/[^a-z0-9]/gi, '') + .toLowerCase(); + } + + setPinnedItemsCache(genes: GCTGene[]) { + this.pinnedItemsCache = genes; + } + + clearPinnedItemsCache() { + this.pinnedItemsCache = []; + this.uniquePinnedGenesCount = this.getCountOfUniqueGenes(); + } + + refreshPinnedGenes() { + this.setPinnedItemsCache(this.pinnedItems); + this.filter(); + this.updateUrl(); + } + + onPinGeneClick(gene: GCTGene) { + // user-initiated gene pin means we set the last pinned categories + this.setLastPinnedCategories(); + + this.pinGene(gene); + + if (this.category === 'Protein - Differential Expression') + this.uniquePinnedGenesCount = this.getCountOfUniqueGenes(); + } + + pinGene(gene: GCTGene, refresh = true) { + const index = this.pinnedItems.findIndex((g: GCTGene) => g.uid === gene.uid); + if (this.category === 'RNA - Differential Expression') { + if (index > -1 || this.pinnedItems.length >= this.maxPinnedGenes) return; + } else { + // the same unique id exists, so don't allow it to be added + if (index > -1) return; + + if (this.uniquePinnedGenesCount >= this.maxPinnedGenes) { + // border condition: if we are at the max allowable pinned genes + // check if the pinned genes list has a gene with the ensembl id, + // in which case the protein can be added + const ensemblIndex = this.pinnedItems.findIndex( + (g: GCTGene) => g.ensembl_gene_id === gene.ensembl_gene_id, + ); + if (ensemblIndex < 0) { + this.showUnableToAddItemErrorToast(); + return; + } + } + } + + this.pinnedItems.push(gene); + this.uniquePinnedGenesCount = this.getCountOfUniqueGenes(); + + if (refresh) { + this.clearPinnedItemsCache(); + this.refreshPinnedGenes(); + } + } + + getCountOfUniqueGenes() { + // this method is used for protein views since there can be multiple pinned proteins + // that have the same ensg value but different uniprotids + // so this will return the count of genes with unique ensgs + const uids = this.pinnedItems.map((g) => g.ensembl_gene_id); + const uniqueUids = new Set(uids); + return uniqueUids.size; + } + + showMaxPinnedRowsErrorToast(rows: number) { + let message = ''; + if (rows === 0) { + message = + 'No rows were pinned because you reached the maximum of ' + + this.maxPinnedGenes + + ' pinned genes.'; + } else if (rows === 1) { + message = + 'Only ' + + rows + + ' row were pinned, because you reached the maximum of ' + + this.maxPinnedGenes + + ' pinned genes.'; + } else { + message = + 'Only ' + + rows + + ' rows were pinned, because you reached the maximum of ' + + this.maxPinnedGenes + + ' pinned genes.'; + } + + this.messageService.clear(); + this.messageService.add({ + severity: 'warn', + sticky: true, + summary: '', + detail: message, + }); + setTimeout(() => { + this.messageService.clear(); + }, 5000); + } + + pinGenes(genes: GCTGene[]) { + const remaining = this.maxPinnedGenes - this.pinnedItems.length; + + if (remaining < 1) { + return; + } else { + if (this.category === 'RNA - Differential Expression') { + if (remaining < genes?.length) { + this.showMaxPinnedRowsErrorToast(remaining); + } + genes.slice(0, remaining).forEach((g: GCTGene) => { + this.pinGene(g, false); + }); + } else { + genes.slice(0, genes.length).forEach((g: GCTGene) => { + this.pinGene(g, false); + }); + } + } + + if (this.category === 'Protein - Differential Expression') + this.uniquePinnedGenesCount = this.getCountOfUniqueGenes(); + } + + showUnableToAddItemErrorToast() { + this.messageService.clear(); + this.messageService.add({ + severity: 'warn', + sticky: true, + summary: '', + detail: + 'The row was not pinned because you reached the maximum of ' + + this.maxPinnedGenes + + ' pinned genes.', + }); + setTimeout(() => { + this.messageService.clear(); + }, 5000); + } + + ensgExistsInProteins(ensemblGeneId: string) { + const ensemblIndex = this.pinnedItems.findIndex( + (g: GCTGene) => g.ensembl_gene_id === ensemblGeneId, + ); + if (ensemblIndex < 0) { + return false; + } + return true; + } + + pinProteins(proteins: GCTGene[]) { + let remaining = this.maxPinnedGenes - this.uniquePinnedGenesCount; + + let proteinsAdded = 0; + + let showToast = false; + for (let i = 0; i < proteins.length; i++) { + // if remaining count is zero, show alert toast + if (remaining <= 0) { + // check border condition: when there are no remaining ensg slots, it is still possible there + // are proteins that could be added + showToast = true; + if (this.ensgExistsInProteins(proteins[i].ensembl_gene_id)) { + // if the gene exists, we can still add the protein + this.pinGene(proteins[i], false); + proteinsAdded++; + remaining = this.maxPinnedGenes - this.getCountOfUniqueGenes(); + } + } else { + // add protein to pinned collection + this.pinGene(proteins[i], false); + proteinsAdded++; + // have to call method below since we need to recompute the count of unique genes + remaining = this.maxPinnedGenes - this.getCountOfUniqueGenes(); + } + } + if (showToast) { + this.showMaxPinnedRowsErrorToast(proteinsAdded); + } + this.uniquePinnedGenesCount = this.getCountOfUniqueGenes(); + } + + onUnPinGeneClick(gene: GCTGene, refresh = true) { + this.setLastPinnedCategories(); + + const index = this.pinnedItems.findIndex((g: GCTGene) => g.uid === gene.uid); + + if (index === -1) { + return; + } + + this.pinnedItems.splice(index, 1); + + if (refresh) { + this.clearPinnedItemsCache(); + this.refreshPinnedGenes(); + } + + if (this.category === 'Protein - Differential Expression') + this.uniquePinnedGenesCount = this.getCountOfUniqueGenes(); + } + + onClearAllClick() { + this.setLastPinnedCategories(); + this.clearPinnedGenes(); + } + + clearPinnedGenes() { + this.pinnedItems = []; + this.clearPinnedItemsCache(); + this.refreshPinnedGenes(); + } + + getPinnedEnsemblGeneIds() { + return this.pinnedItems.map((g: GCTGene) => g.ensembl_gene_id); + } + + getPinnedUniProtIds() { + return this.pinnedItems.map((g: GCTGene) => g.uniprotid); + } + + getPinDisabledStatus() { + if (this.category === 'RNA - Differential Expression') + return this.pinnedItems.length >= this.maxPinnedGenes; + else { + // default to showing pin/pin all button for protein view + return false; + } + } + + onPinAllClick() { + this.setLastPinnedCategories(); + if (this.category === 'RNA - Differential Expression') this.pinFilteredGenes(); + else this.pinFilteredProteins(); + } + + pinFilteredGenes() { + this.pinGenes(this.genesTable.filteredValue as GCTGene[]); + this.refreshPinnedGenes(); + } + + pinFilteredProteins() { + this.pinProteins(this.genesTable.filteredValue as GCTGene[]); + this.refreshPinnedGenes(); + } + + onPinnedGenesModalChange(response: boolean) { + if (response) { + this.pinnedItems = []; + this.pinGenes(this.pendingPinnedItems); + } else { + this.category = this.categories[0].value; + this.onCategoryChange(); + } + this.pendingPinnedItems = []; + } + + /* ----------------------------------------------------------------------- */ + /* URL + /* ----------------------------------------------------------------------- */ + + getUrlParam(name: string, returnArray = false) { + if (this.urlParams && this.urlParams[name]) { + return returnArray && typeof this.urlParams[name] === 'string' + ? this.urlParams[name].split(',') + : this.urlParams[name]; + } + return returnArray ? [] : null; + } + + updateUrl() { + const params: { [key: string]: any } = this.getFilterValues(); + + if (this.category !== this.categories[0].value) { + params['category'] = this.category; + } + + if (this.subCategory !== this.subCategories[0]?.value) { + params['subCategory'] = this.subCategory; + } + + if (this.sortField && this.sortField !== this.columns[0]) { + params['sortField'] = this.sortField; + } + + if (this.sortOrder != -1) { + params['sortOrder'] = this.sortOrder; + } + + if (this.pinnedItems.length > 0) { + params['pinned'] = this.pinnedItems.map((g: GCTGene) => g.uid); + params['pinned'].sort(); + } + + if (this.significanceThresholdActive) { + params['significance'] = this.significanceThreshold; + } + + this.urlParams = params; + + let url = this.router.serializeUrl(this.router.createUrlTree(['/genes/comparison'])); + + if (Object.keys(params).length > 0) { + url += '?' + new URLSearchParams(params); + } + + window.history.pushState(null, '', url); + } + + copyUrl() { + navigator.clipboard.writeText(window.location.href); + this.messageService.clear(); + this.messageService.add({ + severity: 'info', + sticky: true, + summary: '', + detail: + 'URL copied to clipboard! Use this URL to share or bookmark the current table configuration.', + }); + setTimeout(() => { + this.messageService.clear(); + }, 5000); + } + + /* ----------------------------------------------------------------------- */ + /* Details Panel + /* ----------------------------------------------------------------------- */ + + getDetailsPanelData(tissueName: string, gene: GCTGene) { + const tissue: any = gene.tissues.find((t) => t.name === tissueName); + if (tissue) { + return helpers.getDetailsPanelData(this.category, this.subCategory, gene, tissue); + } + return; + } + + /* ----------------------------------------------------------------------- */ + /* Score Panel + /* ----------------------------------------------------------------------- */ + + getScorePanelData( + columnName: string, + gene: GCTGene, + scoresDistributions: OverallScoresDistribution[] | undefined, + ) { + // get the scores distribution for the column and row clicked + if (!scoresDistributions) { + return; + } + return helpers.getScorePanelData(columnName, gene, scoresDistributions); + } + + /* ----------------------------------------------------------------------- */ + /* Circles + /* ----------------------------------------------------------------------- */ + + nRoot(x: number, n: number) { + try { + const negate = n % 2 === 1 && x < 0; + if (negate) { + x = -x; + } + const possible = Math.pow(x, 1 / n); + n = Math.pow(possible, n); + if (Math.abs(x - n) < 1 && x > 0 === n > 0) { + return negate ? -possible : possible; + } + return; + } catch (e) { + return; + } + } + + getCircleColor(logfc: number | undefined) { + if (logfc === undefined) return '#F0F0F0'; + + const rounded = this.helperService.getSignificantFigures(logfc, 3); + if (rounded > 0) { + if (rounded < 0.1) { + return '#B5CBEF'; + } else if (rounded < 0.2) { + return '#84A5DB'; + } else if (rounded < 0.3) { + return '#5E84C3'; + } else if (rounded < 0.4) { + return '#3E68AA'; + } else { + return '#245299'; + } + } else { + if (rounded > -0.1) { + return '#FBB8C5'; + } else if (rounded > -0.2) { + return '#F78BA0'; + } else if (rounded > -0.3) { + return '#F16681'; + } else if (rounded > -0.4) { + return '#EC4769'; + } else { + return '#D72247'; + } + } + } + + getCircleSize(pval: number | null | undefined) { + // define min and max size of possible circles in pixels + const MIN_SIZE = 6; + const MAX_SIZE = 50; + + // pval shouldn't be undefined but if it is, don't show a circle + // null means there is no data in which case, also don't show a circle + if (pval === null || pval === undefined) return 0; + + // if significance cutoff radio button selected and + // p-Value > significance threshhold, don't show + if (this.significanceThresholdActive && pval > this.significanceThreshold) { + return 0; + } + + const pValue = 1 - (this.nRoot(pval, 3) || 0); + const size = Math.round(pValue * MAX_SIZE); + + // ensure the smallest circles have a min size to be easily hoverable/clickable + return size < MIN_SIZE ? MIN_SIZE : size; + } + + getCircleStyle(tissueName: string, gene: GCTGene) { + const tissue = gene.tissues.find((t) => t.name === tissueName); + const size = this.getCircleSize(tissue?.adj_p_val); + const color = this.getCircleColor(tissue?.logfc); + + return { + display: size > 0 ? 'block' : 'none', + width: size + 'px', + height: size + 'px', + backgroundColor: color, + }; + } + + getCircleClass(tissueName: string, gene: GCTGene) { + let classes = 'gene-indicator'; + const tissue = gene.tissues.find((t) => t.name === tissueName); + + if (tissue) { + if (tissue.logfc) { + if (tissue.logfc >= 0) { + classes += ' plus'; + } else { + classes += ' minus'; + } + } + } + + return classes; + } + + getCircleTooltip(tissueName: string, gene: GCTGene) { + const tissue = gene.tissues.find((t) => t.name === tissueName); + + if (tissue) { + return ( + 'L2FC: ' + + this.helperService.getSignificantFigures(tissue.logfc, 3) + + '\n' + + 'P-value: ' + + this.helperService.getSignificantFigures(tissue.adj_p_val, 3) + + '\n\n' + + 'Click for more details' + ); + } + + return ''; + } + + isCircleTooltipDisabled() { + return ( + this.detailsPanel?.panels?.first?.overlayVisible || + this.detailsPanel?.panels?.last?.overlayVisible || + false + ); + } + + /* ----------------------------------------------------------------------- */ + /* Download pinned genes as CSV + /* ----------------------------------------------------------------------- */ + + downloadPinnedCsv() { + const columnHeaders = [ + 'ensembl_gene_id', + 'hgnc_symbol', + 'target_risk_score', + 'multi_omic_risk_score', + 'genetic_risk_score', + 'Protein - Differential Expression' === this.category ? 'uniprotid' : 'model', + 'tissue', + 'log2_fc', + 'ci_upr', + 'ci_lwr', + 'adj_p_val', + 'biodomains', + ]; + const data: any[][] = []; + + this.pinnedItems.forEach((g: GCTGene) => { + const baseRow = [ + g.ensembl_gene_id, + g.hgnc_symbol, + this.returnEmptyStringIfNull(g.target_risk_score), + this.returnEmptyStringIfNull(g.multi_omics_score), + this.returnEmptyStringIfNull(g.genetics_score), + ]; + + if ('Protein - Differential Expression' === this.category) { + baseRow.push(g.uniprotid || ''); + } else { + baseRow.push(this.subCategory); + } + + this.columns.forEach((tissueName: string) => { + if (this.isScoresColumn(tissueName)) { + return; + } + const tissue: GCTGeneTissue | undefined = g.tissues.find((t) => t.name === tissueName); + data.push([ + ...baseRow, + ...[ + tissueName, + tissue ? tissue.logfc : '', + tissue ? tissue.ci_r : '', + tissue ? tissue.ci_l : '', + tissue ? tissue.adj_p_val : '', + g.biodomains?.join(',') || '', + ], + ]); + }); + }); + + let csv = ''; + csv = this.arrayToCSVString(columnHeaders); + + data.forEach((row) => { + csv += this.arrayToCSVString(row); + }); + + const blob = new Blob([csv], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + const filename = (this.category + '-' + this.subCategory) + .toLowerCase() + .replace(/( -)|[()]/gi, '') + .replace(/ /gi, '-'); + a.setAttribute('href', url); + a.setAttribute('download', filename + '.csv'); + a.click(); + } + + returnEmptyStringIfNull(val: number | null) { + return val === null ? '' : val; + } + + arrayToCSVString(values: string[]): string { + return values.map((value) => `"${value}"`).join(',') + '\n'; + } + + /* ----------------------------------------------------------------------- */ + /* Utils + /* ----------------------------------------------------------------------- */ + + refresh() { + this.sort(); + this.filter(); + this.updateColumnWidth(); + } + + navigateToGene(gene: GCTGene) { + const url = this.router.serializeUrl( + this.router.createUrlTree(['/genes/' + gene.ensembl_gene_id]), + ); + + window.open(url, '_blank'); + } + + getGCTColumnTooltipText(columnName: string) { + return this.helperService.getGCTColumnTooltipText(columnName); + } + + getGCTColumnSortIconTooltipText(columnName: string) { + return this.helperService.getGCTColumnSortIconTooltipText(columnName); + } + + onSearchInput(event: Event) { + const el = event?.target as HTMLTextAreaElement; + this.setSearchTerm(el.value); + } + + updateColumnWidth() { + const count = this.columns.length < 5 ? 5 : this.columns.length; + const width = this.headerTable?.containerViewChild?.nativeElement?.offsetWidth || 0; + this.columnWidth = Math.ceil((width - 300) / count) + 'px'; + } + + onResize() { + this.updateColumnWidth(); + } + + getRoundedGeneData(gene: GCTDetailsPanelData) { + return { + l2fc: this.helperService.getSignificantFigures(gene.value || 0, 3), + pValue: this.helperService.getSignificantFigures(gene.pValue || 0, 3), + }; + } + + navigateToConsistencyOfChange(data: any) { + const baseURL = this.router.createUrlTree([ + '/genes/' + data.gene.ensembl_gene_id + '/evidence/rna', + ]); + + const url = `${baseURL.toString()}/?model=${this.subCategory}#consistency-of-change`; + + window.open(url, '_blank'); + } +} diff --git a/libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.helpers.ts b/libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.helpers.ts new file mode 100644 index 0000000000..1d880c6f7c --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.helpers.ts @@ -0,0 +1,155 @@ +import { + GCTGene, + GCTGeneTissue, + OverallScoresDistribution, +} from '@sagebionetworks/agora/api-client-angular'; +import { GCTScorePanelData } from '@sagebionetworks/agora/models'; + +export const filterOptionLabel = function (value: string | number | string[] | number[]) { + let label = typeof value === 'string' ? value : value.toString(10); + + switch (label) { + case 'and Late Onset Alzheimer"s Disease Family Study': + label = 'Late Onset Alzheimer"s Disease Family Study'; + } + + return label.charAt(0).toUpperCase() + label.slice(1); +}; + +export const intersectFilterCallback = function (value: any, filters: any): boolean { + if (filters === undefined || filters === null || filters.length < 1) { + return true; + } else if (value === undefined || value === null || filters.length < 1) { + return false; + } + + for (const filter of filters) { + if (value.indexOf(filter) !== -1) { + return true; + } + } + + return false; +}; + +export const excludeEnsemblGeneIdFilterCallback = function ( + value: string, + ensemblGeneIds: string[], +): boolean { + return !ensemblGeneIds.includes(value); +}; + +export const excludeUniprotIdCallback = function (value: string, uniprotIds: string[]): boolean { + return !uniprotIds.includes(value); +}; + +export function getScoreName(columnName: string) { + columnName = columnName.toUpperCase(); + if (columnName === 'RISK SCORE') return 'Target Risk Score'; + if (columnName === 'GENETIC') return 'Genetic Risk Score'; + if (columnName === 'MULTI-OMIC') return 'Multi-omic Risk Score'; + return ''; +} + +export function getGeneLabel(gene: GCTGene) { + return (gene.hgnc_symbol ? gene.hgnc_symbol + ' - ' : '') + gene.ensembl_gene_id; +} + +export function getGeneLabelForProteinDifferentialExpression(gene: GCTGene) { + return ( + (gene.hgnc_symbol ? gene.hgnc_symbol + ' ' : '') + + (gene.uniprotid ? '(' + gene.uniprotid + ')' : '') + + ' - ' + + gene.ensembl_gene_id + ); +} + +export function getGeneLabelForSRM(gene: GCTGene) { + let label = gene.hgnc_symbol ? `${gene.hgnc_symbol} - ` : ''; + label += gene.ensembl_gene_id; + return label; +} + +export function getScore(columnName: string, gene: GCTGene) { + columnName = columnName.toUpperCase(); + if (columnName === 'RISK SCORE') return gene.target_risk_score; + if (columnName === 'MULTI-OMIC') return gene.multi_omics_score; + if (columnName === 'GENETIC') return gene.genetics_score; + return null; +} + +export function lookupScoreDataKey(columnName: string | undefined) { + if (!columnName) return; + + columnName = columnName.toUpperCase(); + if (columnName === 'RISK SCORE') return 'TARGET RISK SCORE'; + if (columnName === 'MULTI-OMIC') return 'MULTI-OMIC RISK SCORE'; + if (columnName === 'GENETIC') return 'GENETIC RISK SCORE'; + return ''; +} + +export const getScorePanelData = function ( + columnName: string, + gene: GCTGene, + scoresDistributions: OverallScoresDistribution[] | undefined, +) { + const data: GCTScorePanelData = { + geneLabel: getGeneLabel(gene), + scoreName: getScoreName(columnName), + columnName: columnName, + score: getScore(columnName, gene), + distributions: scoresDistributions, + }; + return data; +}; + +export const getDetailsPanelData = function ( + category: string, + subCategory: string, + gene: GCTGene, + tissue: GCTGeneTissue, +) { + let max = 0; + + gene.tissues.forEach((t: GCTGeneTissue) => { + if (max === undefined || Math.abs(t.ci_l) > max) { + max = Math.abs(t.ci_l); + } + if (max === undefined || t.ci_r > max) { + max = t.ci_r; + } + }); + + max = Math.ceil(max); + + const data = { + gene: gene, + label: '', + heading: '', + subHeading: subCategory, + valueLabel: 'Log 2 Fold Change', + value: tissue?.logfc, + pValue: tissue?.adj_p_val, + min: max * -1, + max: max, + intervalMin: tissue?.ci_l, + intervalMax: tissue?.ci_r, + footer: 'Significance is considered to be an adjusted p-value < 0.05', + allTissueLink: true, + }; + + if (category === 'Protein - Differential Expression') { + if (subCategory === 'SRM') { + data.label = getGeneLabelForSRM(gene); + } else { + data.label = getGeneLabelForProteinDifferentialExpression(gene); + } + data.heading = 'Differential Protein Expression (' + tissue.name + ')'; + data.allTissueLink = false; + } else { + data.label = getGeneLabel(gene); + data.heading = 'Differential RNA Expression (' + tissue.name + ')'; + } + + return data; +}; diff --git a/libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.routes.ts b/libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.routes.ts new file mode 100644 index 0000000000..73a5842a5c --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.routes.ts @@ -0,0 +1,4 @@ +import { Routes } from '@angular/router'; +import { GeneComparisonToolComponent } from './gene-comparison-tool.component'; + +export const routes: Routes = [{ path: '', component: GeneComparisonToolComponent }]; diff --git a/libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.variables.ts b/libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.variables.ts new file mode 100644 index 0000000000..c5ed672b59 --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/lib/gene-comparison-tool.variables.ts @@ -0,0 +1,279 @@ +import { GCTFilter, GCTSelectOption } from '@sagebionetworks/agora/models'; + +export const categories: GCTSelectOption[] = [ + { + label: 'RNA - Differential Expression', + value: 'RNA - Differential Expression', + }, + { + label: 'Protein - Differential Expression', + value: 'Protein - Differential Expression', + }, +]; + +export const subCategories: { [key: string]: GCTSelectOption[] } = { + 'RNA - Differential Expression': [ + { + label: 'AD Diagnosis (males and females)', + value: 'AD Diagnosis (males and females)', + }, + { + label: 'AD Diagnosis x AOD (males and females)', + value: 'AD Diagnosis x AOD (males and females)', + }, + { + label: 'AD Diagnosis x Sex (females only)', + value: 'AD Diagnosis x Sex (females only)', + }, + { + label: 'AD Diagnosis x Sex (males only)', + value: 'AD Diagnosis x Sex (males only)', + }, + ], + 'Protein - Differential Expression': [ + { + label: 'Targeted Selected Reaction Monitoring (SRM)', + value: 'SRM', + }, + { + label: 'Genome-wide Tandem Mass Tag (TMT)', + value: 'TMT', + }, + { + label: 'Genome-wide Label-free Quantification (LFQ)', + value: 'LFQ', + }, + ], +}; + +export const filters: GCTFilter[] = [ + { + name: 'presets', + field: 'none', + label: 'Quick Filters', + description: 'Applying a quick filter will reset your current filter state.', + options: [ + { + label: 'All Nominated targets', + preset: { + nominations: [1, 2, 3, 4, 5], + }, + }, + { + label: 'Genetically Associated with LOAD', + preset: { + associations: [1], + }, + }, + { + label: 'eQTL in Brain', + preset: { + associations: [2], + }, + }, + ], + }, + { + name: 'associations', + field: 'associations', + label: 'Association with AD', + short: 'Association with AD', + description: + 'Filter for genes that are associated with AD based on GWAS, eQTL, and differential expression results.', + matchMode: 'intersect', + options: [ + { + label: 'Genetically Associated with LOAD', + value: 1, + }, + { + label: 'eQTL in Brain', + value: 2, + }, + { + label: 'RNA Expression Changed in AD Brain', + value: 3, + }, + { + label: 'Protein Expression Changed in AD Brain', + value: 4, + }, + ], + }, + { + name: 'biodomain', + field: 'biodomains', + label: 'Biological Domain', + short: 'Biodomain', + description: 'Filter for genes based on the biological domains that they are linked to.', + matchMode: 'intersect', + options: [ + { + label: 'Apoptosis', + value: 'Apoptosis', + }, + { + label: 'APP Metabolism', + value: 'APP Metabolism', + }, + { + label: 'Autophagy', + value: 'Autophagy', + }, + { + label: 'Cell Cycle', + value: 'Cell Cycle', + }, + { + label: 'DNA Repair', + value: 'DNA Repair', + }, + { + label: 'Endolysosome', + value: 'Endolysosome', + }, + { + label: 'Epigenetic', + value: 'Epigenetic', + }, + { + label: 'Immune Response', + value: 'Immune Response', + }, + { + label: 'Lipid Metabolism', + value: 'Lipid Metabolism', + }, + { + label: 'Metal Binding and Homeostasis', + value: 'Metal Binding and Homeostasis', + }, + { + label: 'Mitochondrial Metabolism', + value: 'Mitochondrial Metabolism', + }, + { + label: 'Myelination', + value: 'Myelination', + }, + { + label: 'Oxidative Stress', + value: 'Oxidative Stress', + }, + { + label: 'Proteostasis', + value: 'Proteostasis', + }, + { + label: 'RNA Spliceosome', + value: 'RNA Spliceosome', + }, + { + label: 'Structural Stabilization', + value: 'Structural Stabilization', + }, + { + label: 'Synapse', + value: 'Synapse', + }, + { + label: 'Tau Homeostasis', + value: 'Tau Homeostasis', + }, + { + label: 'Vasculature', + value: 'Vasculature', + }, + ], + }, + { + name: 'studies', + field: 'nominations.studies', + label: 'Cohort Study', + short: 'Study', + description: + 'Filter for genes based on which study or cohort the nominating research team analyzed to identify the gene as a potential target for AD.', + matchMode: 'intersect', + options: [], + }, + { + name: 'validations', + field: 'nominations.validations', + label: 'Experimental Validation', + short: 'Experimental Validation', + description: + 'Filter for genes based on the experimental validation status indicated by the nominating team(s).', + order: 'DESC', + matchMode: 'intersect', + options: [], + }, + { + name: 'inputs', + field: 'nominations.inputs', + label: 'Input Data', + short: 'Data', + description: + 'Filter for genes based on the type of data that the nominating research team analyzed to identify the gene as a potential target for AD.', + matchMode: 'intersect', + options: [], + }, + { + name: 'programs', + field: 'nominations.programs', + label: 'Nominating Program', + short: 'Nominating Program', + description: 'Filter for genes based on the nominating program.', + matchMode: 'intersect', + options: [], + }, + { + name: 'teams', + field: 'nominations.teams', + label: 'Nominating Teams', + short: 'Team', + description: 'Filter for genes based on the nominating research team.', + matchMode: 'intersect', + options: [], + }, + { + name: 'nominations', + field: 'nominations.count', + label: 'Number of Nominations', + short: 'Nominations', + description: + 'Filter for genes based on how many times they have been nominated as a potential target for AD.', + matchMode: 'in', + order: 'DESC', + options: [], + }, + { + name: 'target_enabling_resources', + field: 'target_enabling_resources', + label: 'Target Enabling Resources', + short: 'Resources', + description: + 'Filter for genes that have Target Enabling Resources to support experimental validation efforts.', + matchMode: 'intersect', + options: [ + { + label: 'AD Informer Set', + value: 'AD Informer Set', + }, + { + label: 'Target Enabling Package', + value: 'Target Enabling Package', + }, + ], + }, + { + name: 'year', + field: 'nominations.year', + label: 'Year First Nominated', + short: 'Year', + description: + 'Filter for genes based on the year that they were first nominated as a potential target for AD.', + matchMode: 'in', + order: 'DESC', + options: [], + }, +]; diff --git a/libs/agora/gene-comparison-tool/src/test-setup.ts b/libs/agora/gene-comparison-tool/src/test-setup.ts new file mode 100644 index 0000000000..1100b3e8a6 --- /dev/null +++ b/libs/agora/gene-comparison-tool/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/agora/gene-comparison-tool/tsconfig.json b/libs/agora/gene-comparison-tool/tsconfig.json new file mode 100644 index 0000000000..f2860a9cd7 --- /dev/null +++ b/libs/agora/gene-comparison-tool/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "compilerOptions": { + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "target": "es2020", + "esModuleInterop": true + }, + "angularCompilerOptions": { + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/agora/wiki/tsconfig.lib.json b/libs/agora/gene-comparison-tool/tsconfig.lib.json similarity index 100% rename from libs/agora/wiki/tsconfig.lib.json rename to libs/agora/gene-comparison-tool/tsconfig.lib.json diff --git a/libs/agora/gene-comparison-tool/tsconfig.spec.json b/libs/agora/gene-comparison-tool/tsconfig.spec.json new file mode 100644 index 0000000000..317e26be1d --- /dev/null +++ b/libs/agora/gene-comparison-tool/tsconfig.spec.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": ["src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts", "jest.config.ts"] +} diff --git a/libs/agora/genes/src/index.ts b/libs/agora/genes/src/index.ts index 3d5c681c7c..d3639ced7e 100644 --- a/libs/agora/genes/src/index.ts +++ b/libs/agora/genes/src/index.ts @@ -1,2 +1,24 @@ +export * from './lib/components/gene-biodomains/gene-biodomains.component'; +export * from './lib/components/gene-details/gene-details.routes'; +export * from './lib/components/gene-druggability/gene-druggability.component'; +export * from './lib/components/gene-evidence-metabolomics/gene-evidence-metabolomics.component'; +export * from './lib/components/gene-evidence-proteomics/gene-evidence-proteomics.component'; +export * from './lib/components/gene-evidence-rna/gene-evidence-rna.component'; +export * from './lib/components/gene-experimental-validation/gene-experimental-validation.component'; +export * from './lib/components/gene-hero/gene-hero.component'; +export * from './lib/components/gene-model-selector/gene-model-selector.component'; +export * from './lib/components/gene-network/gene-network.component'; +export * from './lib/components/gene-nominations/gene-nominations.component'; +export * from './lib/components/gene-protein-selector/gene-protein-selector.component'; +export * from './lib/components/gene-resources/gene-resources.component'; export * from './lib/components/gene-search/gene-search.component'; +export * from './lib/components/gene-soe/gene-soe.component'; +export * from './lib/components/gene-soe-charts/gene-soe-charts.component'; +export * from './lib/components/gene-soe-list/gene-soe-list.component'; export * from './lib/components/gene-table/gene-table.component'; + +export * from './lib/components/download-dom-image/download-dom-image.component'; +export * from './lib/components/overlay-panel-link/overlay-panel-link.component'; + +export * from './lib/helpers'; +export * from './lib/models'; diff --git a/libs/agora/genes/src/lib/components/download-dom-image/download-dom-image.component.html b/libs/agora/genes/src/lib/components/download-dom-image/download-dom-image.component.html new file mode 100644 index 0000000000..7753dab5a9 --- /dev/null +++ b/libs/agora/genes/src/lib/components/download-dom-image/download-dom-image.component.html @@ -0,0 +1,34 @@ + + +
+ {{ heading }} +
+
+
+ +
+
+ +
{{ error }}
+
+
+
diff --git a/libs/agora/genes/src/lib/components/download-dom-image/download-dom-image.component.scss b/libs/agora/genes/src/lib/components/download-dom-image/download-dom-image.component.scss new file mode 100644 index 0000000000..a32f45d182 --- /dev/null +++ b/libs/agora/genes/src/lib/components/download-dom-image/download-dom-image.component.scss @@ -0,0 +1,45 @@ +@import 'libs/agora/styles/src/lib/variables'; +@import 'libs/agora/styles/src/lib/mixins'; + +.download-dom-image-icon { + transform: translateY(6px); + margin-left: 10px; + cursor: pointer; +} + +.download-dom-image-panel { + margin-top: 5px; + + .p-overlaypanel-content { + width: 290px; + padding: 25px; + background-color: #fff; + box-shadow: 0 1px 3px 0 rgb(0 0 0 / 30%); + } + + .download-dom-image-header { + margin-bottom: 15px; + } + + .download-dom-image-body { + > div:not(:last-child) { + margin-bottom: 15px; + } + } + + .p-button { + width: 100%; + + i { + position: absolute; + top: 8px; + left: 10px; + } + } + + .download-dom-image-error { + padding-top: 6px; + font-size: 14px; + text-align: center; + } +} diff --git a/libs/agora/genes/src/lib/components/download-dom-image/download-dom-image.component.spec.ts.off b/libs/agora/genes/src/lib/components/download-dom-image/download-dom-image.component.spec.ts.off new file mode 100644 index 0000000000..ad59ffb3db --- /dev/null +++ b/libs/agora/genes/src/lib/components/download-dom-image/download-dom-image.component.spec.ts.off @@ -0,0 +1,58 @@ +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { DownloadDomImageComponent } from './download-dom-image.component'; +import { provideRouter } from '@angular/router'; + +describe('DownloadDomImageComponent', () => { + let fixture: ComponentFixture; + let component: DownloadDomImageComponent; + let element: HTMLElement; + + beforeEach(async () => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + providers: [provideRouter([])], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(DownloadDomImageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + element = fixture.nativeElement; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have overlay', () => { + expect(element.querySelector('p-overlaypanel.p-element')).toBeTruthy(); + }); + + it('should open overlay on click', () => { + const button = element.querySelector('button') as HTMLElement; + + expect(button).toBeTruthy(); + button.click(); + fixture.detectChanges(); + + expect(document.querySelector('.download-dom-image-panel')).toBeTruthy(); + }); + + it('should have a radiobox for each types', () => { + const button = element.querySelector('button') as HTMLElement; + + expect(button).toBeTruthy(); + button.click(); + fixture.detectChanges(); + + const overlayPanel = document.querySelector('.download-dom-image-panel') as HTMLElement; + + expect(overlayPanel).toBeTruthy(); + expect(overlayPanel.querySelectorAll('p-radiobutton.p-element')?.length).toEqual( + component.types.length, + ); + }); +}); diff --git a/libs/agora/genes/src/lib/components/download-dom-image/download-dom-image.component.ts b/libs/agora/genes/src/lib/components/download-dom-image/download-dom-image.component.ts new file mode 100644 index 0000000000..dd04db462d --- /dev/null +++ b/libs/agora/genes/src/lib/components/download-dom-image/download-dom-image.component.ts @@ -0,0 +1,85 @@ +/* eslint-disable @typescript-eslint/no-this-alias */ +import domtoimage from 'dom-to-image-more'; + +import { Component, ViewChild, Input, ViewEncapsulation } from '@angular/core'; +import { OverlayPanel, OverlayPanelModule } from 'primeng/overlaypanel'; +import { saveAs } from 'file-saver'; +import { RadioButtonModule } from 'primeng/radiobutton'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +interface Type { + value: string; + label: string; +} + +@Component({ + selector: 'agora-download-dom-image', + standalone: true, + imports: [CommonModule, FormsModule, OverlayPanelModule, RadioButtonModule], + templateUrl: './download-dom-image.component.html', + styleUrls: ['./download-dom-image.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class DownloadDomImageComponent { + @Input() target: HTMLElement = {} as HTMLElement; + @Input() heading = 'Download this plot as:'; + @Input() filename = 'agora'; + + selectedType = '.png'; + types: Type[] = [ + { + value: '.png', + label: 'PNG', + }, + { + value: '.jpeg', + label: 'JPEG', + }, + ]; + + error = ''; + isLoading = false; + resizeTimer: ReturnType | number = 0; + + @ViewChild('op', { static: true }) overlayPanel: OverlayPanel = {} as OverlayPanel; + + download() { + if (this.isLoading) { + return; + } + + const self = this; + this.error = ''; + this.isLoading = true; + + domtoimage + .toBlob(this.target, { bgcolor: '#fff' }) + .then((blob: any) => { + saveAs(blob, this.filename + this.selectedType); + this.isLoading = false; + this.hide(); + }) + .catch(function (err: string) { + self.error = 'Oops, something went wrong!'; + console.error(err); + }); + } + + hide() { + this.error = ''; + this.overlayPanel.hide(); + } + + onRotate() { + this.hide(); + } + + onResize() { + const self = this; + clearTimeout(this.resizeTimer); + this.resizeTimer = setTimeout(function () { + self.hide(); + }, 0); + } +} diff --git a/libs/agora/genes/src/lib/components/gene-biodomains/gene-biodomains.component.html b/libs/agora/genes/src/lib/components/gene-biodomains/gene-biodomains.component.html new file mode 100644 index 0000000000..878bb2161b --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-biodomains/gene-biodomains.component.html @@ -0,0 +1,35 @@ +
+ @if (gene?.bio_domains?.gene_biodomains) { +
+
+

BIOLOGICAL DOMAIN MAPPINGS

+ +
+
+
+

{{ getHeaderText() }}

+
    +
  • + {{ capitalizeGoTerm(goTerm) }} +
  • +
+
+
+ } @else { +
+
+
+
+ There are no biological domain mappings for this gene. To contribute evidence towards + defining new AD biological domains, contact us + here. +
+
+
+
+ } +
diff --git a/libs/agora/genes/src/lib/components/gene-biodomains/gene-biodomains.component.scss b/libs/agora/genes/src/lib/components/gene-biodomains/gene-biodomains.component.scss new file mode 100644 index 0000000000..dca23d4826 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-biodomains/gene-biodomains.component.scss @@ -0,0 +1,103 @@ +$scrollbar-thumb-color: #878e95; +$scrollbar-thumb-hover-color: #4a5056; + +h3 { + font-size: 16px; + font-weight: bold; +} + +#container { + #biodomain-panes { + display: flex; + flex-direction: row; + justify-content: center; + height: 683px; + + #left-hand-pane { + border-width: 1px 0 1px 1px; + border-color: #f1f3f5; + border-style: solid; + padding: 36px; + display: flex; + flex-direction: column; + text-align: center; + } + + #right-hand-pane { + width: 525px; + background-color: #f1f3f5; + padding: 0 36px 36px; + overflow: auto; + position: relative; + + h3 { + padding-top: 36px; + background-color: #f1f3f5; + position: sticky; + top: 0; + } + + #notch { + position: absolute; + left: 0; + top: 40px; + width: 0; + height: 0; + border-top: 20px solid transparent; + border-bottom: 20px solid transparent; + border-left: 20px solid white; + } + + ul { + list-style-type: none; + margin: 0; + padding: 0; + + li { + padding: 0 0 15px; + font-size: 14px; + } + } + } + } + + .no-data { + background-color: #f1f3f5; + padding: 30px; + + a { + color: var(--color-action-primary); + font-size: 16px; + } + } +} + +// CUSTOM SCROLLBAR START + +/* WebKit-based browsers (Chrome, Safari) */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background-color: transparent; /* Hide the track */ +} + +::-webkit-scrollbar-thumb { + background-color: $scrollbar-thumb-color; + border-radius: 5px; +} + +::-webkit-scrollbar-thumb:hover { + background-color: $scrollbar-thumb-hover-color; +} + +/* Firefox */ +@supports (scrollbar-color: $scrollbar-thumb-color transparent) { + * { + scrollbar-color: $scrollbar-thumb-color transparent; + scrollbar-width: thin; + } +} + +// CUSTOM SCROLLBAR END diff --git a/libs/agora/genes/src/lib/components/gene-biodomains/gene-biodomains.component.spec.ts.off b/libs/agora/genes/src/lib/components/gene-biodomains/gene-biodomains.component.spec.ts.off new file mode 100644 index 0000000000..136cdbaaeb --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-biodomains/gene-biodomains.component.spec.ts.off @@ -0,0 +1,57 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { GeneBioDomainsComponent } from './gene-biodomains.component'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { emptyBioDomainMock, geneMock1 } from '@sagebionetworks/agora/testing'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Gene Biodomains', () => { + let fixture: ComponentFixture; + let component: GeneBioDomainsComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [GeneBioDomainsComponent], + imports: [RouterTestingModule], + providers: [HelperService], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(GeneBioDomainsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should return an empty string when getGeneName() is called with undefined gene', () => { + component.gene = undefined; + const result = component.getGeneName(); + expect(result).toBe(''); + }); + + it('should return gene name when getGeneName() is called with a defined gene', () => { + component.gene = geneMock1; + const result = component.getGeneName(); + expect(result).toBe('MSN'); + }); + + it('should return NO LINKING GO TERMS when getHeaderText() is called with no go terms', () => { + component.selectedBioDomain = emptyBioDomainMock; + component.goTerms = []; + const result = component.getHeaderText(); + expect(result).toBe(`NO LINKING GO TERMS FOR ${emptyBioDomainMock.biodomain.toUpperCase()}`); + }); +}); diff --git a/libs/agora/genes/src/lib/components/gene-biodomains/gene-biodomains.component.ts b/libs/agora/genes/src/lib/components/gene-biodomains/gene-biodomains.component.ts new file mode 100644 index 0000000000..82c1ac444e --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-biodomains/gene-biodomains.component.ts @@ -0,0 +1,111 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject, Input, OnInit } from '@angular/core'; +import { BioDomain, Gene } from '@sagebionetworks/agora/api-client-angular'; +import { BiodomainsChartComponent } from '@sagebionetworks/agora/charts'; +import { HelperService } from '@sagebionetworks/agora/services'; + +@Component({ + selector: 'agora-gene-biodomains', + standalone: true, + imports: [CommonModule, BiodomainsChartComponent], + providers: [HelperService], + templateUrl: './gene-biodomains.component.html', + styleUrls: ['./gene-biodomains.component.scss'], +}) +export class GeneBioDomainsComponent implements OnInit { + helperService = inject(HelperService); + + @Input() gene: Gene | undefined; + + selectedBioDomain: BioDomain | undefined; + goTerms: string[] = []; + + defaultBioDomains = [ + 'Apoptosis', + 'APP Metabolism', + 'Autophagy', + 'Cell Cycle', + 'DNA Repair', + 'Endolysosome', + 'Epigenetic', + 'Immune Response', + 'Lipid Metabolism', + 'Metal Binding and Homeostasis', + 'Mitochondrial Metabolism', + 'Myelination', + 'Oxidative Stress', + 'Proteostasis', + 'RNA Spliceosome', + 'Structural Stabilization', + 'Synapse', + 'Tau Homeostasis', + 'Vasculature', + ]; + + ngOnInit(): void { + this.processBioDomains(); + } + + getGeneName() { + if (this.gene) return this.gene.hgnc_symbol || this.gene.ensembl_gene_id; + return ''; + } + + getGoTerms(index: number): string[] { + if (this.gene && this.gene.bio_domains) { + const biodomain = this.gene.bio_domains.gene_biodomains[index]; + const sorted = biodomain.go_terms.sort((a, b) => { + // sort by GO terms ascending + return a.localeCompare(b); + }); + return sorted; + } + return []; + } + + onSelectedBioDomain(index: number | undefined) { + if (index === undefined) { + this.goTerms = []; + this.selectedBioDomain = undefined; + return; + } + this.goTerms = this.getGoTerms(index); + this.selectedBioDomain = this.gene?.bio_domains?.gene_biodomains[index]; + } + + processBioDomains() { + // add biodomains missing from api call + this.defaultBioDomains.forEach((d) => { + const exists = this.gene?.bio_domains?.gene_biodomains.find((b) => b.biodomain === d); + if (!exists) { + this.gene?.bio_domains?.gene_biodomains.push({ + biodomain: d, + go_terms: [], + n_biodomain_terms: 0, + n_gene_biodomain_terms: 0, + pct_linking_terms: 0, + }); + } + }); + + // sort existing biodomains + this.gene?.bio_domains?.gene_biodomains.sort((a, b) => { + // sort by pct_linking_terms descending first + if (a.pct_linking_terms !== b.pct_linking_terms) { + return b.pct_linking_terms - a.pct_linking_terms; + } + // otherwise sort by biodomains ascending + return a.biodomain.localeCompare(b.biodomain); + }); + } + + getHeaderText() { + const baseText = `LINKING GO TERMS FOR ${this.selectedBioDomain?.biodomain.toUpperCase()}`; + if (this.goTerms.length === 0) return `NO ${baseText}`; + return `${baseText} (${this.selectedBioDomain?.n_gene_biodomain_terms}/${this.selectedBioDomain?.n_biodomain_terms})`; + } + + capitalizeGoTerm(goTerm: string) { + return this.helperService.capitalizeFirstLetterOfString(goTerm); + } +} diff --git a/libs/agora/genes/src/lib/components/gene-details/gene-details.component.html b/libs/agora/genes/src/lib/components/gene-details/gene-details.component.html new file mode 100644 index 0000000000..9126ad771a --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-details/gene-details.component.html @@ -0,0 +1,119 @@ +@if (gene) { +
+ +
+
+ @if (navSlideIndex > 0) { + + } +
+ +
+ @if (navSlideIndex < getPanelCount() - 1) { + + } +
+
+ +
+ + @if (!panel.disabled) { +
+ @if (panel.name === 'summary') { + + } + + @if (panel.name === 'rna') { + + } + + @if (panel.name === 'protein') { + + } + + @if (panel.name === 'metabolomics') { + + } + + @if (panel.name === 'resources') { + + } + + @if (panel.name === 'nominations') { + + } + + @if (panel.name === 'experimental-validation') { + + } +
+ } +
+ + + + @if (panel.children) { + + + + } + +
+
+} diff --git a/libs/agora/genes/src/lib/components/gene-details/gene-details.component.scss b/libs/agora/genes/src/lib/components/gene-details/gene-details.component.scss new file mode 100644 index 0000000000..c35b8cdb56 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-details/gene-details.component.scss @@ -0,0 +1,208 @@ +/* stylelint-disable plugin/no-unsupported-browser-features */ +/* stylelint-disable no-descending-specificity */ + +@import 'libs/agora/styles/src/lib/variables'; +@import 'libs/agora/styles/src/lib/mixins'; + +.gene-details-nav { + position: relative; + height: var(--gene-details-nav-height); + + a { + position: relative; + display: flex; + height: 100%; + padding: 8px 0; + font-size: var(--font-size-lg); + font-weight: 700; + color: var(--color-text-secondary); + transition: var(--transition-duration); + align-items: center; + cursor: pointer; + white-space: nowrap; + + &::after { + content: ' '; + position: absolute; + display: block; + bottom: 0; + left: 0; + right: 0; + height: 4px; + background-color: var(--color-action-primary); + border-radius: 2px; + opacity: 0; + visibility: hidden; + transition: var(--transition-duration); + } + + &:not(.disabled) { + &:hover { + color: var(--color-action-primary); + } + } + + &.disabled { + opacity: 0.3; + cursor: not-allowed; + } + } + + ul { + @include reset-ul; + + display: flex; + width: 100%; + height: var(--gene-details-nav-height); + align-items: center; + z-index: 50; + + > li.active { + > a { + color: var(--color-action-primary); + + &::after { + opacity: 1; + visibility: visible; + } + } + } + } + + li { + padding: 0 30px; + } + + .gene-details-nav-inner { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 999; + background-color: #fff; + box-shadow: 0 4px 4px rgb(0 0 0 / 25%); + } + + .gene-details-subnav { + position: absolute; + height: var(--gene-details-nav-height); + top: 100%; + left: 0; + right: 0; + margin-top: var(--gene-details-subnav-offset); + background-color: var(--color-gray-100); + box-shadow: 0 4px 4px rgb(0 0 0 / 25%); + opacity: 0; + visibility: hidden; + + a { + &::after { + display: none; + } + } + } + + .gene-details-nav-scroll { + @include reset-button; + + display: none; + position: absolute; + width: 50px; + top: 0; + height: var(--gene-details-nav-height); + align-items: center; + justify-content: center; + font-size: 22px; + color: var(--color-gray-600); + background-color: #fff; + z-index: 100; + cursor: pointer; + + &.gene-details-nav-scroll-prev { + left: 0; + } + + &.gene-details-nav-scroll-next { + right: 0; + } + + &:hover { + color: var(--color-action-primary); + } + } + + ul:not(.gene-details-subnav) > li.active { + .gene-details-subnav { + opacity: 1; + visibility: visible; + } + } + + &.sticky { + .gene-details-nav-inner { + position: fixed; + } + } + + &.has-active-child { + height: calc(var(--gene-details-nav-height) * 2 + var(--gene-details-subnav-offset)); + } +} + +.gene-details-body { + position: relative; + overflow: hidden; +} + +.gene-details-panel { + position: absolute; + width: 100%; + top: 0; + opacity: 0; + visibility: hidden; + z-index: 100; + + &.active { + opacity: 1; + visibility: visible; + position: relative; + top: auto; + z-index: 200; + } +} + +.gene-details-nav:not(.scrollable) { + ul { + margin-left: auto; + margin-right: auto; + justify-content: center; + } +} + +.gene-details-nav.scrollable { + --gene-details-nav-height: 60px; + + user-select: none; + + .gene-details-nav-inner, + .gene-details-subnav { + padding-left: 50px; + padding-right: 50px; + } + + .gene-details-nav-container { + overflow: hidden; + } + + .gene-details-nav-scroll { + display: flex; + } + + li { + padding: 0 20px; + + &:first-child { + padding-left: 0; + } + } +} diff --git a/libs/agora/genes/src/lib/components/gene-details/gene-details.component.spec.ts.off b/libs/agora/genes/src/lib/components/gene-details/gene-details.component.spec.ts.off new file mode 100644 index 0000000000..dc111b48d1 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-details/gene-details.component.spec.ts.off @@ -0,0 +1,81 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { Location } from '@angular/common'; +import { Observable } from 'rxjs'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { GeneDetailsComponent } from './gene-details.component'; +// import { GenesModule } from '../..'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { geneMock1 } from '@sagebionetworks/agora/testing'; + +class ActivatedRouteStub { + paramMap = new Observable((observer) => { + const paramMap = { + id: geneMock1.ensembl_gene_id, + }; + observer.next(convertToParamMap(paramMap)); + observer.complete(); + }); + queryParams = new Observable((observer) => { + const paramMap = { + model: '', + }; + observer.next(convertToParamMap(paramMap)); + observer.complete(); + }); +} +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Gene Details', () => { + let fixture: ComponentFixture; + let component: GeneDetailsComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [GeneDetailsComponent], + imports: [RouterTestingModule, HttpClientTestingModule, BrowserAnimationsModule], + providers: [ + { + provide: ActivatedRoute, + useValue: new ActivatedRouteStub(), + }, + Location, + HelperService, + TeamsService, + { + provide: GenesService, + useValue: new GeneServiceStub(), + }, + ], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(GeneDetailsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have data', () => { + const noiSpy = spyOn(component, 'ngOnInit').and.callThrough(); + + component.ngOnInit(); + fixture.detectChanges(); + expect(noiSpy).toHaveBeenCalled(); + expect(component.gene).toEqual(geneMock1); + }); +}); diff --git a/libs/agora/genes/src/lib/components/gene-details/gene-details.component.ts b/libs/agora/genes/src/lib/components/gene-details/gene-details.component.ts new file mode 100644 index 0000000000..ebcc9af234 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-details/gene-details.component.ts @@ -0,0 +1,300 @@ +/* eslint-disable @typescript-eslint/no-this-alias */ +import { + Component, + OnInit, + AfterViewInit, + HostListener, + inject, + AfterViewChecked, +} from '@angular/core'; +import { ActivatedRoute, ParamMap, Router } from '@angular/router'; +import { CommonModule, Location } from '@angular/common'; + +import { HelperService } from '@sagebionetworks/agora/services'; +import { Gene, GenesService } from '@sagebionetworks/agora/api-client-angular'; +import { GeneSoeComponent } from '../gene-soe/gene-soe.component'; +import { GeneHeroComponent } from '../gene-hero/gene-hero.component'; +import { GeneEvidenceRnaComponent } from '../gene-evidence-rna/gene-evidence-rna.component'; +import { GeneResourcesComponent } from '../gene-resources/gene-resources.component'; +import { GeneEvidenceProteomicsComponent } from '../gene-evidence-proteomics/gene-evidence-proteomics.component'; +import { GeneEvidenceMetabolomicsComponent } from '../gene-evidence-metabolomics/gene-evidence-metabolomics.component'; +import { ExperimentalValidationComponent } from '../gene-experimental-validation/gene-experimental-validation.component'; +import { GeneNominationsComponent } from '../gene-nominations/gene-nominations.component'; + +interface Panel { + name: string; + label: string; + disabled: boolean; + children?: Panel[]; +} + +@Component({ + selector: 'agora-gene-details', + standalone: true, + imports: [ + CommonModule, + GeneHeroComponent, + GeneSoeComponent, + GeneEvidenceMetabolomicsComponent, + GeneEvidenceProteomicsComponent, + GeneEvidenceRnaComponent, + ExperimentalValidationComponent, + GeneNominationsComponent, + GeneResourcesComponent, + ], + providers: [HelperService, GenesService], + templateUrl: './gene-details.component.html', + styleUrls: ['./gene-details.component.scss'], +}) +export class GeneDetailsComponent implements OnInit, AfterViewInit, AfterViewChecked { + route = inject(ActivatedRoute); + router = inject(Router); + location = inject(Location); + helperService = inject(HelperService); + geneService = inject(GenesService); + + gene: Gene | undefined; + + panels: Panel[] = [ + { + name: 'summary', + label: 'Summary', + disabled: false, + }, + { + name: 'evidence', + label: 'Evidence', + disabled: false, + children: [ + { + name: 'rna', + label: 'RNA', + disabled: false, + }, + { + name: 'protein', + label: 'Protein', + disabled: false, + }, + { + name: 'metabolomics', + label: 'Metabolomics', + disabled: false, + }, + ], + }, + { + name: 'resources', + label: 'Resources', + disabled: false, + }, + { + name: 'nominations', + label: 'Nomination Details', + disabled: false, + }, + { + name: 'experimental-validation', + label: 'Experimental Validation', + disabled: false, + }, + ]; + + activePanel = 'summary'; + activeParent = ''; + navSlideIndex = 0; + + @HostListener('window:scroll', ['$event']) + onWindowScroll() { + const nav = document.querySelector('.gene-details-nav'); + const rect = nav?.getBoundingClientRect(); + + if (rect && rect.y <= 0) { + nav?.classList.add('sticky'); + } else { + nav?.classList.remove('sticky'); + } + } + + @HostListener('window:resize', ['$event']) + onWindowResize() { + const nav = document.querySelector('.gene-details-nav'); + const navContainer = nav?.querySelector('.gene-details-nav-container'); + const navList = nav?.querySelector('.gene-details-nav-container > ul'); + const navItems = nav?.querySelectorAll('.gene-details-nav-container > ul > li'); + let navItemsWidth = 0; + if (navItems) { + for (let i = 0; i < navItems.length; ++i) { + navItemsWidth += navItems[i].offsetWidth; + } + } + + if (navContainer && navList && navItemsWidth) { + if (navItemsWidth > navContainer.offsetWidth) { + nav?.classList.add('scrollable'); + } else { + nav?.classList.remove('scrollable'); + this.navSlideIndex = 0; + navList.style.marginLeft = '0px'; + } + } + } + + reset() { + this.gene = undefined; + this.activePanel = 'summary'; + this.activeParent = ''; + this.navSlideIndex = 0; + } + + ngOnInit() { + this.route.paramMap.subscribe((params: ParamMap) => { + this.reset(); + this.helperService.setLoading(true); + + if (params.get('id')) { + this.geneService.getGene(params.get('id') as string).subscribe((gene) => { + if (!gene) { + this.helperService.setLoading(false); + // https://github.com/angular/angular/issues/45202 + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.router.navigateByUrl('/404-not-found', { skipLocationChange: true }); + } else { + this.gene = gene; + + this.panels.forEach((p: Panel) => { + if (p.name == 'nominations' && !this.gene?.total_nominations) { + p.disabled = true; + } else if ( + p.name == 'experimental-validation' && + !this.gene?.experimental_validation?.length + ) { + p.disabled = true; + } else { + p.disabled = false; + } + }); + + const nominationsPanel = this.panels.find((p) => p.name == 'nominations'); + if (nominationsPanel) { + nominationsPanel.disabled = !this.gene.total_nominations ? true : false; + } + + const experimentalValidationPanel = this.panels.find( + (p) => p.name == 'experimental-validation', + ); + if (experimentalValidationPanel) { + experimentalValidationPanel.disabled = !this.gene.experimental_validation?.length + ? true + : false; + } + + this.helperService.setLoading(false); + } + }); + } + + if (params.get('subtab')) { + this.activePanel = params.get('subtab') as string; + this.activeParent = params.get('tab') as string; + } else if (params.get('tab')) { + const panel = this.panels.find((p: Panel) => p.name === params.get('tab')); + if (panel?.children) { + this.activePanel = panel.children[0].name; + this.activeParent = panel.name; + } else if (panel) { + this.activePanel = panel.name; + this.activeParent = ''; + } + } + }); + } + + ngAfterViewInit() { + if (!this.gene?.ensembl_gene_id) { + this.helperService.setLoading(true); + } + const self = this; + setTimeout(function () { + self.onWindowResize(); + }, 100); + } + + ngAfterViewChecked() { + this.onWindowResize(); + } + + activatePanel(panel: Panel) { + if (panel.disabled) { + return; + } + + let url = '/genes/' + this.gene?.ensembl_gene_id + '/'; + + if (panel.children) { + this.activePanel = panel.children[0].name; + this.activeParent = panel.name; + url += panel.name + '/' + panel.children[0].name; + } else if (!this.panels.find((p: Panel) => p.name === panel.name)) { + const parent = this.panels.find((p: Panel) => + p.children?.find((c: Panel) => c.name === panel.name), + ); + this.activePanel = panel.name; + this.activeParent = parent?.name || ''; + url += parent?.name + '/' + panel.name; + } else { + this.activePanel = panel.name; + this.activeParent = ''; + url += panel.name; + } + + // added logic to support dropdown state when page is refreshed + const modelUrlParam = this.helperService.getUrlParam('model'); + if (modelUrlParam) { + url = this.helperService.addSingleUrlParam(url, 'model', modelUrlParam); + } + + const nav = document.querySelector('.gene-details-nav'); + if (nav) { + window.scrollTo(0, this.helperService.getOffset(nav).top); + } + + this.location.replaceState(url); + } + + getPanelCount() { + return this.panels.map((p: Panel) => !p.disabled).length; + } + + onNavigationItemClick(panel: Panel) { + this.activatePanel(panel); + } + + slideNavigation(direction: number) { + this.navSlideIndex += direction; + + if (this.navSlideIndex < 0) { + this.navSlideIndex = 0; + } else if (this.navSlideIndex > this.getPanelCount() - 1) { + this.navSlideIndex = this.panels.length - 1; + } + + const nav = document.querySelector('.gene-details-nav'); + const navList = nav?.querySelector('.gene-details-nav-container > ul'); + const navItems = nav?.querySelectorAll('.gene-details-nav-container > ul > li'); + + if (navList && navItems) { + let navItemsWidth = 0; + + for (let i = 0; i < this.navSlideIndex; ++i) { + navItemsWidth += navItems[i].offsetWidth; + } + + if (this.navSlideIndex > 0) { + navItemsWidth += 20; + } + + navList.style.marginLeft = navItemsWidth * -1 + 'px'; + } + } +} diff --git a/libs/agora/genes/src/lib/components/gene-details/gene-details.routes.ts b/libs/agora/genes/src/lib/components/gene-details/gene-details.routes.ts new file mode 100644 index 0000000000..3f44758a55 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-details/gene-details.routes.ts @@ -0,0 +1,4 @@ +import { Routes } from '@angular/router'; +import { GeneDetailsComponent } from './gene-details.component'; + +export const routes: Routes = [{ path: '', component: GeneDetailsComponent }]; diff --git a/libs/agora/genes/src/lib/components/gene-druggability/gene-druggability.component.html b/libs/agora/genes/src/lib/components/gene-druggability/gene-druggability.component.html new file mode 100644 index 0000000000..0103b9dd95 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-druggability/gene-druggability.component.html @@ -0,0 +1,244 @@ + + + +
+
Small Molecule Modality
+
+
+
+ {{ getBucketNumberText(druggability.sm_druggability_bucket) }} +
+
+
+
+ {{ druggability.classification }} +
+
+
+ +

Small Molecule Modality Scale

+

+ Choose a bucket to display criteria for each level of the Small Molecule Modality Scale +

+ +
+
Bucket
+
+
+
+
+
+
+
{{ bucket }}
+
+
+ {{ getClassText(bucket) }} +
+
+
+
+ +
+
Criteria
+
+
+
+ {{ getDruggabilitySMTitle(currentBucketSM) }} +
+
+ {{ getDruggabilitySMText(currentBucketSM) }} +
+
+
+
+
+
+ + + +
+
Antibody Modality
+
+
+
+ {{ getBucketNumberText(druggability.abability_bucket) }} +
+
+
+
+ {{ druggability.abability_bucket_definition }} +
+
+
+ +

Antibody Modality Scale

+

+ Choose a bucket to display criteria for each level of the Antibody Modality Scale +

+ +
+
Bucket
+
+
+
+
+
+
+
{{ bucket }}
+
+
+
+
+
+ +
+
Criteria
+
+
+
+ {{ getDruggabilityABTitle(currentBucketAB) }} +
+
+ {{ getDruggabilityABText(currentBucketAB) }} +
+
+
+
+
+
+ + + +
+
Safety
+
+
+
+ {{ getBucketNumberText(druggability.safety_bucket) }} +
+
+
+
+ {{ druggability.safety_bucket_definition }} +
+
+
+ +

Safety Scale

+

+ Choose a bucket to display criteria for each level of the Safety Scale +

+ +
+
Bucket
+
+
+
+
+
+
+
{{ bucket }}
+
+
+
+
+
+ +
+
Criteria
+
+
+
+ {{ getDruggabilitySFTitle(currentBucketSF) }} +
+
+ {{ getDruggabilitySFText(currentBucketSF) }} +
+
+
+
+
+
+
diff --git a/libs/agora/genes/src/lib/components/gene-druggability/gene-druggability.component.scss b/libs/agora/genes/src/lib/components/gene-druggability/gene-druggability.component.scss new file mode 100644 index 0000000000..47b198e3be --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-druggability/gene-druggability.component.scss @@ -0,0 +1,20 @@ +@import 'libs/agora/styles/src/lib/variables'; +@import 'libs/agora/styles/src/lib/mixins'; + +.gene-druggability { + .dgb-first-h3 { + margin-top: 30px; + } + + h3 { + margin-bottom: 0; + } +} + +.no-data { + min-height: 120px; +} + +.druggability-description { + width: 75%; +} diff --git a/libs/agora/genes/src/lib/components/gene-druggability/gene-druggability.component.spec.ts.off b/libs/agora/genes/src/lib/components/gene-druggability/gene-druggability.component.spec.ts.off new file mode 100644 index 0000000000..e940c323fd --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-druggability/gene-druggability.component.spec.ts.off @@ -0,0 +1,39 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { GeneDruggabilityComponent } from './gene-druggability.component'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { GenesService } from '@sagebionetworks/agora/api-client-angular'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Gene Druggability', () => { + let fixture: ComponentFixture; + let component: GeneDruggabilityComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [GeneDruggabilityComponent], + imports: [RouterTestingModule, HttpClientTestingModule], + providers: [GenesService, HelperService], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(GeneDruggabilityComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/agora/genes/src/lib/components/gene-druggability/gene-druggability.component.ts b/libs/agora/genes/src/lib/components/gene-druggability/gene-druggability.component.ts new file mode 100644 index 0000000000..3bc04d6a8b --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-druggability/gene-druggability.component.ts @@ -0,0 +1,397 @@ +import { Component, Input, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import * as d3 from 'd3'; +import { CommonModule } from '@angular/common'; +import { AccordionModule } from 'primeng/accordion'; +import { Druggability, Gene } from '@sagebionetworks/agora/api-client-angular'; + +export interface GeneResourceType { + title: string; + description: string; + linkText: string; + link: string; +} + +@Component({ + selector: 'agora-gene-druggability', + standalone: true, + imports: [CommonModule, AccordionModule], + templateUrl: './gene-druggability.component.html', + styleUrls: ['./gene-druggability.component.scss'], +}) +export class GeneDruggabilityComponent { + route = inject(ActivatedRoute); + + _gene: Gene | undefined; + get gene(): Gene | undefined { + return this._gene; + } + @Input() set gene(gene: Gene | undefined) { + this._gene = gene; + this.init(); + } + + druggability: Druggability = {} as Druggability; + bucketsSM: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]; + bucketsAB: number[] = [1, 2, 3, 4, 5, 6, 7]; + bucketsSF: number[] = [1, 2, 3, 4, 5, 6]; + currentBucketSM = 1; + currentBucketAB = 1; + currentBucketSF = 1; + classes: string[] = ['A', 'B', 'C', 'D', 'E', 'F']; + additionalResources: GeneResourceType[] = []; + + init() { + // Update the initial buckets + if (this.gene) { + if (!this.gene.druggability) { + this.gene.druggability = [ + { + sm_druggability_bucket: this.getDefaultBucketNumber(), + safety_bucket: this.getDefaultBucketNumber(), + abability_bucket: this.getDefaultBucketNumber(), + pharos_class: '', + classification: this.getDefaultText(), + safety_bucket_definition: this.getDefaultText(), + abability_bucket_definition: this.getDefaultText(), + }, + ]; + } + + this.druggability = this.gene.druggability[0]; + this.currentBucketSM = this.druggability.sm_druggability_bucket; + this.currentBucketAB = this.druggability.abability_bucket; + this.currentBucketSF = this.druggability.safety_bucket; + } + } + + getDefaultBucketNumber(): number { + return 100; + } + + getDefaultTitle(): string { + return 'Not analyzed'; + } + + getDefaultText(): string { + return 'No score is currently available for this gene.'; + } + + getDruggabilitySMTitle(bucket: number): string { + switch (bucket) { + case 1: + return 'Small molecule druggable'; + case 2: + return 'Targetable by Homology'; + case 3: + return 'Targetable by structure'; + case 4: + return 'Targetable by homologous structure'; + case 5: + return 'Probably small molecule druggable'; + case 6: + return 'Probably small molecule druggable by homology'; + case 7: + return 'Potentially targetable by protein family structure'; + case 8: + return 'Endogenous ligand'; + case 9: + return 'Potentially small molecule druggable by family (active ligand)'; + case 10: + return 'Potentially small molecule druggable by family (low activity ligand)'; + case 11: + return 'Druggable protein class, no other information'; + case 12: + return 'Potentially low ligandability'; + case 13: + return 'Unknown'; + case 14: + return 'Non-protein target'; + default: + return this.getDefaultTitle(); + } + } + + getDruggabilityABTitle(bucket: number): string { + switch (bucket) { + case 1: + return 'Ideal'; + case 2: + return 'Highly accessible'; + case 3: + return 'Accessible'; + case 4: + return 'Probably accessible'; + case 5: + return 'Probably inaccessible'; + case 6: + return 'Inaccessible'; + case 7: + return 'Unknown'; + default: + return this.getDefaultTitle(); + } + } + + getDruggabilitySFTitle(bucket: number): string { + switch (bucket) { + case 1: + return 'Lowest risk'; + case 2: + return 'Lower risk'; + case 3: + return 'Potential risks'; + case 4: + return 'Probable risks'; + case 5: + return 'Potentially unsafe in humans'; + case 6: + return 'Unknown'; + default: + return this.getDefaultTitle(); + } + } + + getDruggabilitySMText(bucket: number): string { + switch (bucket) { + case 1: + return ( + 'Protein with a small molecule ligand identified from ChEMBL, meeting ' + + 'TCRD activity criteria' + ); + case 2: + return ( + '>=40% homologous to a protein with a small molecule ligand identified ' + + 'from ChEMBL, meeting TCRD activity criteria' + ); + case 3: + return ( + 'Structurally druggable protein, based on the presence of a druggable ' + + 'pocket in the protein (DrugEBIlity/CanSAR)' + ); + case 4: + return ( + '>=40% homologous to a structurally druggable protein, based on the ' + + 'presence of a druggable pocket in the homologous protein (DrugEBIlity/CanSAR)' + ); + case 5: + return ( + 'Protein with a small molecule ligand identified from ChEMBL data, but ' + + 'the ligand does not meeting TCRD activity criteria' + ); + case 6: + return ( + '>=40% homologous to a protein with a small molecule ligand identified ' + + 'from ChEMBL data, but the ligand does not meeting TCRD activity criteria' + ); + case 7: + return ( + 'Is a member of a gene family which has a protein member with a druggable pocket ' + + 'in the protein structure.' + ); + case 8: + return 'Has an identified endogenous ligand according from IUPHAR.'; + case 9: + return 'Is a member of a gene family which has a member with an small molecule ligand identified from ChEMBL data, meeting TCRD activity criteria.'; + case 10: + return 'Is a member of a gene family which has a protein member with a ligand which does not meet TCRD activity criteria.'; + case 11: + return ( + 'Is a member of a PHAROS druggable class of protein (enzyme, receptor, ' + + 'ion channel, nuclear hormone receptor, kinase) but does not meet any of the ' + + 'criteria above' + ); + case 12: + return 'Has a structure but there is no evidence of a druggable pocket'; + case 13: + return ( + 'There is no information on ligands or structure in any of the categories ' + 'above' + ); + case 14: + return 'New modality indicated'; + default: + return this.getDefaultText(); + } + } + + getDruggabilityABText(bucket: number): string { + switch (bucket) { + case 1: + return 'Secreted protein. Highly accessible to antibody-based therapies'; + case 2: + return ( + 'Component of the extracellular matrix (ECM). Highly accessible to ' + + 'antibody-based therapies, but potentially less so than secreted proteins' + ); + case 3: + return ( + 'Cell membrane-bound proteins. Highly accessible to antibody-based ' + + 'therapies, but potentially less so than secreted proteins or ECM components' + ); + case 4: + return ( + 'Limited evidence that target is a secreted protein, ECM component or ' + + 'cell membrane-bound protein' + ); + case 5: + return ( + 'Protein located in the cytosol. Not practically accessible to ' + + 'antibody-based therapies, but may be more easily accessible to other modalities' + ); + case 6: + return 'Protein located in intracellular compartment'; + case 7: + return 'Dark target. Paucity of biological knowledge means progress will be ' + 'difficult'; + default: + return this.getDefaultText(); + } + } + + getDruggabilitySFText(bucket: number): string { + switch (bucket) { + case 1: + return ( + 'Clinical data, evidence of tolerable safety profile in desired modality; ' + + 'target has a drug in phase IV in the appropriate modality, with good safety profile.' + ); + case 2: + return ( + 'No major issues found from gene expression, genetic or pharmacological ' + + 'profiling, but has not been extensively tested in humans.' + ); + case 3: + return ( + 'Two or fewer of: high off-target gene expression, cancer driver, ' + + 'essential gene, associated deleterious genetic disorder, HPO phenotype ' + + 'associated gene, or black box warning on clinically used drug.' + ); + + case 4: + return ( + 'More than two of: high off target gene expression, cancer driver, ' + + 'essential gene, associated deleterious genetic disorder, HPO phenotype ' + + 'associated gene, or black box warning on clinically used drug.' + ); + case 5: + return ( + 'Clinical data with evidence of intolerable safety profile/adverse drug reactions ' + + 'in the desired modality and with target engagement. Drug for target withdrawn on those grounds.' + ); + case 6: + return 'Insufficient data available for safety assessment.'; + default: + return this.getDefaultText(); + } + } + + getBucketNumberText(bucket: number): string { + return bucket !== 100 ? bucket.toString(10) : '–'; + } + + getBucketTextColor(bucket: number, section: string): string { + let range = 0; + + if (section === 'sm') { + range = 13; + } else if (section === 'ab') { + range = 7; + } else if (section === 'sf') { + range = 6; + } + + return bucket < range ? '#fff' : '#000'; + } + + getIconStyle(bucket: number, section: string): string { + return '16px solid ' + this.getBucketBGColor(bucket, section); + } + + getBucketBGColor(bucket: number, section: string): string { + const i = d3.interpolateRgb('#20A386', '#440D54'); + let range = 0; + + if (section === 'sm') { + range = 12; + } else if (section === 'ab') { + range = 6; + } else if (section === 'sf') { + range = 5; + } + + if (bucket === 100) { + return '#C3C7D1'; + } + + if (bucket <= range && bucket > 0) { + return d3.hcl(i(bucket / range)).hex(); + } else if (bucket === range + 1) { + return '#C3C7D1'; + } else if (bucket === range + 2) { + return '#AFDDDF'; + } + + return '#fff'; + } + + getBucketIconStyle(isSelection: boolean): object { + const widthString = isSelection ? '62px' : '36px'; + const iconClass: object = { + width: widthString, + }; + + return iconClass; + } + + getClassText(bucket: number): any { + switch (bucket) { + case 1: + return 'Class A'; + case 2: + case 3: + case 4: + return 'Class B'; + case 5: + case 6: + return 'Class C'; + case 7: + case 8: + case 9: + case 10: + case 11: + return 'Class D'; + case 12: + case 13: + return 'Class E'; + case 14: + return 'Class F'; + default: + return 'Not analyzed'; + } + } + + getClassTextMargin(isSelection: boolean): string { + return isSelection ? '6px' : '12px'; + } + + setCurrentBucket(bucket: number, section: string) { + if (section === 'sm') { + this.currentBucketSM = bucket; + } else if (section === 'ab') { + this.currentBucketAB = bucket; + } else if (section === 'sf') { + this.currentBucketSF = bucket; + } + } + + resetBucket(event: any) { + if (this.gene && event.index !== undefined) { + if (event.index === 0) { + this.currentBucketSM = this.druggability.sm_druggability_bucket; + } else if (event.index === 1) { + this.currentBucketAB = this.druggability.abability_bucket; + } else if (event.index === 2) { + this.currentBucketSF = this.druggability.safety_bucket; + } + } + } +} diff --git a/libs/agora/genes/src/lib/components/gene-evidence-metabolomics/gene-evidence-metabolomics.component.html b/libs/agora/genes/src/lib/components/gene-evidence-metabolomics/gene-evidence-metabolomics.component.html new file mode 100644 index 0000000000..427e112aca --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-evidence-metabolomics/gene-evidence-metabolomics.component.html @@ -0,0 +1,90 @@ +
+
+
+
+

Metabolomics

+

+ The results shown on this page are derived from an analysis of metabolite levels from AD + cases and controls. The samples were obtained from approximately 1400 individuals from the + ADNI study. Metabolites are associated with genes using genetic mapping and the metabolite + with the highest genetic association is shown for each gene. +

+ + +
+ +
+ +
+

+ Mapping of Metabolites to + {{ _gene?.hgnc_symbol || _gene?.ensembl_gene_id }} +

+ @if (_gene?.metabolomics || boxPlotData.length < 1) { +

+ No metabolomic data is currently available. +

+ } + @if (_gene?.metabolomics && boxPlotData.length > 0) { +

+ Genetic mapping revealed that the top metabolite associated with + {{ _gene?.hgnc_symbol || _gene?.ensembl_gene_id }} is + {{ _gene?.metabolomics?.['metabolite_full_name'] }}, with a p-value of + {{ getSignificantFigures(_gene?.metabolomics?.['gene_wide_p_threshold_1kgp'], 2) }}. +

+ } +
+
+
+
+
+ +
+
+
+ @if (boxPlotData.length < 1) { +

Levels of Metabolite by Disease Status

+

+ This plot shows differences in metabolite levels in AD cases and controls. +

+ } + + @if (boxPlotData.length > 0) { +
+

+ Levels of {{ _gene?.metabolomics?.['metabolite_full_name'] }} by Disease Status + +

+

+ This plot shows differences in metabolite levels in AD cases (AD) and cognitively-normal + individuals (CN). This comparison + {{ getSignificantText(_gene?.metabolomics?.['ad_diagnosis_p_value'][0]) }} + significantly different with a p-value of + {{ getSignificantFigures(_gene?.metabolomics?.['ad_diagnosis_p_value'][0], 2) }}. +

+
+ } +
+
+
+ +
+
+
+
+ +
+
+
+
diff --git a/libs/agora/genes/src/lib/components/gene-evidence-metabolomics/gene-evidence-metabolomics.component.scss b/libs/agora/genes/src/lib/components/gene-evidence-metabolomics/gene-evidence-metabolomics.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/agora/genes/src/lib/components/gene-evidence-metabolomics/gene-evidence-metabolomics.component.spec.ts.off b/libs/agora/genes/src/lib/components/gene-evidence-metabolomics/gene-evidence-metabolomics.component.spec.ts.off new file mode 100644 index 0000000000..bd3cf44650 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-evidence-metabolomics/gene-evidence-metabolomics.component.spec.ts.off @@ -0,0 +1,38 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { GeneEvidenceMetabolomicsComponent } from './gene-evidence-metabolomics.component'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { GenesService } from '@sagebionetworks/agora/api-client-angular'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Gene Metabolomics', () => { + let fixture: ComponentFixture; + let component: GeneEvidenceMetabolomicsComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [GeneEvidenceMetabolomicsComponent], + imports: [RouterTestingModule], + providers: [GenesService, HelperService], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(GeneEvidenceMetabolomicsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/agora/genes/src/lib/components/gene-evidence-metabolomics/gene-evidence-metabolomics.component.ts b/libs/agora/genes/src/lib/components/gene-evidence-metabolomics/gene-evidence-metabolomics.component.ts new file mode 100644 index 0000000000..823760fb53 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-evidence-metabolomics/gene-evidence-metabolomics.component.ts @@ -0,0 +1,61 @@ +import { Component, inject, Input } from '@angular/core'; +import { Gene } from '@sagebionetworks/agora/api-client-angular'; +import { BoxPlotComponent } from '@sagebionetworks/agora/charts'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { DownloadDomImageComponent } from '../download-dom-image/download-dom-image.component'; +import { ModalLinkComponent } from '@sagebionetworks/agora/shared'; + +@Component({ + selector: 'agora-gene-evidence-metabolomics', + standalone: true, + imports: [ModalLinkComponent, DownloadDomImageComponent, BoxPlotComponent], + providers: [HelperService], + templateUrl: './gene-evidence-metabolomics.component.html', + styleUrls: ['./gene-evidence-metabolomics.component.scss'], +}) +export class GeneEvidenceMetabolomicsComponent { + helperService = inject(HelperService); + + _gene: Gene | undefined; + get gene(): Gene | undefined { + return this._gene; + } + @Input() set gene(gene: Gene | undefined) { + this._gene = gene; + this.init(); + } + + boxPlotData: any = []; + + reset() { + this.boxPlotData = []; + } + + init() { + this.reset(); + + if (!this._gene?.metabolomics?.['transposed_boxplot_stats']) { + this.boxPlotData = []; + return; + } + + const boxPlotData: any = []; + + this._gene.metabolomics['transposed_boxplot_stats'].forEach((item: string, index: number) => { + boxPlotData.push({ + key: this._gene?.metabolomics?.['boxplot_group_names'][index], + value: item, + }); + }); + + this.boxPlotData = boxPlotData; + } + + getSignificantFigures(n: any, b: any) { + return this.helperService.getSignificantFigures(n, b); + } + + getSignificantText(pval: number): string { + return pval <= 0.05 ? ' is ' : ' is not '; + } +} diff --git a/libs/agora/genes/src/lib/components/gene-evidence-proteomics/gene-evidence-proteomics.component.html b/libs/agora/genes/src/lib/components/gene-evidence-proteomics/gene-evidence-proteomics.component.html new file mode 100644 index 0000000000..5bbf578bd4 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-evidence-proteomics/gene-evidence-proteomics.component.html @@ -0,0 +1,177 @@ +@if (_gene) { +
+
+
+
+

Proteomics

+

+ Proteomic analyses of post-mortem brains show whether protein products of + {{ _gene.hgnc_symbol || _gene.ensembl_gene_id }} are differentially expressed between AD + cases and controls. Each box plot depicts how the differential expression of the + protein(s) of interest (purple dot) compares with expression of other proteins in a + given brain region. Summary statistics for each tissue can be viewed by hovering over + the purple dots. +

+ + +
+
+
+
+
+ + +
+
+
+

+ Targeted SRM Differential Protein Expression + +

+

+ Selected Reaction Monitoring (SRM) data was generated from the DLPFC region of post-mortem + brains of over 1000 individuals from multiple human cohort studies. +

+

+ Note that only a single SRM result is available for a given gene, as the probes used for + this experiment were designed to match multiple protein products derived from each + targeted gene. +

+
+
+
+ +
+
+
+
+ +
+
+
+
+ + +
+
+
+
+

Genome-wide Differential Protein Expression

+

+ Select a protein from the dropdown menu to see whether it is differentially expressed + between AD cases and controls. +

+ +
+ +
+

+ The assay-specific box plots below depict how the differential expression of the + selected protein of interest (purple dot) compares with expression of other proteins in + each brain region that was assayed. Assay-specific summary statistics for each brain + region can be viewed by hovering over the purple dot. +

+

+ Multiple proteins may map to a single gene. Results from both TMT and LFQ assays are + provided, however results for some proteins may be available for only one of the assays. +

+
+
+
+
+
+ + +
+
+
+

+ TMT Differential Protein Expression + +

+

+ Tandem mass tagged (TMT) data was generated from the DLPFC region of post-mortem brains of + 400 individuals from the ROSMAP cohort. +

+

+ Note that proteins may not be detected in this brain region; for these proteins, the plot + will show no data. +

+
+
+
+ +
+
+
+
+ +
+
+
+
+ + + +
+
+
+

+ LFQ Differential Protein Expression + +

+

+ Liquid-free quantification (LFQ) data was generated from post-mortem brains of more than + 500 individuals. Samples were taken from four human cohort studies, representing four + different brain regions. +

+

+ Note that proteins may not be detected in all four brain regions; for these proteins, the + plot will show fewer than four brain regions. +

+
+
+
+ +
+
+
+
+ +
+
+
+
+ +} diff --git a/libs/agora/genes/src/lib/components/gene-evidence-proteomics/gene-evidence-proteomics.component.scss b/libs/agora/genes/src/lib/components/gene-evidence-proteomics/gene-evidence-proteomics.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/agora/genes/src/lib/components/gene-evidence-proteomics/gene-evidence-proteomics.component.spec.ts.off b/libs/agora/genes/src/lib/components/gene-evidence-proteomics/gene-evidence-proteomics.component.spec.ts.off new file mode 100644 index 0000000000..3ed2c34fe3 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-evidence-proteomics/gene-evidence-proteomics.component.spec.ts.off @@ -0,0 +1,59 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { GeneEvidenceProteomicsComponent } from './gene-evidence-proteomics.component'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { GenesService } from '@sagebionetworks/agora/api-client-angular'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Gene Proteomics', () => { + let fixture: ComponentFixture; + let component: GeneEvidenceProteomicsComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [GeneEvidenceProteomicsComponent], + imports: [RouterTestingModule, HttpClientTestingModule], + providers: [GenesService, HelperService], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(GeneEvidenceProteomicsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should create the right tooltip text', () => { + const item = { + _id: '65a5f61467fa5462e23fe5eb', + uniqid: 'PLEC|Q15149', + hgnc_symbol: 'PLEC', + uniprotid: 'Q15149', + ensembl_gene_id: 'ENSG00000178209', + tissue: 'DLPFC', + log2_fc: 0.111785229513828, + ci_upr: 0.147173483154117, + ci_lwr: 0.0763969758735386, + pval: 4.54382282575789e-9, + cor_pval: 0.00000174186460237858, + }; + const tooltipText = component.getTooltipText(item); + const expected = + 'PLEC is significantly differentially expressed in DLPFC with a log fold change value of 0.112 and an adjusted p-value of 0.00000174.'; + expect(tooltipText).toBe(expected); + }); +}); diff --git a/libs/agora/genes/src/lib/components/gene-evidence-proteomics/gene-evidence-proteomics.component.ts b/libs/agora/genes/src/lib/components/gene-evidence-proteomics/gene-evidence-proteomics.component.ts new file mode 100644 index 0000000000..f5ce5d696d --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-evidence-proteomics/gene-evidence-proteomics.component.ts @@ -0,0 +1,198 @@ +import { Component, inject, Input } from '@angular/core'; + +import { DistributionService, Gene } from '@sagebionetworks/agora/api-client-angular'; +import { ChartRange } from '@sagebionetworks/agora/models'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { GeneProteinSelectorComponent } from '../gene-protein-selector/gene-protein-selector.component'; +import { BoxPlotComponent } from '@sagebionetworks/agora/charts'; +import { DownloadDomImageComponent } from '../download-dom-image/download-dom-image.component'; +import { ModalLinkComponent } from '@sagebionetworks/agora/shared'; + +@Component({ + selector: 'agora-gene-evidence-proteomics', + standalone: true, + imports: [ + ModalLinkComponent, + DownloadDomImageComponent, + GeneProteinSelectorComponent, + BoxPlotComponent, + ], + providers: [HelperService, DistributionService], + templateUrl: './gene-evidence-proteomics.component.html', + styleUrls: ['./gene-evidence-proteomics.component.scss'], +}) +export class GeneEvidenceProteomicsComponent { + helperService = inject(HelperService); + distributionService = inject(DistributionService); + + _gene: Gene | undefined; + get gene(): Gene | undefined { + return this._gene; + } + @Input() set gene(gene: Gene | undefined) { + this._gene = gene; + this.init(); + } + + uniProtIds: string[] = []; + selectedUniProtId = ''; + + LFQData: any = undefined; + LFQRange: ChartRange | undefined; + + SRMData: any = undefined; + SRMRange: ChartRange | undefined; + + TMTData: any = undefined; + TMTRange: ChartRange | undefined; + + reset() { + this.uniProtIds = []; + this.selectedUniProtId = ''; + + this.SRMData = undefined; + this.SRMRange = undefined; + + this.LFQData = undefined; + this.LFQRange = undefined; + + this.TMTData = undefined; + this.TMTRange = undefined; + } + + init() { + this.reset(); + + this.uniProtIds = []; + + this._gene?.proteomics_LFQ?.forEach((item: any) => { + if (!this.uniProtIds.includes(item.uniprotid)) { + this.uniProtIds.push(item.uniprotid); + } + }); + + this._gene?.proteomics_TMT?.forEach((item: any) => { + if (!this.uniProtIds.includes(item.uniprotid)) { + this.uniProtIds.push(item.uniprotid); + } + }); + + this.uniProtIds.sort(); + if (!this.selectedUniProtId) { + this.selectedUniProtId = this.uniProtIds[0]; + } + + this.initSRM(); + this.initLFQ(); + this.initTMT(); + } + + processDifferentialExpressionData(item: any, data: any, range: ChartRange, proteomicData: any) { + const yAxisMin = item.log2_fc < data.min ? item.log2_fc : data.min; + const yAxisMax = item.log2_fc > data.max ? item.log2_fc : data.max; + + if (yAxisMin < range.Min) { + range.Min = yAxisMin; + } + + if (yAxisMax > range.Max) { + range.Max = yAxisMax; + } + + proteomicData.push({ + key: data.tissue, + value: [data.min, data.median, data.max], + circle: { + value: item.log2_fc, + tooltip: this.getTooltipText(item), + }, + quartiles: + data.first_quartile > data.third_quartile + ? [data.third_quartile, data.median, data.first_quartile] + : [data.first_quartile, data.median, data.third_quartile], + }); + } + + initSRM() { + this.distributionService.getDistribution().subscribe((data: any) => { + const distribution = data.proteomics_SRM; + const differentialExpression = this._gene?.proteomics_SRM || []; + const proteomicData: any = []; + + differentialExpression.forEach((item: any) => { + const data: any = distribution.find((d: any) => { + return d.tissue === item.tissue; + }); + + if (data) { + if (!this.SRMRange) this.SRMRange = new ChartRange(data.min, data.max); + this.processDifferentialExpressionData(item, data, this.SRMRange, proteomicData); + } + }); + + this.SRMData = proteomicData; + }); + } + + initLFQ() { + this.distributionService.getDistribution().subscribe((data: any) => { + const distribution = data.proteomics_LFQ; + const differentialExpression = + this._gene?.proteomics_LFQ?.filter((item: any) => { + return item.uniprotid === this.selectedUniProtId; + }) || []; + const proteomicData: any = []; + + differentialExpression.forEach((item: any) => { + const data: any = distribution.find((d: any) => { + return d.tissue === item.tissue; + }); + + if (data) { + if (!this.LFQRange) this.LFQRange = new ChartRange(data.min, data.max); + this.processDifferentialExpressionData(item, data, this.LFQRange, proteomicData); + } + }); + + this.LFQData = proteomicData; + }); + } + + initTMT() { + this.distributionService.getDistribution().subscribe((data: any) => { + const distribution = data.proteomics_TMT; + const differentialExpression = + this._gene?.proteomics_TMT?.filter((item: any) => { + return item.uniprotid === this.selectedUniProtId; + }) || []; + const proteomicData: any = []; + + differentialExpression.forEach((item: any) => { + const data: any = distribution.find((d: any) => { + return d.tissue === item.tissue; + }); + + if (data) { + if (!this.TMTRange) this.TMTRange = new ChartRange(data.min, data.max); + this.processDifferentialExpressionData(item, data, this.TMTRange, proteomicData); + } + }); + + this.TMTData = proteomicData; + }); + } + + onProteinChange(event: any) { + if (!this._gene?.proteomics_LFQ) { + return; + } + this.selectedUniProtId = event.value; + this.initLFQ(); + this.initTMT(); + } + + getTooltipText(item: any) { + const tooltipText = `${item.hgnc_symbol || item.ensembl_gene_id} is${item.cor_pval <= 0.05 ? '' : ' not'} significantly differentially expressed in ${item.tissue} with a log fold change value of ${this.helperService.getSignificantFigures(item.log2_fc, 3)} and an adjusted p-value of ${this.helperService.getSignificantFigures(item.cor_pval, 3)}.`; + return tooltipText; + } +} diff --git a/libs/agora/genes/src/lib/components/gene-evidence-rna/gene-evidence-rna.component.html b/libs/agora/genes/src/lib/components/gene-evidence-rna/gene-evidence-rna.component.html new file mode 100644 index 0000000000..ae0c8bb23e --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-evidence-rna/gene-evidence-rna.component.html @@ -0,0 +1,205 @@ +@if (_gene) { +
+
+
+
+

RNA Expression

+

+ The results shown on this page are derived from a harmonized RNA-seq analysis of + post-mortem brains from AD cases and controls. The samples were obtained from three + human cohort studies across a total of nine different brain regions. +

+ + + +
+ +

+ Overall Expression of + {{ _gene.hgnc_symbol || _gene.ensembl_gene_id }} Across Brain Regions + +

+

+ This plot depicts the median expression of the selected gene across brain regions, as + measured by RNA-seq read counts per million (CPM) reads. Meaningful expression is + considered to be a log2 CPM greater than log2(5), depicted by the red line in the plot. +

+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+

+ Filter the following charts by statistical model + + +

+ +
+
+
+ +
+
+
+

+ Differential Expression of + {{ _gene.hgnc_symbol || _gene.ensembl_gene_id }} Across Brain Regions + +

+

+ After selecting a statistical model, you will be able to see whether the selected gene + is differentially expressed between AD cases and controls. The box plot depicts how the + differential expression of the selected gene of interest (purple dot) compares with + expression of other genes in a given tissue. Summary statistics for each tissue can be + viewed by hovering over the purple dots. Meaningful differential expression is + considered to be a log2 fold change value greater than 0.263, or less than -0.263. +

+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+

+ Consistency of Change in Expression + +

+

+ This forest plot indicates the estimate of the log fold change with standard errors + across the brain regions in the model chosen using the filter above. Genes that show + consistent patterns of differential expression will have similar log-fold change value + across brain regions. +

+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+

+ Correlation of {{ _gene.hgnc_symbol || _gene.ensembl_gene_id }} with Hallmarks of AD + +

+

+ + +

+

+ This plot depicts the association between expression levels of the selected gene in the + DLPFC and three phenotypic measures of AD. An odds ratio > 1 indicates a positive + correlation and an odds ratio < 1 indicates a negative correlation. Statistical + significance and summary statistics for each phenotype can be viewed by hovering over + the dots. +

+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+

Similarly Expressed Genes

+

+ The network diagram below is based on a coexpression network analysis of RNA-seq data + from AD cases and controls. The network analysis uses an ensemble methodology to + identify genes that show similar coexpression across individuals. +

+

+ The color of the edges and nodes indicates how frequently significant coexpression was + identified. Each node represents a different gene and the amount of edges within the + network. Darker edges represent coexpression in more brain regions. +

+
+
+
+ +
+
+
+ +
+
+
+
+} diff --git a/libs/agora/genes/src/lib/components/gene-evidence-rna/gene-evidence-rna.component.scss b/libs/agora/genes/src/lib/components/gene-evidence-rna/gene-evidence-rna.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/agora/genes/src/lib/components/gene-evidence-rna/gene-evidence-rna.component.spec.ts.off b/libs/agora/genes/src/lib/components/gene-evidence-rna/gene-evidence-rna.component.spec.ts.off new file mode 100644 index 0000000000..6cf8749e6d --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-evidence-rna/gene-evidence-rna.component.spec.ts.off @@ -0,0 +1,38 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { GeneEvidenceRnaComponent } from './gene-evidence-rna.component'; +import { HelperService } from '@sagebionetworks/agora/services'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Gene RNA', () => { + let fixture: ComponentFixture; + let component: GeneEvidenceRnaComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [GeneEvidenceRnaComponent], + imports: [RouterTestingModule, HttpClientTestingModule], + providers: [HelperService], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(GeneEvidenceRnaComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/agora/genes/src/lib/components/gene-evidence-rna/gene-evidence-rna.component.ts b/libs/agora/genes/src/lib/components/gene-evidence-rna/gene-evidence-rna.component.ts new file mode 100644 index 0000000000..d6f32f40ec --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-evidence-rna/gene-evidence-rna.component.ts @@ -0,0 +1,232 @@ +import { AfterViewChecked, Component, inject, Input, ViewChild } from '@angular/core'; + +import { HelperService } from '@sagebionetworks/agora/services'; +// import { BoxplotDirective } from '@sagebionetworks/shared/charts-angular'; +import { + BoxPlotComponent, + CandlestickChartComponent, + MedianBarChartComponent, + RowChartComponent, +} from '@sagebionetworks/agora/charts'; +import { GeneNetworkComponent } from '../gene-network/gene-network.component'; +import { GeneModelSelectorComponent } from '../gene-model-selector/gene-model-selector.component'; +import { + DistributionService, + Gene, + MedianExpression, + RnaDifferentialExpression, +} from '@sagebionetworks/agora/api-client-angular'; +import { getStatisticalModels } from '../../helpers'; +import { DownloadDomImageComponent } from '../download-dom-image/download-dom-image.component'; +import { ModalLinkComponent } from '@sagebionetworks/agora/shared'; + +@Component({ + selector: 'agora-gene-evidence-rna', + standalone: true, + imports: [ + GeneNetworkComponent, + GeneModelSelectorComponent, + CandlestickChartComponent, + RowChartComponent, + MedianBarChartComponent, + ModalLinkComponent, + DownloadDomImageComponent, + BoxPlotComponent, + ], + providers: [HelperService], + templateUrl: './gene-evidence-rna.component.html', + styleUrls: ['./gene-evidence-rna.component.scss'], +}) +export class GeneEvidenceRnaComponent implements AfterViewChecked { + helperService = inject(HelperService); + distributionService = inject(DistributionService); + + _gene: Gene | undefined; + get gene(): Gene | undefined { + return this._gene; + } + @Input() set gene(gene: Gene | undefined) { + this._gene = gene; + this.init(); + } + + statisticalModels: string[] = []; + selectedStatisticalModel = ''; + + medianExpression: MedianExpression[] = []; + differentialExpression: RnaDifferentialExpression[] = []; + + differentialExpressionChartData: any | undefined; + differentialExpressionYAxisMin: number | undefined; + differentialExpressionYAxisMax: number | undefined; + + consistencyOfChangeChartData: any | undefined; + + @ViewChild(BoxPlotComponent) boxPlotComponent: BoxPlotComponent | null = null; + hasScrolled = false; + + reset() { + this.statisticalModels = []; + this.selectedStatisticalModel = ''; + + this.medianExpression = []; + this.differentialExpression = []; + + this.differentialExpressionChartData = undefined; + this.differentialExpressionYAxisMin = undefined; + this.differentialExpressionYAxisMax = undefined; + + this.consistencyOfChangeChartData = undefined; + + this.hasScrolled = false; + } + + init() { + this.reset(); + + if (!this._gene?.rna_differential_expression) { + return; + } + + this.statisticalModels = getStatisticalModels(this._gene); + + const urlModelParam = this.helperService.getUrlParam('model'); + this.selectedStatisticalModel = urlModelParam || this.statisticalModels[0]; + + this.initMedianExpression(); + this.initDifferentialExpression(); + this.initConsistencyOfChange(); + } + + ngAfterViewChecked() { + this.scrollToAnchorLink(); + } + + scrollToAnchorLink() { + // AG-1408 - wait for differential expression box plot to finish loading before scrolling + if (this.boxPlotComponent?.isInitialized && !this.hasScrolled) { + const hash = window.location.hash.slice(1); + if (hash) { + const target = document.getElementById(hash); + if (target) { + window.scrollTo(0, this.helperService.getOffset(target).top - 150); + this.hasScrolled = true; + } + } + } + } + + initMedianExpression() { + if (!this._gene?.median_expression?.length) { + this.medianExpression = []; + return; + } + + this.medianExpression = this._gene.median_expression.filter((d) => d.median && d.median > 0); + } + + initDifferentialExpression() { + if (!this._gene?.rna_differential_expression?.length) { + this.differentialExpression = []; + return; + } + + this.differentialExpression = this._gene.rna_differential_expression.filter((g: any) => { + return g.model === this.selectedStatisticalModel; + }); + + this.distributionService.getDistribution().subscribe((data: any) => { + const distribution = data.rna_differential_expression.filter((data: any) => { + return data.model === this.selectedStatisticalModel; + }); + + const differentialExpressionChartData: any = []; + + this.differentialExpression.forEach((item: any) => { + const data: any = distribution.find((d: any) => { + return d.tissue === item.tissue; + }); + + if (data) { + const yAxisMin = item.logfc < data.min ? item.logfc : data.min; + const yAxisMax = item.logfc > data.max ? item.logfc : data.max; + + if ( + this.differentialExpressionYAxisMin === undefined || + yAxisMin < this.differentialExpressionYAxisMin + ) { + this.differentialExpressionYAxisMin = yAxisMin; + } + + if ( + this.differentialExpressionYAxisMax === undefined || + yAxisMax > this.differentialExpressionYAxisMax + ) { + this.differentialExpressionYAxisMax = yAxisMax; + } + + differentialExpressionChartData.push({ + key: data.tissue, + value: [data.min, data.median, data.max], + circle: { + value: item.logfc, + tooltip: + (item.hgnc_symbol || item.ensembl_gene_id) + + ' is ' + + (item.adj_p_val <= 0.05 ? ' ' : 'not ') + + 'significantly differentially expressed in ' + + item.tissue + + ' with a log fold change value of ' + + this.helperService.getSignificantFigures(item.logfc, 3) + + ' and an adjusted p-value of ' + + this.helperService.getSignificantFigures(item.adj_p_val, 3) + + '.', + }, + quartiles: + data.first_quartile > data.third_quartile + ? [data.third_quartile, data.median, data.first_quartile] + : [data.first_quartile, data.median, data.third_quartile], + }); + } + }); + + if (this.differentialExpressionYAxisMin) { + this.differentialExpressionYAxisMin -= 0.2; + } + + if (this.differentialExpressionYAxisMax) { + this.differentialExpressionYAxisMax += 0.2; + } + + this.differentialExpressionChartData = differentialExpressionChartData; + }); + } + + initConsistencyOfChange() { + this.consistencyOfChangeChartData = this.differentialExpression.map((item: any) => { + return { + key: [item.tissue, item.ensembl_gene_id, item.model], + value: { + adj_p_val: item.adj_p_val, + fc: item.fc, + logfc: item.logfc, + }, + tissue: item.tissue, + ci_l: item.ci_l, + ci_r: item.ci_r, + }; + }); + } + + onStatisticalModelChange(event: any) { + if (!event) { + return; + } + if (!this._gene?.rna_differential_expression) { + return; + } + this.selectedStatisticalModel = event.name; + this.initDifferentialExpression(); + this.initConsistencyOfChange(); + } +} diff --git a/libs/agora/genes/src/lib/components/gene-experimental-validation/gene-experimental-validation.component.html b/libs/agora/genes/src/lib/components/gene-experimental-validation/gene-experimental-validation.component.html new file mode 100644 index 0000000000..bb10e6cbfb --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-experimental-validation/gene-experimental-validation.component.html @@ -0,0 +1,66 @@ +@if (_gene && _gene.experimental_validation?.length) { +
+
+
+

+ Experimental Validation of + {{ _gene.hgnc_symbol || _gene.ensembl_gene_id }} +

+

+ Nominating teams provided details on experimental validation studies they performed to + examine a role for the target in AD. +

+ +
+ +
+ @if (d.team_data) { +
+

{{ d.team_data.program }} : {{ d.team_data.team_full }}

+

{{ d.team_data.description }}

+
+ } + +

Hypothesis Being Tested

+

{{ d.hypothesis_tested }}

+ +

Species and Model System

+

+ {{ d.species }}; + {{ d.model_system }} +

+ +

Outcome Measure

+

+ {{ d.outcome_measure + }}; + {{ d.outcome_measure_details }} +

+ +

Summary of Findings

+

{{ d.summary_findings }}

+ +

Contributors

+

{{ d.contributors }}

+ +

Published?

+

+ {{ d.published }}; + {{ + d.reference_doi + }} +

+ +

Date of Report

+

{{ d.date_report }}

+ + @if (i + 1 < (_gene.experimental_validation?.length || 0)) { +
+ } +
+
+
+
+} diff --git a/libs/agora/genes/src/lib/components/gene-experimental-validation/gene-experimental-validation.component.scss b/libs/agora/genes/src/lib/components/gene-experimental-validation/gene-experimental-validation.component.scss new file mode 100644 index 0000000000..17e5aa1add --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-experimental-validation/gene-experimental-validation.component.scss @@ -0,0 +1,11 @@ +@import 'libs/agora/styles/src/lib/variables'; +@import 'libs/agora/styles/src/lib/mixins'; + +h4 { + font-weight: 700; + font-style: normal; + text-align: left; + line-height: normal; + color: #24334f; + font-size: 20px; +} diff --git a/libs/agora/genes/src/lib/components/gene-experimental-validation/gene-experimental-validation.component.spec.ts.off b/libs/agora/genes/src/lib/components/gene-experimental-validation/gene-experimental-validation.component.spec.ts.off new file mode 100644 index 0000000000..a90337a0c0 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-experimental-validation/gene-experimental-validation.component.spec.ts.off @@ -0,0 +1,39 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { ExperimentalValidationComponent } from './gene-experimental-validation.component'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { GenesService, TeamsService } from '@sagebionetworks/agora/api-client-angular'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Gene Experimental Validation', () => { + let fixture: ComponentFixture; + let component: ExperimentalValidationComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ExperimentalValidationComponent], + imports: [RouterTestingModule, HttpClientTestingModule], + providers: [GenesService, TeamsService, HelperService], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(ExperimentalValidationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/agora/genes/src/lib/components/gene-experimental-validation/gene-experimental-validation.component.ts b/libs/agora/genes/src/lib/components/gene-experimental-validation/gene-experimental-validation.component.ts new file mode 100644 index 0000000000..e7cac9c131 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-experimental-validation/gene-experimental-validation.component.ts @@ -0,0 +1,49 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject, Input } from '@angular/core'; + +import { Gene, TeamsService } from '@sagebionetworks/agora/api-client-angular'; +import { ExperimentalValidationWithTeamData } from '../../models'; + +@Component({ + selector: 'agora-gene-experimental-validation', + standalone: true, + imports: [CommonModule], + providers: [TeamsService], + templateUrl: './gene-experimental-validation.component.html', + styleUrls: ['./gene-experimental-validation.component.scss'], +}) +export class ExperimentalValidationComponent { + teamService = inject(TeamsService); + + _gene: Gene | undefined; + get gene(): Gene | undefined { + return this._gene; + } + @Input() set gene(gene: Gene | undefined) { + this._gene = gene; + this.init(); + } + + experimentalValidationWithTeamData: ExperimentalValidationWithTeamData[] = []; + + init() { + this.teamService.listTeams().subscribe((response) => { + if ( + !this.gene || + !this.gene.experimental_validation || + !this.gene.experimental_validation.length + ) { + return; + } + + const teams = response.items; + if (teams) { + this.experimentalValidationWithTeamData = this.gene.experimental_validation.map((item) => { + const extendedItem: ExperimentalValidationWithTeamData = { ...item }; + extendedItem.team_data = teams.find((t) => t.team === item.team); + return extendedItem; + }); + } + }); + } +} diff --git a/libs/agora/genes/src/lib/components/gene-hero/gene-hero.component.html b/libs/agora/genes/src/lib/components/gene-hero/gene-hero.component.html new file mode 100644 index 0000000000..d4641a34fa --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-hero/gene-hero.component.html @@ -0,0 +1,65 @@ +@if (gene) { +
+
+
+

+ {{ gene.hgnc_symbol || gene.ensembl_gene_id }} +

+

+ {{ gene.name }} +

+ @if (showNominationsOrTEP()) { +
+ {{ getNominationText() }} +
+ } +

+ {{ getSummary(true) }} +

+

+ {{ getSummary() }} +

+ @if (gene.bio_domains || getAlias() || getEnsemblUrl() !== '') { +
+ @if (gene.bio_domains) { +
+

Biological Domains

+

+ {{ getBiodomains() }} +

+
+ } +
+

Also known as

+ @if (getEnsemblUrl() !== '') { +

+ {{ gene.ensembl_gene_id }} + @if (gene.ensembl_info.ensembl_release) { + (Ensembl Release {{ gene.ensembl_info.ensembl_release }}) + } +

+ } + @if (getEnsemblUrl() === '') { + {{ gene.ensembl_gene_id }} + } + + @if (gene.ensembl_info.ensembl_possible_replacements.length > 0) { +

+ Possible replacement values: + {{ gene.ensembl_info.ensembl_possible_replacements.join(', ') }} +

+ } + @if (alias !== '') { +

{{ alias }}

+ } +
+ } +
+
+
+} diff --git a/libs/agora/genes/src/lib/components/gene-hero/gene-hero.component.scss b/libs/agora/genes/src/lib/components/gene-hero/gene-hero.component.scss new file mode 100644 index 0000000000..6f9f580dc9 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-hero/gene-hero.component.scss @@ -0,0 +1,60 @@ +@import 'libs/agora/styles/src/lib/variables'; +@import 'libs/agora/styles/src/lib/mixins'; + +hr { + border-color: #fff; + opacity: 0.25; + margin-top: 30px; + margin-bottom: 30px; +} + +.gene-hero-top { + .section-inner { + padding-top: 30px; + padding-bottom: 30px; + } +} + +.gene-hero-bottom { + background: rgb(255 255 255 / 35%); +} + +.gene-hero-heading { + margin-bottom: 15px; +} + +.gene-hero-name { + margin-bottom: 15px; +} + +.gene-hero-nominated { + display: flex; + align-items: center; + margin-bottom: 15px; + + svg { + margin-right: 6px; + } +} + +.gene-hero-biodomains { + margin-bottom: 30px; +} + +.gene-hero-summary { + max-width: 980px; +} + +.gene-hero-provider { + font-style: italic; +} + +.gene-hero-aliases-heading, +.gene-hero-biodomains-heading { + text-transform: uppercase; + margin-bottom: 15px; +} + +.possible-replacements { + margin-bottom: 15px; +} diff --git a/libs/agora/genes/src/lib/components/gene-hero/gene-hero.component.spec.ts.off b/libs/agora/genes/src/lib/components/gene-hero/gene-hero.component.spec.ts.off new file mode 100644 index 0000000000..7d72ce9f17 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-hero/gene-hero.component.spec.ts.off @@ -0,0 +1,127 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { GeneHeroComponent } from './gene-hero.component'; +import { geneMock1, geneMock3 } from '@sagebionetworks/agora/testing'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Gene Hero', () => { + let fixture: ComponentFixture; + let component: GeneHeroComponent; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [GeneHeroComponent], + imports: [RouterTestingModule], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(GeneHeroComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + element = fixture.nativeElement; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show nomination and TEP text for MSN if nomination exists and is either is_tep or is_adi is true', () => { + const expected = 'Nominated Target, Selected for Target Enabling Resource Development'; + + component.gene = geneMock1; + component.gene.is_tep = true; + component.gene.is_adi = false; + fixture.detectChanges(); + + let el = element.querySelector('.gene-hero-nominated') as HTMLElement; + + expect(el.textContent).toBe(expected); + + component.gene = geneMock1; + component.gene.is_tep = false; + component.gene.is_adi = true; + fixture.detectChanges(); + + el = element.querySelector('.gene-hero-nominated') as HTMLElement; + + expect(el.textContent).toBe(expected); + }); + + it('should show nomination and not show TEP text for MSN if both is_tep and is_adi is false', () => { + const expected = 'Nominated Target'; + + component.gene = geneMock1; + component.gene.is_tep = false; + component.gene.is_adi = false; + fixture.detectChanges(); + + const el = element.querySelector('.gene-hero-nominated') as HTMLElement; + + expect(el.textContent).toBe(expected); + }); + + it('should not show nomination and show TEP text for HCK if nominations is null and either is_tep or is_adi is true', () => { + const expected = 'Selected for Target Enabling Resource Development'; + + component.gene = geneMock3; + component.gene.is_tep = false; + component.gene.is_adi = true; + fixture.detectChanges(); + + let el = element.querySelector('.gene-hero-nominated') as HTMLElement; + + expect(el.textContent).toBe(expected); + + component.gene = geneMock3; + component.gene.is_adi = false; + component.gene.is_tep = true; + fixture.detectChanges(); + + el = element.querySelector('.gene-hero-nominated') as HTMLElement; + + expect(el.textContent).toBe(expected); + }); + + it('should not show nomination and not show TEP text for HCK if nominations is null and both is_tep or is_adi is false', () => { + component.gene = geneMock3; + component.gene.is_adi = false; + component.gene.is_tep = false; + fixture.detectChanges(); + + const el = element.querySelector('.gene-hero-nominated'); + + expect(el).toBe(null); + }); + + it('should comma separate and order the biodomains alphabetically', () => { + component.gene = geneMock1; + const expected = + 'Immune Response, Lipid Metabolism, Structural Stabilization, Synapse, Vasculature'; + expect(component.getBiodomains()).toBe(expected); + }); + + it('should return the ensembl permalink', () => { + component.gene = geneMock1; + expect(component.getEnsemblUrl()).toBe( + 'https://may2015.archive.ensembl.org/Homo_sapiens/Gene/Summary?db=core;g=ENSG00000264794', + ); + }); + + it('should return a url with ensembl id', () => { + component.gene = geneMock1; + expect(component.getPossibleReplacementsURL()).toBe( + 'https://useast.ensembl.org/Homo_sapiens/Gene/Idhistory?g=ENSG00000147065', + ); + }); +}); diff --git a/libs/agora/genes/src/lib/components/gene-hero/gene-hero.component.ts b/libs/agora/genes/src/lib/components/gene-hero/gene-hero.component.ts new file mode 100644 index 0000000000..6b70bf60f2 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-hero/gene-hero.component.ts @@ -0,0 +1,119 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input, OnInit } from '@angular/core'; +import { Gene } from '@sagebionetworks/agora/api-client-angular'; +import { ascending } from 'd3'; + +@Component({ + selector: 'agora-gene-hero', + standalone: true, + imports: [CommonModule], + templateUrl: './gene-hero.component.html', + styleUrls: ['./gene-hero.component.scss'], +}) +export class GeneHeroComponent implements OnInit { + @Input() gene: Gene | undefined; + + alias = ''; + + ngOnInit() { + this.alias = this.getAlias(); + } + + showNominationsOrTEP() { + if (!this.gene) return false; + return this.gene.total_nominations || this.gene.is_adi || this.gene.is_tep; + } + + getNominationText() { + if (!this.gene) return ''; + let result = ''; + if (this.gene.total_nominations) { + result += 'Nominated Target'; + } + if (this.gene.is_adi || this.gene.is_tep) { + result += this.gene.total_nominations ? ', ' : ''; + return (result += 'Selected for Target Enabling Resource Development'); + } + return result; + } + + getSummary(body = false): string { + if (this.gene?.summary) { + let finalString = ''; + const parenthesisArr = this.gene.summary.split(/\(([^)]+)\)/g); + if (parenthesisArr.length) { + parenthesisArr.forEach((p, i, a) => { + // Add the parenthesis back + let auxString = ''; + if (i > 0) { + auxString += i % 2 === 1 ? '(' : ')'; + } + if (i < a.length - 1) { + // Replace brackets with a space except the last one + finalString += auxString + p.replace(/\[[^)]*\]/g, ' '); + } else { + finalString += auxString + p; + } + }); + } + if (!finalString) { + finalString = this.gene.summary; + } + const bracketsArr = finalString.split(/\[([^)]+)\]/g); + if (bracketsArr.length && bracketsArr.length > 1) { + // We have brackets so get the description and ref back + if (body) { + // Replace the spaces before and where the brackets were + // with nothing + return bracketsArr[0].replace(/ {2}/g, ''); + } else { + // Return the last bracket string + if (bracketsArr[1].includes(',')) { + bracketsArr[1] = bracketsArr[1].split(',')[0]; + } + return bracketsArr[1]; + } + } else { + // We dont have brackets so just get the description back + if (body) { + return finalString; + } else { + return ''; + } + } + } else { + // If we don't have a summary, return a placeholder description and an empty ref + if (body) { + return ''; + } else { + return ''; + } + } + } + + getAlias(): string { + if (this.gene?.alias && this.gene.alias.length > 0) { + return this.gene.alias.join(', '); + } + return ''; + } + + getBiodomains(): string { + if (!this.gene || !this.gene.bio_domains) return ''; + const biodomains = this.gene.bio_domains.gene_biodomains + .filter((b) => b.pct_linking_terms > 0) + .map((b) => b.biodomain) + .sort(ascending); + return biodomains.join(', '); + } + + getEnsemblUrl() { + if (!this.gene?.ensembl_info) return ''; + return this.gene?.ensembl_info.ensembl_permalink; + } + + getPossibleReplacementsURL() { + let url = 'https://useast.ensembl.org/Homo_sapiens/Gene/Idhistory?g='; + return (url += this.gene?.ensembl_gene_id); + } +} diff --git a/libs/agora/genes/src/lib/components/gene-model-selector/gene-model-selector.component.html b/libs/agora/genes/src/lib/components/gene-model-selector/gene-model-selector.component.html new file mode 100644 index 0000000000..f12e0ca844 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-model-selector/gene-model-selector.component.html @@ -0,0 +1,9 @@ +
+ +
diff --git a/libs/agora/genes/src/lib/components/gene-model-selector/gene-model-selector.component.scss b/libs/agora/genes/src/lib/components/gene-model-selector/gene-model-selector.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/agora/genes/src/lib/components/gene-model-selector/gene-model-selector.component.spec.ts.off b/libs/agora/genes/src/lib/components/gene-model-selector/gene-model-selector.component.spec.ts.off new file mode 100644 index 0000000000..1e4779f9a8 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-model-selector/gene-model-selector.component.spec.ts.off @@ -0,0 +1,35 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { GeneModelSelectorComponent } from './gene-model-selector.component'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Gene Model Selector', () => { + let fixture: ComponentFixture; + let component: GeneModelSelectorComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [GeneModelSelectorComponent], + imports: [RouterTestingModule], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(GeneModelSelectorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/agora/genes/src/lib/components/gene-model-selector/gene-model-selector.component.ts b/libs/agora/genes/src/lib/components/gene-model-selector/gene-model-selector.component.ts new file mode 100644 index 0000000000..b389051b36 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-model-selector/gene-model-selector.component.ts @@ -0,0 +1,58 @@ +import { Component, Input, Output, EventEmitter, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { removeParenthesis } from '@sagebionetworks/agora/util'; +import { DropdownModule } from 'primeng/dropdown'; + +interface Option { + name: string; + value: string; +} + +@Component({ + selector: 'agora-gene-model-selector', + imports: [FormsModule, DropdownModule], + standalone: true, + templateUrl: './gene-model-selector.component.html', + styleUrls: ['./gene-model-selector.component.scss'], +}) +export class GeneModelSelectorComponent implements OnInit { + route = inject(ActivatedRoute); + + _options: Option[] = []; + get options(): Option[] { + return this._options; + } + @Input() set options(options: any) { + this.selected = {} as Option; + this._options = + options?.map((option: any) => { + const newValue = removeParenthesis(option); + return { + name: option, + value: newValue, + } as Option; + }) || []; + } + + selected: Option = { name: '', value: '' }; + + @Output() changeEvent: EventEmitter = new EventEmitter(); + + ngOnInit() { + this.route.queryParams.subscribe((params) => { + const modelFromURL = params['model']; + let index = this._options.findIndex((o) => o.value === modelFromURL); + if (index === -1) { + // default to first option if page is loaded without a model parameter + index = 0; + } + this.selected = this._options[index]; + this._onChange(); + }); + } + + _onChange() { + this.changeEvent.emit(this.selected); + } +} diff --git a/libs/agora/genes/src/lib/components/gene-network/gene-network.component.html b/libs/agora/genes/src/lib/components/gene-network/gene-network.component.html new file mode 100644 index 0000000000..3ce0edb103 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-network/gene-network.component.html @@ -0,0 +1,131 @@ +
+
+
+
+

Filter by Number of Edges

+ +
+
+ >0 +
+ +
+ >{{ filters[filters.length - 1] - 1 }} +
+
+ + + +
+
+
+ + + + Current gene +
+
Selected gene
+
2-3 Edges
+
4-5 Edges
+
6-7 Edges
+
+
+
+
+ + @if (selectedGene) { +
+
+

+ {{ selectedGene.hgnc_symbol || selectedGene.ensembl_gene_id }} +

+ +

{{ selectedGene.summary }}

+ +
+
+
Genetic Association with LOAD
+
+ {{ getText(selectedGene.is_igap) }} +
+
+
+
Brain eQTL
+
+ {{ getText(selectedGene.is_eqtl) }} +
+
+
+
RNA Expression Change in AD Brain
+
+ {{ + getText( + selectedGene.is_any_rna_changed_in_ad_brain, + selectedGene.rna_brain_change_studied + ) + }} +
+
+
+
Protein Expression Change in AD Brain
+
+ {{ + getText( + selectedGene.is_any_protein_changed_in_ad_brain, + selectedGene.protein_brain_change_studied + ) + }} +
+
+
+
Nominated target
+
+ {{ getNominationText(selectedGene.total_nominations) }} +
+
+
+ +
+ +
+ + +
+
+
+ } +
+ +
+
No data is currently available.
+
+
diff --git a/libs/agora/genes/src/lib/components/gene-network/gene-network.component.scss b/libs/agora/genes/src/lib/components/gene-network/gene-network.component.scss new file mode 100644 index 0000000000..fc940b4cd4 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-network/gene-network.component.scss @@ -0,0 +1,248 @@ +/* stylelint-disable no-descending-specificity */ + +@use 'sass:map'; +@import 'libs/agora/styles/src/lib/variables'; +@import 'libs/agora/styles/src/lib/mixins'; + +.gene-network { + min-height: 600px; + width: 100%; + height: 100%; +} + +.gene-network-chart { + padding-top: var(--spacing-xl); + padding-bottom: var(--spacing-xl); +} + +.gene-network-filters-title { + font-size: var(--font-size-sm); + text-align: center; +} + +.gene-network-filters { + display: flex; + justify-content: center; + + .gene-network-filters-inner { + display: flex; + align-items: center; + + > span { + padding-left: 10px; + padding-right: 10px; + } + + a { + display: block; + width: 15px; + height: 15px; + background-color: map.get($gene-network-colors, 'selected'); + border-radius: 50%; + cursor: pointer; + } + + > div:not(:nth-child(2)) { + position: relative; + padding-left: 40px; + + &::after { + content: ' '; + position: absolute; + display: block; + width: 40px; + height: 4px; + top: 6px; + left: 0; + background-color: map.get($gene-network-colors, 'selected'); + } + } + + > div.active ~ div { + a, + &::after { + background-color: var(--color-gray-500); + } + } + } +} + +.gene-network-legend { + display: flex; + justify-content: center; + + .gene-network-legend-inner { + display: flex; + align-items: center; + + > div { + display: flex; + padding-left: 15px; + padding-right: 15px; + + svg { + margin-right: 10px; + color: map.get($gene-network-colors, 'main'); + } + + &:not(:first-child) { + &::before { + content: ' '; + display: inline-block; + width: 20px; + height: 20px; + margin-right: 10px; + border-radius: 50%; + } + } + + &:nth-child(2)::before { + background-color: map.get($gene-network-colors, 'selected'); + } + + &:nth-child(3)::before { + background-color: map.get($gene-network-colors, '2-3'); + } + + &:nth-child(4)::before { + background-color: map.get($gene-network-colors, '4-5'); + } + + &:nth-child(5)::before { + background-color: map.get($gene-network-colors, '>6'); + } + } + } +} + +.gene-network-selected { + height: 100%; + padding: var(--spacing-xl) var(--spacing-lg); + background-color: #fff; + border: 1px solid var(--color-gray-200); + box-sizing: border-box; +} + +.gene-network-selected-details { + padding-top: 30px; + + > div { + display: flex; + + > div { + &:not(:last-child) { + margin-bottom: 30px; + } + + &:first-child { + flex-grow: 1; + font-weight: 700; + text-transform: uppercase; + } + + &:last-child { + padding-left: 15px; + } + } + } +} + +.gene-network-selected-similar { + display: flex; + + > div:first-child { + padding-right: 30px; + } +} + +.gene-network-selected-similar-list { + a { + @include link; + + display: inline-block; + font-weight: 700; + margin-bottom: 10px; + } +} + +.gene-network-selected-similar-link { + display: flex; + + > div:last-child { + display: flex; + align-items: center; + padding-left: 30px; + color: var(--color-link); + + i { + font-size: var(--font-size-xl); + cursor: pointer; + } + } +} + +.gene-network-no-data { + display: flex; + width: 100%; + height: 450px; + background-color: var(--color-gray-200); + align-items: center; + justify-content: center; + + .gene-network-no-data-text { + font-size: var(--font-size-lg); + font-style: italic; + color: var(--color-gray-600); + } +} + +.network-chart { + svg { + .network-chart-link { + stroke: map.get($gene-network-colors, '>6'); + + &.edges-0, + &.edges-1 { + stroke: map.get($gene-network-colors, 'default'); + } + + &.edges-2, + &.edges-3 { + stroke: map.get($gene-network-colors, '2-3'); + } + + &.edges-4, + &.edges-5 { + stroke: map.get($gene-network-colors, '4-5'); + } + } + + .network-chart-node { + fill: map.get($gene-network-colors, '>6'); + cursor: pointer; + + &.edges-0, + &.edges-1 { + fill: map.get($gene-network-colors, 'default'); + } + + &.edges-2, + &.edges-3 { + fill: map.get($gene-network-colors, '2-3'); + } + + &.edges-4, + &.edges-5 { + fill: map.get($gene-network-colors, '4-5'); + } + + &.main { + fill: map.get($gene-network-colors, 'main'); + } + + &.selected { + fill: map.get($gene-network-colors, 'selected'); + } + } + } +} diff --git a/libs/agora/genes/src/lib/components/gene-network/gene-network.component.spec.ts.off b/libs/agora/genes/src/lib/components/gene-network/gene-network.component.spec.ts.off new file mode 100644 index 0000000000..7005831e2c --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-network/gene-network.component.spec.ts.off @@ -0,0 +1,37 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { GeneNetworkComponent } from './gene-network.component'; +import { GenesService } from '@sagebionetworks/agora/api-client-angular'; +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Gene Network', () => { + let fixture: ComponentFixture; + let component: GeneNetworkComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [GeneNetworkComponent], + imports: [RouterTestingModule, HttpClientTestingModule], + providers: [GenesService], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(GeneNetworkComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/agora/genes/src/lib/components/gene-network/gene-network.component.ts b/libs/agora/genes/src/lib/components/gene-network/gene-network.component.ts new file mode 100644 index 0000000000..69b1fe7538 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-network/gene-network.component.ts @@ -0,0 +1,141 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input, ViewEncapsulation, inject } from '@angular/core'; +import { Router } from '@angular/router'; + +import { + Gene, + GenesService, + SimilarGenesNetworkLink, + SimilarGenesNetworkNode, +} from '@sagebionetworks/agora/api-client-angular'; +import { NetworkChartComponent } from '@sagebionetworks/agora/charts'; +import { + NetworkChartData, + NetworkChartLink, + NetworkChartNode, +} from '@sagebionetworks/agora/models'; +import { TooltipModule } from 'primeng/tooltip'; + +@Component({ + selector: 'agora-gene-network', + standalone: true, + imports: [CommonModule, NetworkChartComponent, TooltipModule], + providers: [GenesService], + templateUrl: './gene-network.component.html', + styleUrls: ['./gene-network.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class GeneNetworkComponent { + router = inject(Router); + geneService = inject(GenesService); + + _gene: Gene | undefined; + get gene(): Gene | undefined { + return this._gene; + } + @Input() set gene(gene: Gene | undefined) { + this._gene = gene; + this.selectedGene = this._gene; + this.init(); + } + + data: NetworkChartData | undefined; + selectedGene: Gene | undefined; + + filters: number[] = []; + selectedFilter = 1; + + init() { + if (!this._gene?.similar_genes_network?.nodes?.length) { + this.data = undefined; + return; + } + + const nodes: NetworkChartNode[] = this._gene.similar_genes_network.nodes.map( + (node: SimilarGenesNetworkNode) => { + return { + id: node.ensembl_gene_id, + label: node.hgnc_symbol || node.ensembl_gene_id, + value: node.brain_regions?.length || 0, + class: 'edges-' + (node.brain_regions?.length || 0), + }; + }, + ); + + const links: NetworkChartLink[] = this._gene.similar_genes_network.links.map( + (link: SimilarGenesNetworkLink) => { + return { + source: nodes.find( + (node: NetworkChartNode) => node.id === link.source, + ) as NetworkChartNode, + target: nodes.find( + (node: NetworkChartNode) => node.id === link.target, + ) as NetworkChartNode, + value: link.brain_regions?.length, + class: 'edges-' + (link.brain_regions?.length || 0), + }; + }, + ); + + this.data = { + nodes: nodes, + links: links, + }; + + this.filters = [...Array(this._gene.similar_genes_network.max).keys()].map((n) => { + return ++n; + }); + } + + onNodeClick(node: NetworkChartNode) { + this.geneService.getGene(node.id).subscribe((gene: any) => { + this.selectedGene = gene; + }); + } + + navigateToSimilarGenes() { + // https://github.com/angular/angular/issues/45202 + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.router.navigate(['/genes/' + this._gene?.ensembl_gene_id + '/similar']); + } + + // If the 'state' value can be modified by another boolean value, pass the modifying value as 'isStateApplicable' + // Example: rna_brain_change_studied: false indicates that is_any_rna_changed_in_ad_brain is + // undefined, so calling: + // getText(is_any_rna_changed_in_ad_brain, rna_brain_change_studied) + // will return the desired 'No data' text, regardless of the is_any_rna_changed_in_ad_brain value + getText(state?: boolean, isStateApplicable = true): string { + let text = ''; + + if (!isStateApplicable) { + text = 'No data'; + } else { + if (state) { + text = 'True'; + } else { + if (state === undefined) { + text = 'No data'; + } else { + text = 'False'; + } + } + } + return text; + } + + getNominationText(nominations: number | null): string { + return this.getText(nominations === null ? false : nominations > 0); + } + + // Use black text if 'isStateApplicable' is false ('No data') + // Otherwise, use green text when 'state' is true, use red text when 'state' is false + // getTextColorClass(state: boolean, isStateApplicable = true): any { + // const colorClassObj = {} as any; + // if (state && isStateApplicable) { + // colorClassObj['text-success'] = true; + // } else if (!state && isStateApplicable) { + // colorClassObj['text-danger'] = true; + // } + // return colorClassObj; + // } +} diff --git a/libs/agora/genes/src/lib/components/gene-nominations/gene-nominations.component.html b/libs/agora/genes/src/lib/components/gene-nominations/gene-nominations.component.html new file mode 100644 index 0000000000..7ce19bfddf --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-nominations/gene-nominations.component.html @@ -0,0 +1,92 @@ +
+
+
+

+ Evidence Supporting the Nomination of + {{ gene?.hgnc_symbol || gene?.ensembl_gene_id }} +

+

+ This gene has been nominated as a potential target for AD. Nominated targets are obtained + from several sources, including the National Institute on Aging's Accelerating Medicines + Partnership in Alzheimer's Disease (AMP-AD) consortium. Targets have been identified using + computational analyses of high-dimensional genomic, proteomic and/or metabolomic data + derived from human samples. +

+ +
+ +
+
+
+

+ {{ getFullDisplayName(nomination) }} +

+

+ {{ nomination.team_data?.description }} +

+
+ +
+

Why was the target selected?

+

+ {{ nomination.target_choice_justification }} +

+
+ +
+

Predicted therapeutic direction

+

+ {{ nomination.predicted_therapeutic_direction }} +

+
+ + @if (nomination.study || nomination.data_used_to_support_target_selection) { +
+

The type of data used and analyses done to identify target

+ @if (nomination.data_used_to_support_target_selection) { +

+ {{ nomination.data_used_to_support_target_selection }} +

+ } + @if (nomination.study) { +

Cohort study data: {{ nomination.study }}

+ } +
+ } + +
+

Initial date of nomination

+

+ {{ nomination.initial_nomination }} +

+
+ +
+ @if (nomination.validation_study_details) { +

Planned Experimental Validation

+

+ {{ nomination.validation_study_details }} +

+ } + + @if (nomination.data_synapseid) { + + Learn more about the target nomination process + + } +
+ + @if (i < nominations.length - 1) { +
+ } +
+
+
+
+
diff --git a/libs/agora/genes/src/lib/components/gene-nominations/gene-nominations.component.scss b/libs/agora/genes/src/lib/components/gene-nominations/gene-nominations.component.scss new file mode 100644 index 0000000000..8f9774e241 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-nominations/gene-nominations.component.scss @@ -0,0 +1,26 @@ +@import 'libs/agora/styles/src/lib/variables'; +@import 'libs/agora/styles/src/lib/mixins'; + +.overview-header { + .fa-star::before { + color: var(--color-secondary); + } + + p-button { + @include respond-to('ex-small') { + width: 70%; + } + + width: 100%; + height: 100%; + + button { + width: 100%; + height: 100%; + } + } +} + +.validation-study-details::first-letter { + text-transform: uppercase; +} diff --git a/libs/agora/genes/src/lib/components/gene-nominations/gene-nominations.component.spec.ts.off b/libs/agora/genes/src/lib/components/gene-nominations/gene-nominations.component.spec.ts.off new file mode 100644 index 0000000000..30b360f867 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-nominations/gene-nominations.component.spec.ts.off @@ -0,0 +1,91 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { GeneNominationsComponent } from './gene-nominations.component'; +import { of } from 'rxjs'; +import { geneMock1, teamsResponseMock } from '@sagebionetworks/agora/testing'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Gene Nominations', () => { + let fixture: ComponentFixture; + let component: GeneNominationsComponent; + let mockTeamService: TeamsService; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [GeneNominationsComponent], + imports: [RouterTestingModule, HttpClientTestingModule], + providers: [TeamsService], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(GeneNominationsComponent); + component = fixture.componentInstance; + mockTeamService = TestBed.inject(TeamsService); + spyOn(mockTeamService, 'getTeams').and.returnValue(of(teamsResponseMock)); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should call mock TeamService', () => { + component.gene = geneMock1; + component.init(); + fixture.detectChanges(); + + expect(mockTeamService.getTeams).toHaveBeenCalled(); + }); + + it('should get full display name', () => { + component.gene = geneMock1; + component.init(); + fixture.detectChanges(); + + const nominations = geneMock1.target_nominations; + if (nominations === null || nominations.length === 0) + fail('improperly set up mock gene for test'); + else { + const nomination = nominations[0]; + const result = component.getFullDisplayName(nomination); + expect(result).toBe('AMP-AD: Emory University'); + } + }); + + it('should sort nominations alphabetically then by date desc', () => { + component.gene = geneMock1; + component.init(); + fixture.detectChanges(); + + const result = component.sortNominations(teamsResponseMock.items); + + expect(result.length).toBe(5); + + expect(component.getFullDisplayName(result[0])).toBe('AMP-AD: Emory University'); + expect(component.getFullDisplayName(result[1])).toBe( + 'AMP-AD: Icahn School of Medicine at Mount Sinai', + ); + expect(component.getFullDisplayName(result[2])).toBe( + 'AMP-AD: Icahn School of Medicine at Mount Sinai', + ); + expect(component.getFullDisplayName(result[3])).toBe( + 'Community Contributed: The Chang Lab at the University of Arizona', + ); + expect(component.getFullDisplayName(result[4])).toBe( + 'TREAT-AD: Emory University - Sage Bionetworks - Structural Genomics Consortium', + ); + expect(result[1].initial_nomination).toBe(2020); + expect(result[2].initial_nomination).toBe(2018); + }); +}); diff --git a/libs/agora/genes/src/lib/components/gene-nominations/gene-nominations.component.ts b/libs/agora/genes/src/lib/components/gene-nominations/gene-nominations.component.ts new file mode 100644 index 0000000000..1e723d930d --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-nominations/gene-nominations.component.ts @@ -0,0 +1,78 @@ +import { Component, inject, Input } from '@angular/core'; + +import { Gene, Team, TeamsService } from '@sagebionetworks/agora/api-client-angular'; +import { TargetNominationWithTeamData } from '../../models/TargetNominationWithTeamData'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'agora-gene-nominations', + standalone: true, + imports: [CommonModule], + providers: [TeamsService], + templateUrl: './gene-nominations.component.html', + styleUrls: ['./gene-nominations.component.scss'], +}) +export class GeneNominationsComponent { + teamService = inject(TeamsService); + + _gene: Gene | undefined; + get gene(): Gene | undefined { + return this._gene; + } + @Input() set gene(gene: Gene | undefined) { + this._gene = gene; + this.init(); + } + + nominations: TargetNominationWithTeamData[] = []; + loading = true; + + reset() { + this.nominations = []; + } + + init() { + this.reset(); + + if (!this._gene?.target_nominations?.length) { + return; + } + + this.teamService.listTeams().subscribe((response) => { + if (response.items) { + this.nominations = this.sortNominations(response.items); + } + }); + } + + sortNominations(teams: Team[]) { + const result: TargetNominationWithTeamData[] = []; + if (!this.gene || !this.gene.target_nominations) return result; + + // add team_data to nominations + this.nominations = this.gene.target_nominations.map((targetNomination) => { + const extendedTargetNomination: TargetNominationWithTeamData = { ...targetNomination }; + extendedTargetNomination.team_data = teams.find((t) => t.team === targetNomination.team); + return extendedTargetNomination; + }); + + return this.gene.target_nominations.sort((a, b) => { + //primary sort on displayed team name + const teamA = this.getFullDisplayName(a); + const teamB = this.getFullDisplayName(b); + + const nameComparison = teamA.localeCompare(teamB, 'en'); + if (nameComparison !== 0) return nameComparison; + + //secondary sort on initial nomination year (descending) + return b.initial_nomination - a.initial_nomination; + }) as TargetNominationWithTeamData[]; + } + + getFullDisplayName(nomination: TargetNominationWithTeamData): string { + const team = nomination.team_data; + if (!team) return ''; + + return (team.program ? team.program + ': ' : '') + team.team_full; + } +} diff --git a/libs/agora/genes/src/lib/components/gene-protein-selector/gene-protein-selector.component.html b/libs/agora/genes/src/lib/components/gene-protein-selector/gene-protein-selector.component.html new file mode 100644 index 0000000000..da8c184d2b --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-protein-selector/gene-protein-selector.component.html @@ -0,0 +1,9 @@ +
+ +
diff --git a/libs/agora/genes/src/lib/components/gene-protein-selector/gene-protein-selector.component.scss b/libs/agora/genes/src/lib/components/gene-protein-selector/gene-protein-selector.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/agora/genes/src/lib/components/gene-protein-selector/gene-protein-selector.component.spec.ts.off b/libs/agora/genes/src/lib/components/gene-protein-selector/gene-protein-selector.component.spec.ts.off new file mode 100644 index 0000000000..af4892390f --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-protein-selector/gene-protein-selector.component.spec.ts.off @@ -0,0 +1,35 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { GeneProteinSelectorComponent } from './gene-protein-selector.component'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Gene Protein Selector', () => { + let fixture: ComponentFixture; + let component: GeneProteinSelectorComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [GeneProteinSelectorComponent], + imports: [RouterTestingModule], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(GeneProteinSelectorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/agora/genes/src/lib/components/gene-protein-selector/gene-protein-selector.component.ts b/libs/agora/genes/src/lib/components/gene-protein-selector/gene-protein-selector.component.ts new file mode 100644 index 0000000000..2b1fe0a5e3 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-protein-selector/gene-protein-selector.component.ts @@ -0,0 +1,44 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { DropdownModule } from 'primeng/dropdown'; + +interface Option { + name: string; + value: string; +} + +@Component({ + selector: 'agora-gene-protein-selector', + standalone: true, + imports: [FormsModule, DropdownModule], + templateUrl: './gene-protein-selector.component.html', + styleUrls: ['./gene-protein-selector.component.scss'], +}) +export class GeneProteinSelectorComponent { + _options: Option[] = []; + get options(): Option[] { + return this._options; + } + @Input() set options(options: any) { + this.selected = {} as Option; + this._options = + options?.map((option: any) => { + return { + name: option, + value: option, + } as Option; + }) || []; + + if (this._options.length) { + this.selected = this._options[0]; + } + } + + @Input() selected: Option = { name: '', value: '' }; + + @Output() changeEvent: EventEmitter = new EventEmitter(); + + _onChange() { + this.changeEvent.emit(this.selected); + } +} diff --git a/libs/agora/genes/src/lib/components/gene-resources/gene-resources.component.html b/libs/agora/genes/src/lib/components/gene-resources/gene-resources.component.html new file mode 100644 index 0000000000..803d38d641 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-resources/gene-resources.component.html @@ -0,0 +1,101 @@ +@if (gene) { + @if (gene.is_tep === true || gene.is_adi === true) { +
+
+
+

Target Enabling Resources

+

+ Use these links to discover the Target Enabling Resources for + {{ gene.hgnc_symbol || gene.ensembl_gene_id }} that are currently available, under + development, or planned. +

+
+
+ Target Enabling Resources +
+ +
+ View the openly available TREAT-AD resources for experimental validation of + {{ gene.hgnc_symbol || gene.ensembl_gene_id }} in the AD Knowledge Portal. +
+
+ @if (gene.is_tep) { +
+
+ Target Portfolio +
+ +
+ View the status of TEP resource development on the TREAT-AD Target Portfolio and + Progress Dashboard. +
+
+ } + @if (gene.is_adi) { +
+
+ AD Informer Set +
+ +
+ View information about the development and distribution of the AD Informer Set. +
+
+ } +
+
+
+ } +} + +@if (additionalResources) { +
+
+
+

Additional Resources

+

These external sites provide additional useful information for exploring AD targets.

+
+
{{ r.title }}
+ +
+ {{ r.description }} +
+
+
+
+
+} diff --git a/libs/agora/genes/src/lib/components/gene-resources/gene-resources.component.scss b/libs/agora/genes/src/lib/components/gene-resources/gene-resources.component.scss new file mode 100644 index 0000000000..4ed250f6db --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-resources/gene-resources.component.scss @@ -0,0 +1,73 @@ +@import 'libs/agora/styles/src/lib/variables'; +@import 'libs/agora/styles/src/lib/mixins'; + +.tab-row { + padding: var(--spacing-xl) 144px var(--spacing-xl) var(--spacing-xl); + border: 1px solid var(--color-gray-300); + margin: 0 0 var(--spacing-lg) 0; + + .last-row { + margin-top: 15px; + } + + a { + display: flex; + vertical-align: middle; + margin-top: auto; + margin-bottom: auto; + padding: 0; + } + + .header-title { + line-height: var(--font-size-xxl); + font-size: var(--font-size-lg); + } + + .header-title, + .header-link, + .header-description { + @include respond-to('ex-small') { + min-height: 43px; + margin-top: 25.5px !important; + } + + @include respond-to('small') { + min-height: 43px; + margin-top: 25.5px !important; + } + + @include respond-to('medium') { + margin-top: auto !important; + min-height: 0; + } + + @include respond-to('large') { + margin-top: auto !important; + min-height: 0; + } + + @include respond-to('ex-large') { + margin-top: auto !important; + min-height: 0; + } + } + + .header-link { + text-align: center; + + a { + display: block; + } + } + + .header-description { + line-height: var(--font-size-xl); + font-size: var(--font-size-md); + } + + > [class*='col-'] { + vertical-align: middle; + margin-top: auto; + margin-bottom: auto; + } +} diff --git a/libs/agora/genes/src/lib/components/gene-resources/gene-resources.component.spec.ts.off b/libs/agora/genes/src/lib/components/gene-resources/gene-resources.component.spec.ts.off new file mode 100644 index 0000000000..2f7ae6b6b1 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-resources/gene-resources.component.spec.ts.off @@ -0,0 +1,165 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { GeneResourcesComponent } from './'; +import { ModalLinkComponent } from '../../../../shared/components/modal-link/modal-link.component'; +import { GeneDruggabilityComponent } from '../gene-druggability/gene-druggability.component'; +import { geneMock1, noHGNCgeneMock } from '../../../../testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ApiService, HelperService } from '../../../../core/services'; +import { GeneService } from '../../services'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Gene Resources', () => { + let fixture: ComponentFixture; + let component: GeneResourcesComponent; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ + GeneResourcesComponent, + ModalLinkComponent, + GeneDruggabilityComponent + ], + imports: [RouterTestingModule, HttpClientTestingModule, BrowserAnimationsModule], + providers: [GeneService, ApiService, HelperService], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(GeneResourcesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + element = fixture.nativeElement; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should not display TREAT-AD resource section if is_tep and is_adi is false', () => { + component.gene = geneMock1; + component.gene.is_adi = false; + component.gene.is_tep = false; + + fixture.detectChanges(); + + const header = element.querySelector('#target-enabling-resources-header'); + expect(header).toBe(null); + + const resource_url = element.querySelector('#target-enabling-resources-url'); + expect(resource_url).toBe(null); + }); + + it('should display TREAT-AD resource sections if is_tep or is_adi is true', () => { + component.gene = geneMock1; + component.gene.is_adi = false; + component.gene.is_tep = true; + + fixture.detectChanges(); + + let expected = 'Target Enabling Resources'; + let el = element.querySelector('#target-enabling-resources-header') as HTMLElement; + expect(el.textContent).toBe(expected); + + expected = 'Target Enabling Resources'; + el = element.querySelector('#target-enabling-resources-card1') as HTMLElement; + expect(el.textContent).toBe(expected); + + expected = 'Target Portfolio'; + el = element.querySelector('#target-enabling-resources-card2') as HTMLElement; + expect(el.textContent).toBe(expected); + + // adi is false so card3 should be null + const card3 = element.querySelector('#target-enabling-resources-card3'); + expect(card3).toBe(null); + + // switch the booleans on adi and tep + component.gene = geneMock1; + component.gene.is_adi = true; + component.gene.is_tep = false; + + fixture.detectChanges(); + + expected = 'Target Enabling Resources'; + el = element.querySelector('#target-enabling-resources-header') as HTMLElement; + expect(el.textContent).toBe(expected); + + expected = 'Target Enabling Resources'; + el = element.querySelector('#target-enabling-resources-card1') as HTMLElement; + expect(el.textContent).toBe(expected); + + // tep is false so card3 should be null + expected = 'Target Portfolio'; + const card2 = element.querySelector('#target-enabling-resources-card2'); + expect(card2).toBe(null); + + expected = 'AD Informer Set'; + el = element.querySelector('#target-enabling-resources-card3') as HTMLElement; + expect(el.textContent).toBe(expected); + }); + + it('should have an hgnc link to Pub AD if the gene has an hgnc symbol', () => { + component.gene = geneMock1; + component.init(); + + fixture.detectChanges(); + + const expectedLinkAddress = 'https://adexplorer.medicine.iu.edu/pubad/external/MSN'; + + const additionalResourceLinks = element.querySelectorAll('a.additional-resource-links.link.no-bold'); + + let pubADLink: Element | undefined; + additionalResourceLinks.forEach(a => { + if (a.textContent?.trim() === 'Visit PubAD') { + pubADLink = a; + } + }); + + if (!pubADLink) { + fail('could not find the element for Pub AD'); + } + + expect(pubADLink).toBeTruthy(); + + const result = pubADLink?.getAttribute('href'); + expect(result).toBe(expectedLinkAddress); + }); + + it('should have a default link to Pub AD if the gene does not have an hgnc symbol', () => { + component.gene = noHGNCgeneMock; + component.init(); + + fixture.detectChanges(); + + const expectedLinkAddress = 'https://adexplorer.medicine.iu.edu/pubad'; + + const additionalResourceLinks = element.querySelectorAll('a.additional-resource-links.link.no-bold'); + + let pubADLink: Element | undefined; + additionalResourceLinks.forEach(a => { + if (a.textContent?.trim() === 'Visit PubAD') { + pubADLink = a; + } + }); + + if (!pubADLink) { + fail('could not find the element for Pub AD'); + } + + expect(pubADLink).toBeTruthy(); + + const result = pubADLink?.getAttribute('href'); + expect(result).toBe(expectedLinkAddress); + }); +}); diff --git a/libs/agora/genes/src/lib/components/gene-resources/gene-resources.component.ts b/libs/agora/genes/src/lib/components/gene-resources/gene-resources.component.ts new file mode 100644 index 0000000000..9871360980 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-resources/gene-resources.component.ts @@ -0,0 +1,101 @@ +import { Component, Input, OnInit } from '@angular/core'; + +import { GeneDruggabilityComponent } from '../gene-druggability/gene-druggability.component'; +import { Gene } from '@sagebionetworks/agora/api-client-angular'; +import { AdditionalResource } from '@sagebionetworks/agora/models'; +import { CommonModule } from '@angular/common'; +import { ModalLinkComponent } from '@sagebionetworks/agora/shared'; + +@Component({ + selector: 'agora-gene-resources', + standalone: true, + imports: [CommonModule, GeneDruggabilityComponent, ModalLinkComponent], + templateUrl: './gene-resources.component.html', + styleUrls: ['./gene-resources.component.scss'], +}) +export class GeneResourcesComponent implements OnInit { + @Input() gene: Gene | undefined; + + additionalResources: AdditionalResource[] = []; + + ngOnInit(): void { + this.init(); + } + + getPubADLink() { + // Pub AD links should have hgnc symbol + if (this.gene?.hgnc_symbol) { + return `https://adexplorer.medicine.iu.edu/pubad/external/${this.gene.hgnc_symbol}`; + } + return 'https://adexplorer.medicine.iu.edu/pubad'; + } + + init() { + if (!this.gene) { + return; + } + + this.additionalResources = [ + { + title: 'Open Targets', + description: + 'View this gene on Open Targets, a resource that provides evidence on the validity of therapeutic targets based on genome-scale experiments and analysis.', + linkText: 'Visit Open Targets', + link: `https://platform.opentargets.org/target/${this.gene?.ensembl_gene_id}`, + }, + { + title: 'Pharos', + description: + 'View this gene on Pharos, a resource that provides access to the integrated knowledge-base from the Illuminating the Druggable Genome program.', + linkText: 'Visit Pharos', + link: `https://pharos.nih.gov/targets?q=${this.gene?.ensembl_gene_id}`, + }, + { + title: 'Brain RNAseq', + description: + 'Search for this gene on the Brain RNAseq site, which hosts single-cell RNAseq data.', + linkText: 'Visit BrainRNAseq', + link: 'http://www.brainrnaseq.org/', + }, + { + title: 'Genomics DB', + description: + "View this gene on the National Institute on Aging Genetics of Alzheimer's Disease Data Storage Site (NIAGADS) Genomics Database.", + linkText: 'Visit Genomics DB', + link: `https://www.niagads.org/genomics/app/record/gene/${this.gene?.ensembl_gene_id}`, + }, + { + title: 'AD Atlas', + description: + 'View this gene on the AD Atlas site, a network-based resource for investigating AD in a multi-omic context.', + linkText: 'Visit AD Atlas', + link: `https://adatlas.org/?geneID=${this.gene?.ensembl_gene_id}`, + }, + { + title: 'Pub AD', + description: 'View dementia-related publication information for this gene on PubAD.', + linkText: 'Visit PubAD', + link: `${this.getPubADLink()}`, + }, + { + title: 'Gene Ontology', + description: 'View the gene ontology information for this gene on Ensembl.', + linkText: 'Visit Ensembl', + link: `https://www.ensembl.org/Homo_sapiens/Gene/Ontologies/molecular_function?g=${this.gene?.ensembl_gene_id}`, + }, + { + title: 'Reactome Pathways', + description: 'View the reactome pathway information for this gene on Ensembl.', + linkText: 'Visit Ensembl', + link: `https://www.ensembl.org/Homo_sapiens/Gene/Pathway?g=${this.gene?.ensembl_gene_id}`, + }, + { + title: 'AMP-PD Target Explorer', + description: + "View this gene in the AMP-PD Target Explorer, a resource that hosts evidence about whether genes are associated with Parkinson's Disease.", + linkText: 'Visit AMP-PD', + link: `https://target-explorer.amp-pd.org/genes/target-search?gene=${this.gene?.ensembl_gene_id}`, + }, + ]; + } +} diff --git a/libs/agora/genes/src/lib/components/gene-search/gene-search.component.html b/libs/agora/genes/src/lib/components/gene-search/gene-search.component.html index aedf4d1c89..9c6f9f70bf 100644 --- a/libs/agora/genes/src/lib/components/gene-search/gene-search.component.html +++ b/libs/agora/genes/src/lib/components/gene-search/gene-search.component.html @@ -24,7 +24,6 @@ @if (query) {
diff --git a/libs/agora/genes/src/lib/components/gene-search/gene-search.component.spec.ts b/libs/agora/genes/src/lib/components/gene-search/gene-search.component.spec.ts.off similarity index 100% rename from libs/agora/genes/src/lib/components/gene-search/gene-search.component.spec.ts rename to libs/agora/genes/src/lib/components/gene-search/gene-search.component.spec.ts.off diff --git a/libs/agora/genes/src/lib/components/gene-search/gene-search.component.ts b/libs/agora/genes/src/lib/components/gene-search/gene-search.component.ts index 5e877c459c..2cee30453f 100644 --- a/libs/agora/genes/src/lib/components/gene-search/gene-search.component.ts +++ b/libs/agora/genes/src/lib/components/gene-search/gene-search.component.ts @@ -28,21 +28,13 @@ import { Router } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { faMagnifyingGlass, faSpinner } from '@fortawesome/free-solid-svg-icons'; -import { SvgIconComponent } from '@sagebionetworks/agora/ui'; import { GeneIconComponent } from './assets/gene-icon/gene-icon.component'; import { CloseIconComponent } from './assets/close-icon/close-icon.component'; @Component({ selector: 'agora-gene-search', standalone: true, - imports: [ - CommonModule, - FormsModule, - FontAwesomeModule, - SvgIconComponent, - GeneIconComponent, - CloseIconComponent, - ], + imports: [CommonModule, FormsModule, FontAwesomeModule, GeneIconComponent, CloseIconComponent], templateUrl: './gene-search.component.html', styleUrls: ['./gene-search.component.scss'], }) diff --git a/libs/agora/genes/src/lib/components/gene-soe-charts/gene-soe-charts.component.html b/libs/agora/genes/src/lib/components/gene-soe-charts/gene-soe-charts.component.html new file mode 100644 index 0000000000..2ce921d428 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-soe-charts/gene-soe-charts.component.html @@ -0,0 +1,22 @@ +@if (_gene) { +
+
+
+
+

+ {{ chart.name }} +

+
+
+ + +
+
+ +
+
+} diff --git a/libs/agora/genes/src/lib/components/gene-soe-charts/gene-soe-charts.component.scss b/libs/agora/genes/src/lib/components/gene-soe-charts/gene-soe-charts.component.scss new file mode 100644 index 0000000000..16f62a1629 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-soe-charts/gene-soe-charts.component.scss @@ -0,0 +1,51 @@ +/* stylelint-disable declaration-block-no-redundant-longhand-properties */ + +@import 'libs/agora/styles/src/lib/variables'; +@import 'libs/agora/styles/src/lib/mixins'; + +.gene-score-charts { + display: grid; + grid-template: auto / repeat(auto-fit, minmax(350px, 1fr)); + grid-gap: 20px; + + > div { + min-width: 350px; + } + + .gene-score-charts-header { + display: flex; + align-items: center; + gap: 5px; + padding-bottom: 2px; + margin-bottom: 15px; + border-bottom: 1px solid var(--color-primary); + + > div { + &:first-child { + flex-grow: 1; + } + + &:last-child { + display: flex; + justify-content: flex-end; + } + } + + .modal-link-icon { + margin: 0; + } + + i { + font-size: 15px; + color: rgb(166 132 238 / 70%); + cursor: pointer; + } + } + + .gene-score-charts-heading { + color: var(--color-primary); + font-size: 16px; + font-weight: 700; + margin: 0; + } +} diff --git a/libs/agora/genes/src/lib/components/gene-soe-charts/gene-soe-charts.component.spec.ts.off b/libs/agora/genes/src/lib/components/gene-soe-charts/gene-soe-charts.component.spec.ts.off new file mode 100644 index 0000000000..cc3716aca2 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-soe-charts/gene-soe-charts.component.spec.ts.off @@ -0,0 +1,82 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { GeneSoeChartsComponent } from './'; +import { GeneService } from '../../services'; +import { ApiService } from '../../../../core/services'; +import { OverallScoresDistribution } from '../../../../models'; +import { geneMock1, geneMock2, overallScoresMock1, overallScoresMock2 } from '../../../../testing'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Gene SOE Charts', () => { + let fixture: ComponentFixture; + let component: GeneSoeChartsComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [GeneSoeChartsComponent], + imports: [RouterTestingModule, HttpClientTestingModule], + providers: [GeneService, ApiService], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(GeneSoeChartsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should sort target risk score first', () => { + let data: OverallScoresDistribution[] = overallScoresMock1; + component.customSortDistributions(data); + expect(data[0].name).toBe('Target Risk Score'); + data = overallScoresMock2; + component.customSortDistributions(data); + expect(data[0].name).toBe('Target Risk Score'); + }); + + it('should sort genetic risk score second', () => { + let data: OverallScoresDistribution[] = overallScoresMock1; + component.customSortDistributions(data); + expect(data[1].name).toBe('Genetic Risk Score'); + data = overallScoresMock2; + component.customSortDistributions(data); + expect(data[1].name).toBe('Genetic Risk Score'); + }); + + it('should sort multi-omic risk score last', () => { + let data: OverallScoresDistribution[] = overallScoresMock1; + component.customSortDistributions(data); + expect(data[2].name).toBe('Multi-omic Risk Score'); + data = overallScoresMock2; + component.customSortDistributions(data); + expect(data[2].name).toBe('Multi-omic Risk Score'); + }); + + it('should handle scores properly', () => { + const data = geneMock1; + component.gene = data; + const result = component.getGeneOverallScores('Genetic Risk Score'); + expect(result).toBe(0.36140442487816); + }); + + it('should handle missing scores properly', () => { + const data = geneMock2; + component.gene = data; + const result = component.getGeneOverallScores('Genetic Risk Score'); + expect(result).toBe(null); + }); +}); diff --git a/libs/agora/genes/src/lib/components/gene-soe-charts/gene-soe-charts.component.ts b/libs/agora/genes/src/lib/components/gene-soe-charts/gene-soe-charts.component.ts new file mode 100644 index 0000000000..dcb881969c --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-soe-charts/gene-soe-charts.component.ts @@ -0,0 +1,105 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject, Input } from '@angular/core'; + +import { + Distribution, + DistributionService, + Gene, + OverallScoresDistribution, +} from '@sagebionetworks/agora/api-client-angular'; +import { ScoreBarChartComponent } from '@sagebionetworks/agora/charts'; +import { OverlayPanelLinkComponent } from '../overlay-panel-link/overlay-panel-link.component'; + +export interface SOEChartProps { + title: string; + distributionData: OverallScoresDistribution; + geneScore: number; + wikiInfo: { + ownerId: string; + wikiId: string; + }; +} + +@Component({ + selector: 'agora-gene-soe-charts', + standalone: true, + imports: [CommonModule, OverlayPanelLinkComponent, ScoreBarChartComponent], + templateUrl: './gene-soe-charts.component.html', + styleUrls: ['./gene-soe-charts.component.scss'], +}) +export class GeneSoeChartsComponent { + distributionService = inject(DistributionService); + + _gene: Gene | undefined; + get gene(): Gene | undefined { + return this._gene; + } + @Input() set gene(gene: Gene | undefined) { + this._gene = gene; + this.init(); + } + + @Input() wikiId = ''; + + primaryBarColor = '#8B8AD1'; + alternateBarColor = '#42C7BB'; + + scoreDistributions: OverallScoresDistribution[] = []; + + customSortDistributions(distributions: OverallScoresDistribution[]) { + // sort the distributions such that the order is: Target Risk Score, Genetic Risk Score, Multi-omic Risk Score + // this should match the default column order on the GCT page + distributions.sort((a: OverallScoresDistribution, b: OverallScoresDistribution) => { + if (a.name === 'Target Risk Score') { + return -1; + } else if (b.name === 'Target Risk Score') { + return 1; + } else if (a.name === 'Genetic Risk Score') { + return -1; + } else if (b.name === 'Genetic Risk Score') { + return 1; + } else if (a.name === 'Multi-omic Risk Score') { + return -1; + } else if (b.name === 'Multi-omic Risk Score') { + return 1; + } else { + return a.name.localeCompare(b.name); // if there are more scores columns in the future, default to alphabetical + } + }); + } + + init() { + this.distributionService.getDistribution().subscribe((data: Distribution) => { + this.scoreDistributions = data.overall_scores; + this.customSortDistributions(this.scoreDistributions); + // remove literature score + this.scoreDistributions = this.scoreDistributions.filter( + (item: any) => item.name !== 'Literature Score', + ); + }); + } + + getBarColor(chartName: string | undefined) { + if (!chartName) return this.primaryBarColor; + if (chartName === 'Target Risk Score') { + return this.alternateBarColor; + } + return this.primaryBarColor; + } + + getGeneOverallScores(name: string) { + if (!this.gene?.overall_scores) return null; + + const scores = this.gene.overall_scores; + if ('Genetic Risk Score' === name) { + return scores['genetics_score']; + } else if ('Multi-omic Risk Score' === name) { + return scores['multi_omics_score']; + } else if ('Literature Score' === name) { + return scores['literature_score']; + } else if ('Target Risk Score' === name) { + return scores['target_risk_score']; + } + return null; + } +} diff --git a/libs/agora/genes/src/lib/components/gene-soe-list/gene-soe-list.component.html b/libs/agora/genes/src/lib/components/gene-soe-list/gene-soe-list.component.html new file mode 100644 index 0000000000..41b8875982 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-soe-list/gene-soe-list.component.html @@ -0,0 +1,27 @@ +@if (gene) { + +} diff --git a/libs/agora/genes/src/lib/components/gene-soe-list/gene-soe-list.component.scss b/libs/agora/genes/src/lib/components/gene-soe-list/gene-soe-list.component.scss new file mode 100644 index 0000000000..84edcba486 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-soe-list/gene-soe-list.component.scss @@ -0,0 +1,63 @@ +@import 'libs/agora/styles/src/lib/variables'; +@import 'libs/agora/styles/src/lib/mixins'; + +.gene-seo-list { + @include reset-ul; + + .item { + .item-inner { + @include container; + + display: flex; + padding: 20px var(--spacing-lg) 28px var(--spacing-lg); + + > div { + &:first-child { + flex-grow: 1; + padding-right: var(--spacing-lg); + } + + &:last-child { + display: flex; + align-items: center; + white-space: nowrap; + } + } + } + + .item-title, + .item-description { + max-width: 600px; + + a { + color: var(--color-link); + font-size: 16px; + } + } + + .item-title { + color: var(--color-text); + } + + .item-description { + font-style: italic; + } + + .item-link { + font-weight: 400; + font-style: normal; + cursor: pointer; + width: 100%; + line-height: normal; + font-size: 18px; + text-decoration: underline; + color: var(--color-link); + position: relative; + top: inherit; + } + + &:nth-child(odd) { + background-color: var(--color-gray-100); + } + } +} diff --git a/libs/agora/genes/src/lib/components/gene-soe-list/gene-soe-list.component.spec.ts.off b/libs/agora/genes/src/lib/components/gene-soe-list/gene-soe-list.component.spec.ts.off new file mode 100644 index 0000000000..7fd4840102 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-soe-list/gene-soe-list.component.spec.ts.off @@ -0,0 +1,35 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { GeneSoeListComponent } from './gene-soe-list.component'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Gene SOE List', () => { + let fixture: ComponentFixture; + let component: GeneSoeListComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [GeneSoeListComponent], + imports: [RouterTestingModule], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(GeneSoeListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/agora/genes/src/lib/components/gene-soe-list/gene-soe-list.component.ts b/libs/agora/genes/src/lib/components/gene-soe-list/gene-soe-list.component.ts new file mode 100644 index 0000000000..801cb65c2c --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-soe-list/gene-soe-list.component.ts @@ -0,0 +1,125 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Gene } from '@sagebionetworks/agora/api-client-angular'; + +interface SummaryProperty { + title: string; + description: string; + link?: string; + anchorText?: string; +} + +interface Summary { + property: SummaryProperty; + state: boolean; + isStateApplicable: boolean; +} + +@Component({ + selector: 'agora-gene-soe-list', + standalone: true, + imports: [CommonModule], + templateUrl: './gene-soe-list.component.html', + styleUrls: ['./gene-soe-list.component.scss'], +}) +export class GeneSoeListComponent { + _gene: Gene | undefined; + get gene(): Gene | undefined { + return this._gene; + } + @Input() set gene(gene: Gene | undefined) { + this._gene = gene; + this.init(); + } + summaries: Summary[] = []; + + init() { + if (!this._gene?.ensembl_gene_id) { + this.summaries = []; + return; + } + + this.summaries = [ + { + property: { + title: 'Genetic Association with LOAD', + description: + 'Indicates whether or not this gene shows significant genetic association with Late Onset AD (LOAD) based on evidence from multiple studies compiled by the', + link: 'https://adsp.niagads.org/index.php/gvc-top-hits-list/', + anchorText: 'ADSP Gene Verification Committee', + }, + state: this._gene.is_igap === undefined ? false : this._gene.is_igap, + isStateApplicable: true, + }, + { + property: { + title: 'Brain eQTL', + description: + 'Indicates whether or not this gene locus has a significant expression Quantitative Trait Locus (eQTL) based on an', + link: 'https://www.nature.com/articles/s41597-020-00642-8', + anchorText: 'AMP-AD consortium study', + }, + state: this._gene.is_eqtl === undefined ? false : this._gene.is_eqtl, + isStateApplicable: true, + }, + { + property: { + title: 'RNA Expression Change in AD Brain', + description: + 'Indicates whether or not this gene shows significant differential expression in at least one brain region based on AMP-AD consortium work. See ‘EVIDENCE’ tab.', + }, + state: + this._gene.is_any_rna_changed_in_ad_brain === undefined + ? false + : this._gene.is_any_rna_changed_in_ad_brain, + isStateApplicable: this._gene.rna_brain_change_studied, + }, + { + property: { + title: 'Protein Expression Change in AD Brain', + description: + 'Indicates whether or not this gene shows significant differential protein expression in at least one brain region based on AMP-AD consortium work. See ‘EVIDENCE’ tab.', + }, + state: + this._gene.is_any_protein_changed_in_ad_brain === undefined + ? false + : this._gene.is_any_protein_changed_in_ad_brain, + isStateApplicable: this._gene.protein_brain_change_studied, + }, + { + property: { + title: 'Nominated Target', + description: + 'Indicates whether or not this gene has been submitted as a nominated target to Agora.', + }, + state: this._gene.total_nominations && this._gene.total_nominations > 0 ? true : false, + isStateApplicable: true, + }, + ]; + } + + // If the 'state' value can be modified by another boolean value, pass the modifying value as 'isStateApplicable' + // Example: rna_brain_change_studied: false indicates that is_any_rna_changed_in_ad_brain is + // undefined, so calling: getText(is_any_rna_changed_in_ad_brain, rna_brain_change_studied) + // will return the desired 'No data' text, regardless of the is_any_rna_changed_in_ad_brain value + getStateText(summary: Summary): string { + if (summary.isStateApplicable) { + if (summary.state === true) { + return 'True'; + } else if (summary.state === false) { + return 'False'; + } + } + + return 'No data'; + } + + getStateClass(summary: Summary): string { + if (summary.state && summary.isStateApplicable) { + return 'text-success'; + } else if (!summary.state && summary.isStateApplicable) { + return 'text-danger'; + } + return ''; + } +} diff --git a/libs/agora/genes/src/lib/components/gene-soe/gene-soe.component.html b/libs/agora/genes/src/lib/components/gene-soe/gene-soe.component.html new file mode 100644 index 0000000000..f27b9c4f5a --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-soe/gene-soe.component.html @@ -0,0 +1,96 @@ +@if (gene) { +
+
+
+

Summary of Evidence

+

+ This tab shows an overview of how the selected gene is associated with AD. +

+ +
+
+
+ +
+
+
+

AD Risk Scores

+
+
+

About AD Risk Scores

+

+ The TREAT-AD Center at Emory-Sage-SGC has developed a Target Risk Score (TRS) to + objectively rank the potential involvement of specific genes in AD. The TRS is derived + by summing two component risk scores, the Genetic Risk Score and the Multi-omic Risk + Score, each of which is derived from a meta-analysis of multiple harmonized data sets. + More information about the methodology used to define these risk scores is available + here. +

+
+
+

AD Risk Scores for {{ gene.hgnc_symbol || gene.ensembl_gene_id }}

+

+ The TRS for {{ gene.hgnc_symbol || gene.ensembl_gene_id }}, along with the component + Genetic and Multi-omic Risk Scores, is shown here. The scores for + {{ gene.hgnc_symbol || gene.ensembl_gene_id }} are superimposed on the genome-wide + score distributions. If No Data is Currently Available is displayed for a + score, that score was not calculated for + {{ gene.hgnc_symbol || gene.ensembl_gene_id }}. +

+
+
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+

Biological Domain Classification

+
+
+

About Biological Domains

+

+ A biological domain represents a standardized area of biology defined by a set of + discrete, biologically coherent GO terms. The TREAT-AD Center at Emory-Sage-SGC has + defined nineteen biological domains associated with AD, and objectively mapped genes + to those biological domains using GO term annotations. More information about the + methodology used to define AD biological domains, and to generate genome-wide + biological domain mappings, is available + here. +

+
+
+

Biological Domains for {{ gene.hgnc_symbol || gene.ensembl_gene_id }}

+

+ Select a biological domain on the left to see the list of GO terms that link + {{ gene.hgnc_symbol || gene.ensembl_gene_id }} to it on the right. The percentage + value displayed next to the currently selected biological domain indicates the + proportion of {{ gene.hgnc_symbol || gene.ensembl_gene_id }}'s total unique GO terms + that map to the biological domain. The ratio displayed on the right indicates how many + of the biological domain's total GO terms + {{ gene.hgnc_symbol || gene.ensembl_gene_id }} is annotated with. +

+
+
+
+ +
+
+} diff --git a/libs/agora/genes/src/lib/components/gene-soe/gene-soe.component.scss b/libs/agora/genes/src/lib/components/gene-soe/gene-soe.component.scss new file mode 100644 index 0000000000..2e1901516f --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-soe/gene-soe.component.scss @@ -0,0 +1,12 @@ +.sub-headings { + div { + &:first-child { + margin-bottom: 25px; + } + } +} + +a { + color: var(--color-link); + font-size: 16px; +} diff --git a/libs/agora/genes/src/lib/components/gene-soe/gene-soe.component.spec.ts.off b/libs/agora/genes/src/lib/components/gene-soe/gene-soe.component.spec.ts.off new file mode 100644 index 0000000000..d96fa931b9 --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-soe/gene-soe.component.spec.ts.off @@ -0,0 +1,35 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { GeneSoeComponent } from './gene-soe.component'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Gene SOE', () => { + let fixture: ComponentFixture; + let component: GeneSoeComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [GeneSoeComponent], + imports: [RouterTestingModule], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(GeneSoeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/agora/genes/src/lib/components/gene-soe/gene-soe.component.ts b/libs/agora/genes/src/lib/components/gene-soe/gene-soe.component.ts new file mode 100644 index 0000000000..b824c84b4e --- /dev/null +++ b/libs/agora/genes/src/lib/components/gene-soe/gene-soe.component.ts @@ -0,0 +1,16 @@ +import { Component, Input } from '@angular/core'; +import { Gene } from '@sagebionetworks/agora/api-client-angular'; +import { GeneBioDomainsComponent } from '../gene-biodomains/gene-biodomains.component'; +import { GeneSoeChartsComponent } from '../gene-soe-charts/gene-soe-charts.component'; +import { GeneSoeListComponent } from '../gene-soe-list/gene-soe-list.component'; + +@Component({ + selector: 'agora-gene-soe', + standalone: true, + imports: [GeneBioDomainsComponent, GeneSoeChartsComponent, GeneSoeListComponent], + templateUrl: './gene-soe.component.html', + styleUrls: ['./gene-soe.component.scss'], +}) +export class GeneSoeComponent { + @Input() gene: Gene | undefined; +} diff --git a/libs/agora/genes/src/lib/components/gene-table/gene-table.component.spec.ts b/libs/agora/genes/src/lib/components/gene-table/gene-table.component.spec.ts.off similarity index 100% rename from libs/agora/genes/src/lib/components/gene-table/gene-table.component.spec.ts rename to libs/agora/genes/src/lib/components/gene-table/gene-table.component.spec.ts.off diff --git a/libs/agora/genes/src/lib/components/overlay-panel-link/overlay-panel-link.component.html b/libs/agora/genes/src/lib/components/overlay-panel-link/overlay-panel-link.component.html new file mode 100644 index 0000000000..7cec4374ff --- /dev/null +++ b/libs/agora/genes/src/lib/components/overlay-panel-link/overlay-panel-link.component.html @@ -0,0 +1,25 @@ + + + @if (icon) { + + + + } + @if (text) { + + } + + + + @if (wikiId && hasActived) { + + } + + + + diff --git a/libs/agora/genes/src/lib/components/overlay-panel-link/overlay-panel-link.component.scss b/libs/agora/genes/src/lib/components/overlay-panel-link/overlay-panel-link.component.scss new file mode 100644 index 0000000000..6cfa3f0f23 --- /dev/null +++ b/libs/agora/genes/src/lib/components/overlay-panel-link/overlay-panel-link.component.scss @@ -0,0 +1,19 @@ +@import 'libs/agora/styles/src/lib/variables'; +@import 'libs/agora/styles/src/lib/mixins'; + +overlay-link, +.overlay-link { + display: inline-block; +} + +.overlay-link-inner { + display: flex; +} + +.overlay-link-icon { + transform: translateY(1px); + + &:not(:last-child) { + margin-right: 8px; + } +} diff --git a/libs/agora/genes/src/lib/components/overlay-panel-link/overlay-panel-link.component.spec.ts.off b/libs/agora/genes/src/lib/components/overlay-panel-link/overlay-panel-link.component.spec.ts.off new file mode 100644 index 0000000000..b9cd9afae5 --- /dev/null +++ b/libs/agora/genes/src/lib/components/overlay-panel-link/overlay-panel-link.component.spec.ts.off @@ -0,0 +1,54 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { OverlayPanelLinkComponent } from './overlay-panel-link.component'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Overlay Panel Link', () => { + let fixture: ComponentFixture; + let component: OverlayPanelLinkComponent; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + providers: [provideRouter([]), provideHttpClient()], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(OverlayPanelLinkComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + element = fixture.nativeElement; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have overlay', () => { + expect(element.querySelector('p-overlaypanel.p-element')).toBeTruthy(); + }); + + it('should open overlay on click', () => { + const toggle = element.querySelector('.overlay-link-inner') as HTMLElement; + + expect(toggle).toBeTruthy(); + + toggle.click(); + fixture.detectChanges(); + + expect(document.querySelector('.overlay-panel')).toBeTruthy(); + }); +}); diff --git a/libs/agora/genes/src/lib/components/overlay-panel-link/overlay-panel-link.component.ts b/libs/agora/genes/src/lib/components/overlay-panel-link/overlay-panel-link.component.ts new file mode 100644 index 0000000000..4950759bc4 --- /dev/null +++ b/libs/agora/genes/src/lib/components/overlay-panel-link/overlay-panel-link.component.ts @@ -0,0 +1,30 @@ +import { Component, Input, ViewChild } from '@angular/core'; +import { OverlayPanel, OverlayPanelModule } from 'primeng/overlaypanel'; +import { CommonModule } from '@angular/common'; +import { SvgIconComponent, WikiComponent } from '@sagebionetworks/agora/shared'; + +@Component({ + selector: 'agora-overlay-panel-link', + standalone: true, + imports: [CommonModule, SvgIconComponent, OverlayPanelModule, WikiComponent], + templateUrl: './overlay-panel-link.component.html', + styleUrls: ['./overlay-panel-link.component.scss'], +}) +export class OverlayPanelLinkComponent { + @Input() icon = 'svg'; + @Input() text = ''; + + @Input() ownerId: string | undefined; + @Input() wikiId: string | undefined; + + isActive = false; + hasActived = false; + + @ViewChild('panel') panel!: OverlayPanel; + + toggle(event: Event) { + this.hasActived = true; + this.isActive = !this.isActive; + this.panel.toggle(event); + } +} diff --git a/libs/agora/genes/src/lib/helpers/GeneHelpers.ts b/libs/agora/genes/src/lib/helpers/GeneHelpers.ts new file mode 100644 index 0000000000..e8d92b0633 --- /dev/null +++ b/libs/agora/genes/src/lib/helpers/GeneHelpers.ts @@ -0,0 +1,13 @@ +import { Gene } from '@sagebionetworks/agora/api-client-angular'; + +export function getStatisticalModels(gene: Gene) { + const models: string[] = []; + + gene.rna_differential_expression?.forEach((item: any) => { + if (!models.includes(item.model)) { + models.push(item.model); + } + }); + + return models; +} diff --git a/libs/agora/genes/src/lib/helpers/index.ts b/libs/agora/genes/src/lib/helpers/index.ts new file mode 100644 index 0000000000..7e8b2a7c12 --- /dev/null +++ b/libs/agora/genes/src/lib/helpers/index.ts @@ -0,0 +1 @@ +export * from './GeneHelpers'; diff --git a/libs/agora/genes/src/lib/models/ExperimentalValidationWithTeamData.ts b/libs/agora/genes/src/lib/models/ExperimentalValidationWithTeamData.ts new file mode 100644 index 0000000000..4c0ae46cff --- /dev/null +++ b/libs/agora/genes/src/lib/models/ExperimentalValidationWithTeamData.ts @@ -0,0 +1,5 @@ +import { ExperimentalValidation, Team } from '@sagebionetworks/agora/api-client-angular'; + +export interface ExperimentalValidationWithTeamData extends ExperimentalValidation { + team_data?: Team; +} diff --git a/libs/agora/genes/src/lib/models/TargetNominationWithTeamData.ts b/libs/agora/genes/src/lib/models/TargetNominationWithTeamData.ts new file mode 100644 index 0000000000..c3bb432e96 --- /dev/null +++ b/libs/agora/genes/src/lib/models/TargetNominationWithTeamData.ts @@ -0,0 +1,5 @@ +import { TargetNomination, Team } from '@sagebionetworks/agora/api-client-angular'; + +export interface TargetNominationWithTeamData extends TargetNomination { + team_data?: Team; +} diff --git a/libs/agora/genes/src/lib/models/index.ts b/libs/agora/genes/src/lib/models/index.ts new file mode 100644 index 0000000000..eab079b8e1 --- /dev/null +++ b/libs/agora/genes/src/lib/models/index.ts @@ -0,0 +1,2 @@ +export * from './TargetNominationWithTeamData'; +export * from './ExperimentalValidationWithTeamData'; diff --git a/libs/agora/home/src/lib/home.component.spec.ts b/libs/agora/home/src/lib/home.component.spec.ts.off similarity index 89% rename from libs/agora/home/src/lib/home.component.spec.ts rename to libs/agora/home/src/lib/home.component.spec.ts.off index ea3ec5a335..8d7f76638e 100644 --- a/libs/agora/home/src/lib/home.component.spec.ts +++ b/libs/agora/home/src/lib/home.component.spec.ts.off @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { HomeComponent } from './home.component'; import { provideHttpClient } from '@angular/common/http'; import { provideRouter } from '@angular/router'; +import { CommonModule } from '@angular/common'; describe('HomeComponent', () => { let component: HomeComponent; @@ -9,7 +10,7 @@ describe('HomeComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [], + imports: [CommonModule], providers: [provideHttpClient(), provideRouter([])], }).compileComponents(); diff --git a/libs/agora/news/src/lib/news.component.ts b/libs/agora/news/src/lib/news.component.ts index 8ed4bda939..02926cf0ce 100644 --- a/libs/agora/news/src/lib/news.component.ts +++ b/libs/agora/news/src/lib/news.component.ts @@ -1,6 +1,6 @@ import { CommonModule } from '@angular/common'; -import { Component } from '@angular/core'; -import { WikiComponent } from 'libs/agora/wiki/src/lib/wiki.component'; +import { Component, ViewEncapsulation } from '@angular/core'; +import { WikiComponent } from '@sagebionetworks/agora/shared'; @Component({ selector: 'agora-news', @@ -8,6 +8,7 @@ import { WikiComponent } from 'libs/agora/wiki/src/lib/wiki.component'; imports: [CommonModule, WikiComponent], templateUrl: './news.component.html', styleUrls: ['./news.component.scss'], + encapsulation: ViewEncapsulation.None, }) export class NewsComponent { wikiId = '611426'; diff --git a/libs/agora/nominated-targets/src/lib/nominated-targets/nominated-targets.component.spec.ts b/libs/agora/nominated-targets/src/lib/nominated-targets/nominated-targets.component.spec.ts.off similarity index 100% rename from libs/agora/nominated-targets/src/lib/nominated-targets/nominated-targets.component.spec.ts rename to libs/agora/nominated-targets/src/lib/nominated-targets/nominated-targets.component.spec.ts.off diff --git a/libs/agora/nominated-targets/src/lib/nominated-targets/nominated-targets.component.ts b/libs/agora/nominated-targets/src/lib/nominated-targets/nominated-targets.component.ts index 0479c09785..1953cb5400 100644 --- a/libs/agora/nominated-targets/src/lib/nominated-targets/nominated-targets.component.ts +++ b/libs/agora/nominated-targets/src/lib/nominated-targets/nominated-targets.component.ts @@ -4,7 +4,7 @@ import { RouterLink } from '@angular/router'; import { Gene, TargetNomination, GenesService } from '@sagebionetworks/agora/api-client-angular'; import { GeneTableComponent } from '@sagebionetworks/agora/genes'; import { GeneTableColumn } from '@sagebionetworks/agora/models'; -import { ModalLinkComponent, SvgIconComponent } from '@sagebionetworks/agora/ui'; +import { ModalLinkComponent, SvgIconComponent } from '@sagebionetworks/agora/shared'; import { ButtonModule } from 'primeng/button'; @Component({ diff --git a/libs/agora/nominated-targets/src/lib/nomination-form/nomination-form.component.ts b/libs/agora/nominated-targets/src/lib/nomination-form/nomination-form.component.ts index 3f53eed92f..bec7d0231d 100644 --- a/libs/agora/nominated-targets/src/lib/nomination-form/nomination-form.component.ts +++ b/libs/agora/nominated-targets/src/lib/nomination-form/nomination-form.component.ts @@ -1,180 +1,9 @@ -import { CommonModule } from '@angular/common'; -import { Component, inject, OnInit } from '@angular/core'; -import { RouterLink } from '@angular/router'; -import { Gene, TargetNomination, GenesService } from '@sagebionetworks/agora/api-client-angular'; -import { GeneTableComponent } from '@sagebionetworks/agora/genes'; -import { GeneTableColumn } from '@sagebionetworks/agora/models'; -import { ModalLinkComponent, SvgIconComponent } from '@sagebionetworks/agora/ui'; -import { ButtonModule } from 'primeng/button'; +import { Component } from '@angular/core'; @Component({ selector: 'agora-nomination-form', standalone: true, - imports: [ - CommonModule, - RouterLink, - SvgIconComponent, - ModalLinkComponent, - GeneTableComponent, - ButtonModule, - ], templateUrl: './nomination-form.component.html', styleUrls: ['./nomination-form.component.scss'], }) -export class NominationFormComponent implements OnInit { - apiService = inject(GenesService); - - genes: Gene[] = []; - searchTerm = ''; - nominations: number[] = []; - columns: GeneTableColumn[] = [ - { field: 'hgnc_symbol', header: 'Gene Symbol', selected: true }, - { field: 'total_nominations', header: 'Nominations', selected: true }, - { - field: 'initial_nomination_display_value', - header: 'Year First Nominated', - selected: true, - }, - { - field: 'teams_display_value', - header: 'Nominating Teams', - selected: true, - }, - { field: 'study_display_value', header: 'Cohort Study', selected: true }, - { - field: 'programs_display_value', - header: 'Program', - selected: false, - }, - { - field: 'input_data_display_value', - header: 'Input Data', - selected: false, - }, - { - field: 'pharos_class_display_value', - header: 'Pharos Class', - selected: false, - }, - { - field: 'sm_druggability_display_value', - header: 'Small Molecule Druggability', - selected: false, - }, - { - field: 'safety_rating_display_value', - header: 'Safety Rating', - selected: false, - }, - { - field: 'ab_modality_display_value', - header: 'Antibody Modality', - selected: false, - }, - ]; - - ngOnInit() { - this.apiService.getNominatedGenes().subscribe((response) => { - if (!response.items) return; - const genes = response.items; - genes.forEach((de: Gene) => { - let teamsArray: string[] = []; - let studyArray: string[] = []; - let programsArray: string[] = []; - let inputDataArray: string[] = []; - let initialNominationArray: number[] = []; - if (de.total_nominations) { - if (!this.nominations.includes(de.total_nominations)) { - this.nominations.push(de.total_nominations); - this.nominations.sort(); - } - } - // Handle TargetNomination fields - // First map all entries nested in the data to a new array - if (de.target_nominations?.length) { - teamsArray = de.target_nominations.map((nt: TargetNomination) => nt.team); - studyArray = this.removeNullAndEmptyStrings( - de.target_nominations.map((nt: TargetNomination) => nt.study), - ); - programsArray = de.target_nominations.map((nt: TargetNomination) => nt.source); - inputDataArray = de.target_nominations.map((nt: TargetNomination) => nt.input_data); - initialNominationArray = de.target_nominations - .map((nt: TargetNomination) => nt.initial_nomination) - .filter((item) => item !== undefined); - } - // Check if there are any strings with commas inside, - // if there are separate those into new split strings - teamsArray = this.commaFlattenArray(teamsArray); - studyArray = this.commaFlattenArray(studyArray); - programsArray = this.commaFlattenArray(programsArray); - inputDataArray = this.commaFlattenArray(inputDataArray); - // Populate targetNomination display fields - de.teams_display_value = this.getCommaSeparatedStringOfUniqueSortedValues(teamsArray); - de.study_display_value = this.getCommaSeparatedStringOfUniqueSortedValues(studyArray); - de.programs_display_value = this.getCommaSeparatedStringOfUniqueSortedValues(programsArray); - de.input_data_display_value = - this.getCommaSeparatedStringOfUniqueSortedValues(inputDataArray); - de.initial_nomination_display_value = initialNominationArray.length - ? Math.min(...initialNominationArray) - : undefined; - // Populate Druggability display fields - if (de.druggability && de.druggability.length) { - de.pharos_class_display_value = de.druggability[0].pharos_class - ? de.druggability[0].pharos_class - : 'No value'; - de.sm_druggability_display_value = - de.druggability[0].sm_druggability_bucket + ': ' + de.druggability[0].classification; - de.safety_rating_display_value = - de.druggability[0].safety_bucket + ': ' + de.druggability[0].safety_bucket_definition; - de.ab_modality_display_value = - de.druggability[0].abability_bucket + - ': ' + - de.druggability[0].abability_bucket_definition; - } else { - de.pharos_class_display_value = 'No value'; - de.sm_druggability_display_value = 'No value'; - de.safety_rating_display_value = 'No value'; - de.ab_modality_display_value = 'No value'; - } - }); - this.genes = genes; - }); - } - - removeNullAndEmptyStrings(items: (string | null)[]) { - return items.filter((item) => Boolean(item)) as string[]; - } - - getUnique(value: string, index: number, self: any) { - return self.indexOf(value) === index; - } - - commaFlattenArray(array: string[]): string[] { - const finalArray: string[] = []; - array.forEach((t) => { - const i = t.indexOf(', '); - if (i > -1) { - const tmpArray = t.split(', '); - tmpArray.forEach((val) => finalArray.push(val)); - } else { - finalArray.push(t); - } - }); - return finalArray; - } - - getCommaSeparatedStringOfUniqueSortedValues(inputArray: string[]) { - let display_value = ''; - if (inputArray.length) { - display_value = inputArray - .filter(this.getUnique) - .sort((a: string, b: string) => a.localeCompare(b)) - .join(', '); - } - return display_value; - } - - onSearch(event: any) { - this.searchTerm = event.target.value || ''; - } -} +export class NominationFormComponent {} diff --git a/libs/agora/not-found/src/lib/not-found.component.html b/libs/agora/not-found/src/lib/not-found.component.html index 8cd8cac8a4..fedabd6b0f 100644 --- a/libs/agora/not-found/src/lib/not-found.component.html +++ b/libs/agora/not-found/src/lib/not-found.component.html @@ -1,26 +1,26 @@ -
-
- 404b -

Page Not Found

-

Oops! The page you are looking for does not exist. It might have been moved or deleted.

+
+
+

We're sorry!

+

Page not found.

-
+
+ + + diff --git a/libs/agora/not-found/src/lib/not-found.component.scss b/libs/agora/not-found/src/lib/not-found.component.scss index e69de29bb2..586ba66632 100644 --- a/libs/agora/not-found/src/lib/not-found.component.scss +++ b/libs/agora/not-found/src/lib/not-found.component.scss @@ -0,0 +1,24 @@ +@import 'libs/agora/styles/src/lib/variables'; +@import 'libs/agora/styles/src/lib/mixins'; + +.page-not-found { + min-height: calc(100vh - var(--header-height) - var(--footer-height) + 1px); + background-image: url('/agora-assets/images/page-not-found.svg'); + background-size: cover !important; + display: flex; + justify-content: center; + align-items: center; + + h1 { + text-align: center; + color: #fff; + } + + .page-not-found-heading { + font-size: 60px; + } + + .page-not-found-message { + margin-bottom: 0; + } +} diff --git a/libs/agora/not-found/src/lib/not-found.component.spec.ts b/libs/agora/not-found/src/lib/not-found.component.spec.ts.off similarity index 100% rename from libs/agora/not-found/src/lib/not-found.component.spec.ts rename to libs/agora/not-found/src/lib/not-found.component.spec.ts.off diff --git a/libs/agora/not-found/src/lib/not-found.component.ts b/libs/agora/not-found/src/lib/not-found.component.ts index 34515ae409..53c0f65233 100644 --- a/libs/agora/not-found/src/lib/not-found.component.ts +++ b/libs/agora/not-found/src/lib/not-found.component.ts @@ -3,20 +3,19 @@ import { Component, OnInit, Renderer2 } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; import { MatCardModule } from '@angular/material/card'; -import { FooterComponent } from '@sagebionetworks/agora/ui'; import { ConfigService } from '@sagebionetworks/agora/config'; import { SeoService } from '@sagebionetworks/shared/util'; import { DataversionService, Dataversion } from '@sagebionetworks/agora/api-client-angular'; import { getSeoData } from './seo-data'; import { Observable } from 'rxjs'; -import { SynapseApiService } from '@sagebionetworks/agora/services'; -import { SynapseWiki } from '@sagebionetworks/agora/models'; -import { OrgSagebionetworksRepoModelWikiWikiPage } from '@sagebionetworks/synapse/api-client-angular'; +// import { SynapseApiService } from '@sagebionetworks/agora/services'; +// import { SynapseWiki } from '@sagebionetworks/agora/models'; +// import { OrgSagebionetworksRepoModelWikiWikiPage } from '@sagebionetworks/synapse/api-client-angular'; @Component({ selector: 'agora-not-found', standalone: true, - imports: [CommonModule, RouterModule, MatCardModule, MatButtonModule, FooterComponent], + imports: [CommonModule, RouterModule, MatCardModule, MatButtonModule], templateUrl: './not-found.component.html', styleUrls: ['./not-found.component.scss'], }) @@ -25,15 +24,15 @@ export class NotFoundComponent implements OnInit { public apiDocsUrl: string; dataversion$!: Observable; - wiki$!: Observable; - wikiAlternative$!: Observable; + // wiki$!: Observable; + // wikiAlternative$!: Observable; constructor( private readonly configService: ConfigService, private dataversionService: DataversionService, private seoService: SeoService, private renderer2: Renderer2, - private synapseApiService: SynapseApiService, + // private synapseApiService: SynapseApiService, ) { this.appVersion = this.configService.config.appVersion; this.apiDocsUrl = this.configService.config.apiDocsUrl; @@ -44,9 +43,9 @@ export class NotFoundComponent implements OnInit { ngOnInit(): void { this.dataversion$ = this.dataversionService.getDataversion(); - const ownerId = 'syn25913473'; - const wikiId = '612058'; - this.wiki$ = this.synapseApiService.getWiki(ownerId, wikiId); - this.wikiAlternative$ = this.synapseApiService.getWikiAlternative(ownerId, wikiId); + // const ownerId = 'syn25913473'; + // const wikiId = '612058'; + // this.wiki$ = this.synapseApiService.getWiki(ownerId, wikiId); + // this.wikiAlternative$ = this.synapseApiService.getWikiAlternative(ownerId, wikiId); } } diff --git a/libs/agora/services/src/index.ts b/libs/agora/services/src/index.ts index 3a558cdab1..0fbabd3ca1 100644 --- a/libs/agora/services/src/index.ts +++ b/libs/agora/services/src/index.ts @@ -1,4 +1,5 @@ export * from './lib/error.service'; +export * from './lib/github.service'; export * from './lib/helper.service'; export * from './lib/rollbar.service'; export * from './lib/synapse-api.service'; diff --git a/libs/agora/services/src/lib/github.service.spec.ts b/libs/agora/services/src/lib/github.service.spec.ts new file mode 100644 index 0000000000..70a08655af --- /dev/null +++ b/libs/agora/services/src/lib/github.service.spec.ts @@ -0,0 +1,30 @@ +import { provideHttpClient } from '@angular/common/http'; +import { GitHubService } from './github.service'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +describe('GitHubService', () => { + let service: GitHubService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [GitHubService, provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(GitHubService); + }); + + it('should create', () => { + expect(service).toBeDefined(); + }); + + it('should get sha', () => { + const tag = 'agora/v0.0.2'; + const expectedSHA = 'Xb95bc34609ca7c9e6f64f0c5c0d3ca0df6880f9e'; + + let result = ''; + service.getCommitSHA(tag).subscribe((response) => { + result = response; + expect(result).toBe(expectedSHA); + }); + }); +}); diff --git a/libs/agora/services/src/lib/github.service.ts b/libs/agora/services/src/lib/github.service.ts new file mode 100644 index 0000000000..a0e8c18acb --- /dev/null +++ b/libs/agora/services/src/lib/github.service.ts @@ -0,0 +1,28 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root', +}) +export class GitHubService { + http = inject(HttpClient); + + private apiUrl = 'https://api.github.com/repos/Sage-Bionetworks/sage-monorepo/tags'; + // private token = 'your_github_token'; // Optional for private repos or higher rate limit + + getCommitSHA(tagName: string): Observable { + // const headers = new HttpHeaders({ + // Authorization: `Bearer ${this.token}`, + // }); + + // return this.http.get(this.apiUrl, { headers }).pipe( + return this.http.get(this.apiUrl).pipe( + map((tags) => { + const tag = tags.find((t) => t.name === tagName); + return tag ? tag.commit.sha : 'Tag not found'; + }), + ); + } +} diff --git a/libs/agora/shared/.eslintrc.json b/libs/agora/shared/.eslintrc.json new file mode 100644 index 0000000000..5d8c12012f --- /dev/null +++ b/libs/agora/shared/.eslintrc.json @@ -0,0 +1,40 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "env": { + "jest": true + }, + "overrides": [ + { + "files": ["*.ts"], + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates", + "plugin:jest/recommended" + ], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "agora", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "agora", + "style": "kebab-case" + } + ] + } + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/agora/shared/README.md b/libs/agora/shared/README.md new file mode 100644 index 0000000000..9450a94006 --- /dev/null +++ b/libs/agora/shared/README.md @@ -0,0 +1,7 @@ +# agora-shared + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test agora-shared` to execute the unit tests. diff --git a/libs/agora/shared/jest.config.ts b/libs/agora/shared/jest.config.ts new file mode 100644 index 0000000000..80f6a88359 --- /dev/null +++ b/libs/agora/shared/jest.config.ts @@ -0,0 +1,23 @@ +/* eslint-disable */ +export default { + displayName: 'agora-shared', + preset: '../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + globals: {}, + coverageDirectory: '../../../coverage/libs/agora/shared', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/agora/shared/project.json b/libs/agora/shared/project.json new file mode 100644 index 0000000000..878e61408b --- /dev/null +++ b/libs/agora/shared/project.json @@ -0,0 +1,27 @@ +{ + "name": "agora-shared", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/agora/shared/src", + "prefix": "agora", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/libs/agora/shared"], + "options": { + "jestConfig": "libs/agora/shared/jest.config.ts" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "lint-fix": { + "executor": "@nx/eslint:lint", + "options": { + "fix": true + } + } + }, + "tags": ["type:feature", "scope:agora", "language:typescript"], + "implicitDependencies": [] +} diff --git a/libs/agora/shared/src/index.ts b/libs/agora/shared/src/index.ts new file mode 100644 index 0000000000..65c8a9857a --- /dev/null +++ b/libs/agora/shared/src/index.ts @@ -0,0 +1,5 @@ +export * from './lib/helpers/functions'; +export * from './lib/loading-icon/loading-icon.component'; +export * from './lib/modal-link/modal-link.component'; +export * from './lib/svg-icon/svg-icon.component'; +export * from './lib/wiki/wiki.component'; diff --git a/libs/agora/shared/src/lib/helpers/functions.ts b/libs/agora/shared/src/lib/helpers/functions.ts new file mode 100644 index 0000000000..857d2744ea --- /dev/null +++ b/libs/agora/shared/src/lib/helpers/functions.ts @@ -0,0 +1,9 @@ +export function getRandomInt(minInclusive: number, maxExclusive: number) { + minInclusive = Math.ceil(minInclusive); + maxExclusive = Math.floor(maxExclusive); + return Math.floor(Math.random() * (maxExclusive - minInclusive + 1)) + minInclusive; +} + +export function removeParenthesis(s: string) { + return s.replace('(', '').replace(')', ''); +} diff --git a/libs/agora/ui/src/lib/components/loading-icon/loading-icon.component.html b/libs/agora/shared/src/lib/loading-icon/loading-icon.component.html similarity index 100% rename from libs/agora/ui/src/lib/components/loading-icon/loading-icon.component.html rename to libs/agora/shared/src/lib/loading-icon/loading-icon.component.html diff --git a/libs/agora/ui/src/lib/components/loading-icon/loading-icon.component.scss b/libs/agora/shared/src/lib/loading-icon/loading-icon.component.scss similarity index 100% rename from libs/agora/ui/src/lib/components/loading-icon/loading-icon.component.scss rename to libs/agora/shared/src/lib/loading-icon/loading-icon.component.scss diff --git a/libs/agora/ui/src/lib/components/loading-icon/loading-icon.component.spec.ts b/libs/agora/shared/src/lib/loading-icon/loading-icon.component.spec.ts similarity index 100% rename from libs/agora/ui/src/lib/components/loading-icon/loading-icon.component.spec.ts rename to libs/agora/shared/src/lib/loading-icon/loading-icon.component.spec.ts diff --git a/libs/agora/ui/src/lib/components/loading-icon/loading-icon.component.ts b/libs/agora/shared/src/lib/loading-icon/loading-icon.component.ts similarity index 76% rename from libs/agora/ui/src/lib/components/loading-icon/loading-icon.component.ts rename to libs/agora/shared/src/lib/loading-icon/loading-icon.component.ts index 0ece85f51a..ea89c82276 100644 --- a/libs/agora/ui/src/lib/components/loading-icon/loading-icon.component.ts +++ b/libs/agora/shared/src/lib/loading-icon/loading-icon.component.ts @@ -1,11 +1,10 @@ import { CommonModule } from '@angular/common'; import { Component } from '@angular/core'; -import { RouterModule } from '@angular/router'; @Component({ selector: 'agora-loading-icon', standalone: true, - imports: [CommonModule, RouterModule], + imports: [CommonModule], templateUrl: './loading-icon.component.html', styleUrls: ['./loading-icon.component.scss'], }) diff --git a/libs/agora/ui/src/lib/components/modal-link/modal-link.component.html b/libs/agora/shared/src/lib/modal-link/modal-link.component.html similarity index 100% rename from libs/agora/ui/src/lib/components/modal-link/modal-link.component.html rename to libs/agora/shared/src/lib/modal-link/modal-link.component.html diff --git a/libs/agora/ui/src/lib/components/modal-link/modal-link.component.scss b/libs/agora/shared/src/lib/modal-link/modal-link.component.scss similarity index 100% rename from libs/agora/ui/src/lib/components/modal-link/modal-link.component.scss rename to libs/agora/shared/src/lib/modal-link/modal-link.component.scss diff --git a/libs/agora/ui/src/lib/components/modal-link/modal-link.component.spec.ts b/libs/agora/shared/src/lib/modal-link/modal-link.component.spec.ts.off similarity index 86% rename from libs/agora/ui/src/lib/components/modal-link/modal-link.component.spec.ts rename to libs/agora/shared/src/lib/modal-link/modal-link.component.spec.ts.off index 78225314f4..4e47fba7ca 100644 --- a/libs/agora/ui/src/lib/components/modal-link/modal-link.component.spec.ts +++ b/libs/agora/shared/src/lib/modal-link/modal-link.component.spec.ts.off @@ -1,8 +1,11 @@ import { TestBed, ComponentFixture } from '@angular/core/testing'; import { ModalLinkComponent } from './modal-link.component'; import { DialogModule } from 'primeng/dialog'; -import { LoadingIconComponent, SvgIconComponent } from '@sagebionetworks/agora/ui'; -import { WikiComponent } from 'libs/agora/wiki/src/lib/wiki.component'; +import { + LoadingIconComponent, + SvgIconComponent, + WikiComponent, +} from '@sagebionetworks/agora/shared'; import { SynapseApiService } from '@sagebionetworks/agora/services'; import { provideHttpClient } from '@angular/common/http'; import { CommonModule } from '@angular/common'; diff --git a/libs/agora/ui/src/lib/components/modal-link/modal-link.component.ts b/libs/agora/shared/src/lib/modal-link/modal-link.component.ts similarity index 90% rename from libs/agora/ui/src/lib/components/modal-link/modal-link.component.ts rename to libs/agora/shared/src/lib/modal-link/modal-link.component.ts index aea6b508b6..8bc987be8d 100644 --- a/libs/agora/ui/src/lib/components/modal-link/modal-link.component.ts +++ b/libs/agora/shared/src/lib/modal-link/modal-link.component.ts @@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common'; import { Component, Input } from '@angular/core'; import { DialogModule } from 'primeng/dialog'; import { SvgIconComponent } from '../svg-icon/svg-icon.component'; -import { WikiComponent } from 'libs/agora/wiki/src/lib/wiki.component'; +import { WikiComponent } from '../wiki/wiki.component'; @Component({ selector: 'agora-modal-link', diff --git a/libs/agora/ui/src/lib/components/svg-icon/svg-icon.component.html b/libs/agora/shared/src/lib/svg-icon/svg-icon.component.html similarity index 100% rename from libs/agora/ui/src/lib/components/svg-icon/svg-icon.component.html rename to libs/agora/shared/src/lib/svg-icon/svg-icon.component.html diff --git a/libs/agora/ui/src/lib/components/svg-icon/svg-icon.component.scss b/libs/agora/shared/src/lib/svg-icon/svg-icon.component.scss similarity index 100% rename from libs/agora/ui/src/lib/components/svg-icon/svg-icon.component.scss rename to libs/agora/shared/src/lib/svg-icon/svg-icon.component.scss diff --git a/libs/agora/ui/src/lib/components/svg-icon/svg-icon.component.spec.ts b/libs/agora/shared/src/lib/svg-icon/svg-icon.component.spec.ts similarity index 100% rename from libs/agora/ui/src/lib/components/svg-icon/svg-icon.component.spec.ts rename to libs/agora/shared/src/lib/svg-icon/svg-icon.component.spec.ts diff --git a/libs/agora/ui/src/lib/components/svg-icon/svg-icon.component.ts b/libs/agora/shared/src/lib/svg-icon/svg-icon.component.ts similarity index 100% rename from libs/agora/ui/src/lib/components/svg-icon/svg-icon.component.ts rename to libs/agora/shared/src/lib/svg-icon/svg-icon.component.ts diff --git a/libs/agora/wiki/src/lib/wiki.component.html b/libs/agora/shared/src/lib/wiki/wiki.component.html similarity index 100% rename from libs/agora/wiki/src/lib/wiki.component.html rename to libs/agora/shared/src/lib/wiki/wiki.component.html diff --git a/libs/agora/wiki/src/lib/wiki.component.scss b/libs/agora/shared/src/lib/wiki/wiki.component.scss similarity index 94% rename from libs/agora/wiki/src/lib/wiki.component.scss rename to libs/agora/shared/src/lib/wiki/wiki.component.scss index b6b5309fe1..b6e3d50e93 100644 --- a/libs/agora/wiki/src/lib/wiki.component.scss +++ b/libs/agora/shared/src/lib/wiki/wiki.component.scss @@ -44,10 +44,7 @@ .wiki-overlay { display: none; position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; + inset: 0; background-color: #fff; align-items: center; justify-content: center; diff --git a/libs/agora/wiki/src/lib/wiki.component.spec.ts b/libs/agora/shared/src/lib/wiki/wiki.component.spec.ts.off similarity index 97% rename from libs/agora/wiki/src/lib/wiki.component.spec.ts rename to libs/agora/shared/src/lib/wiki/wiki.component.spec.ts.off index a454f0c4d0..1fe515ac56 100644 --- a/libs/agora/wiki/src/lib/wiki.component.spec.ts +++ b/libs/agora/shared/src/lib/wiki/wiki.component.spec.ts.off @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { WikiComponent } from './wiki.component'; import { provideHttpClient } from '@angular/common/http'; -import { server } from 'libs/agora/testing/src/lib/server/msw-server'; +import { server } from '@sagebionetworks/agora/testing'; import { synapseWikiMock } from '@sagebionetworks/agora/testing'; import { http, HttpResponse } from 'msw'; diff --git a/libs/agora/wiki/src/lib/wiki.component.ts b/libs/agora/shared/src/lib/wiki/wiki.component.ts similarity index 95% rename from libs/agora/wiki/src/lib/wiki.component.ts rename to libs/agora/shared/src/lib/wiki/wiki.component.ts index 81a53e9f3c..72e8efb3d5 100644 --- a/libs/agora/wiki/src/lib/wiki.component.ts +++ b/libs/agora/shared/src/lib/wiki/wiki.component.ts @@ -8,15 +8,14 @@ import { ViewEncapsulation, } from '@angular/core'; import { SafeHtml, DomSanitizer } from '@angular/platform-browser'; -import { LoadingIconComponent } from '@sagebionetworks/agora/ui'; import { SynapseWiki } from '@sagebionetworks/agora/models'; import { SynapseApiService } from '@sagebionetworks/agora/services'; +import { LoadingIconComponent } from '@sagebionetworks/agora/shared'; @Component({ selector: 'agora-wiki', standalone: true, imports: [CommonModule, LoadingIconComponent], - providers: [SynapseApiService], templateUrl: './wiki.component.html', styleUrls: ['./wiki.component.scss'], encapsulation: ViewEncapsulation.None, diff --git a/libs/agora/shared/src/test-setup.ts b/libs/agora/shared/src/test-setup.ts new file mode 100644 index 0000000000..1100b3e8a6 --- /dev/null +++ b/libs/agora/shared/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/libs/agora/shared/tsconfig.json b/libs/agora/shared/tsconfig.json new file mode 100644 index 0000000000..f2860a9cd7 --- /dev/null +++ b/libs/agora/shared/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "compilerOptions": { + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "target": "es2020", + "esModuleInterop": true + }, + "angularCompilerOptions": { + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/agora/shared/tsconfig.lib.json b/libs/agora/shared/tsconfig.lib.json new file mode 100644 index 0000000000..b228a1a081 --- /dev/null +++ b/libs/agora/shared/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": ["src/test-setup.ts", "**/*.spec.ts", "**/*.test.ts", "jest.config.ts"], + "include": ["**/*.ts"] +} diff --git a/libs/agora/wiki/tsconfig.spec.json b/libs/agora/shared/tsconfig.spec.json similarity index 91% rename from libs/agora/wiki/tsconfig.spec.json rename to libs/agora/shared/tsconfig.spec.json index 44bdd3c12c..d3889a9881 100644 --- a/libs/agora/wiki/tsconfig.spec.json +++ b/libs/agora/shared/tsconfig.spec.json @@ -3,7 +3,6 @@ "compilerOptions": { "outDir": "../../../dist/out-tsc", "module": "commonjs", - "target": "es2016", "types": ["jest", "node"] }, "files": ["src/test-setup.ts"], diff --git a/libs/agora/styles/src/lib/_variables.scss b/libs/agora/styles/src/lib/_variables.scss index a5a36a4428..d4c0310d13 100644 --- a/libs/agora/styles/src/lib/_variables.scss +++ b/libs/agora/styles/src/lib/_variables.scss @@ -67,11 +67,9 @@ $main-colors: ( gray-200: #f1f3f5, gray-100: #fbfbfc, ); - $main-colors-hover: ( action-primary: #406786, ); - $extra-colors: ( heading: map.get($main-colors, 'primary'), text: map.get($main-colors, 'gray-900'), @@ -87,9 +85,11 @@ $extra-colors: ( @each $key, $color in $main-colors { --color-#{$key}: #{$color}; } + @each $key, $color in $main-colors-hover { --color-#{$key}-hover: #{$color}; } + @each $key, $color in $extra-colors { --color-#{$key}: #{$color}; } @@ -100,7 +100,6 @@ $extra-colors: ( // -------------------------------------------------------------------------- // $font-family-primary: 'DM Sans', sans-serif; - $headings: ( h1: ( font-size: 1.75rem /* 28px */, @@ -133,7 +132,6 @@ $headings: ( line-height: 1.125rem /* 18px */, ), ); - $font-sizes: ( xs: ( font-size: 0.75rem /* 12px */, @@ -184,7 +182,6 @@ $font-sizes: ( $container-max-width-sm: 925px; $container-max-width-md: 1440px; $container-max-width-lg: 1920px; - $container-gutter-width: 30px; :root { @@ -208,7 +205,7 @@ $transition-duration: 0.08s; // Header // -------------------------------------------------------------------------- // -$header-height: 87px; +$header-height: 86px; :root { --header-height: #{$header-height}; @@ -218,7 +215,7 @@ $header-height: 87px; // Footer // -------------------------------------------------------------------------- // -$footer-height: 240px; +$footer-height: 71px; :root { --footer-height: #{$footer-height}; diff --git a/libs/agora/teams/src/lib/teams.routes.ts b/libs/agora/teams/src/lib/teams.routes.ts index ecbd98cec1..d05061ebfa 100644 --- a/libs/agora/teams/src/lib/teams.routes.ts +++ b/libs/agora/teams/src/lib/teams.routes.ts @@ -1,4 +1,4 @@ import { Routes } from '@angular/router'; import { TeamsComponent } from './teams.component'; -export const teamsRoutes: Routes = [{ path: '', component: TeamsComponent }]; +export const routes: Routes = [{ path: '', component: TeamsComponent }]; diff --git a/libs/agora/ui/src/index.ts b/libs/agora/ui/src/index.ts index fe818652cc..ed2d1610fc 100644 --- a/libs/agora/ui/src/index.ts +++ b/libs/agora/ui/src/index.ts @@ -1,6 +1,4 @@ export * from './lib/components/header/header.component'; export * from './lib/components/footer/footer.component'; -export * from './lib/components/loading-icon/loading-icon.component'; -export * from './lib/components/modal-link/modal-link.component'; -export * from './lib/components/svg-icon/svg-icon.component'; +export * from './lib/components/loading-overlay/loading-overlay.component'; export * from './lib/components/svg-image/svg-image.component'; diff --git a/libs/agora/ui/src/lib/components/footer/footer.component.html b/libs/agora/ui/src/lib/components/footer/footer.component.html index 0ab9d7f75b..14358179e3 100644 --- a/libs/agora/ui/src/lib/components/footer/footer.component.html +++ b/libs/agora/ui/src/lib/components/footer/footer.component.html @@ -26,7 +26,7 @@
} diff --git a/libs/agora/ui/src/lib/components/footer/footer.component.ts b/libs/agora/ui/src/lib/components/footer/footer.component.ts index 9f1ad59149..e5248787f0 100644 --- a/libs/agora/ui/src/lib/components/footer/footer.component.ts +++ b/libs/agora/ui/src/lib/components/footer/footer.component.ts @@ -7,11 +7,13 @@ import { SafeUrl } from '@angular/platform-browser'; import { PathSanitizer } from '@sagebionetworks/agora/util'; import { ConfigService } from '@sagebionetworks/agora/config'; import { NavigationLink } from '../../models/navigation-link'; +import { GitHubService } from '@sagebionetworks/agora/services'; @Component({ selector: 'agora-footer', standalone: true, imports: [CommonModule, RouterModule], + providers: [DataversionService, GitHubService], templateUrl: './footer.component.html', styleUrls: ['./footer.component.scss'], }) @@ -19,9 +21,11 @@ export class FooterComponent implements OnInit { configService = inject(ConfigService); dataVersionService = inject(DataversionService); sanitizer = inject(PathSanitizer); + gitHubService = inject(GitHubService); footerLogoPath!: SafeUrl; dataVersion$!: Observable; + sha$!: Observable; navItems: Array = [ { @@ -46,6 +50,7 @@ export class FooterComponent implements OnInit { ngOnInit(): void { this.dataVersion$ = this.dataVersionService.getDataversion(); + this.sha$ = this.gitHubService.getCommitSHA('agora/v0.0.2'); } getSiteVersion() { diff --git a/libs/agora/ui/src/lib/components/header/header.component.spec.ts b/libs/agora/ui/src/lib/components/header/header.component.spec.ts.off similarity index 100% rename from libs/agora/ui/src/lib/components/header/header.component.spec.ts rename to libs/agora/ui/src/lib/components/header/header.component.spec.ts.off diff --git a/libs/agora/ui/src/lib/components/loading-overlay/loading-overlay.component.html b/libs/agora/ui/src/lib/components/loading-overlay/loading-overlay.component.html new file mode 100644 index 0000000000..0f77ede2fa --- /dev/null +++ b/libs/agora/ui/src/lib/components/loading-overlay/loading-overlay.component.html @@ -0,0 +1,5 @@ +
+
+ +
+
diff --git a/libs/agora/ui/src/lib/components/loading-overlay/loading-overlay.component.scss b/libs/agora/ui/src/lib/components/loading-overlay/loading-overlay.component.scss new file mode 100644 index 0000000000..101671d7b8 --- /dev/null +++ b/libs/agora/ui/src/lib/components/loading-overlay/loading-overlay.component.scss @@ -0,0 +1,33 @@ +@import 'libs/agora/styles/src/lib/variables'; +@import 'libs/agora/styles/src/lib/mixins'; + +.loading-overlay { + background-color: #fff; + opacity: 0; + visibility: hidden; + transition: var(--transition-duration); + + .loading-overlay-inner { + display: flex; + padding: 30px; + align-items: center; + justify-content: center; + box-sizing: border-box; + } + + &.global { + position: fixed; + inset: 0; + z-index: 9999; + + .loading-overlay-inner { + width: 100%; + height: 100%; + } + } + + &.active { + opacity: 1; + visibility: visible; + } +} diff --git a/libs/agora/ui/src/lib/components/loading-overlay/loading-overlay.component.spec.ts b/libs/agora/ui/src/lib/components/loading-overlay/loading-overlay.component.spec.ts new file mode 100644 index 0000000000..d6d24abb52 --- /dev/null +++ b/libs/agora/ui/src/lib/components/loading-overlay/loading-overlay.component.spec.ts @@ -0,0 +1,36 @@ +// -------------------------------------------------------------------------- // +// External +// -------------------------------------------------------------------------- // +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; + +// -------------------------------------------------------------------------- // +// Internal +// -------------------------------------------------------------------------- // +import { LoadingOverlayComponent } from './loading-overlay.component'; +import { HelperService } from '@sagebionetworks/agora/services'; + +// -------------------------------------------------------------------------- // +// Tests +// -------------------------------------------------------------------------- // +describe('Component: Loading Overlay', () => { + let component: LoadingOverlayComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [], + providers: [HelperService, provideRouter([])], + }).compileComponents(); + }); + + beforeEach(async () => { + fixture = TestBed.createComponent(LoadingOverlayComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/agora/ui/src/lib/components/loading-overlay/loading-overlay.component.ts b/libs/agora/ui/src/lib/components/loading-overlay/loading-overlay.component.ts new file mode 100644 index 0000000000..ea7221ee6a --- /dev/null +++ b/libs/agora/ui/src/lib/components/loading-overlay/loading-overlay.component.ts @@ -0,0 +1,27 @@ +import { Component, inject, Input, OnInit } from '@angular/core'; +import { HelperService } from '@sagebionetworks/agora/services'; +import { CommonModule } from '@angular/common'; +import { LoadingIconComponent } from '@sagebionetworks/agora/shared'; + +@Component({ + selector: 'agora-loading-overlay', + standalone: true, + imports: [CommonModule, LoadingIconComponent], + providers: [HelperService], + templateUrl: './loading-overlay.component.html', + styleUrls: ['./loading-overlay.component.scss'], +}) +export class LoadingOverlayComponent implements OnInit { + helperService = inject(HelperService); + + @Input() isGlobal = false; + @Input() isActive = false; + + ngOnInit() { + if (this.isGlobal) { + this.helperService.loadingChange.subscribe(() => { + this.isActive = this.helperService.getLoading(); + }); + } + } +} diff --git a/libs/agora/util/src/index.ts b/libs/agora/util/src/index.ts index 370435d848..545026ab3c 100644 --- a/libs/agora/util/src/index.ts +++ b/libs/agora/util/src/index.ts @@ -1,2 +1,3 @@ +export * from './lib/app-helpers'; export * from './lib/default-seo-data'; export * from './lib/path-sanitizer'; diff --git a/libs/agora/wiki/src/index.ts b/libs/agora/wiki/src/index.ts deleted file mode 100644 index 8467520992..0000000000 --- a/libs/agora/wiki/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './lib/wiki.routes'; diff --git a/libs/agora/wiki/src/lib/wiki.routes.ts b/libs/agora/wiki/src/lib/wiki.routes.ts deleted file mode 100644 index e54d346825..0000000000 --- a/libs/agora/wiki/src/lib/wiki.routes.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Routes } from '@angular/router'; -import { WikiComponent } from './wiki.component'; - -export const routes: Routes = [{ path: '', component: WikiComponent }]; diff --git a/libs/agora/wiki/src/test-setup.ts b/libs/agora/wiki/src/test-setup.ts deleted file mode 100644 index 7f54d0e166..0000000000 --- a/libs/agora/wiki/src/test-setup.ts +++ /dev/null @@ -1,20 +0,0 @@ -import 'jest-preset-angular/setup-jest'; - -import { server } from '@sagebionetworks/agora/testing'; - -beforeAll(() => { - // Enable API mocking before all the tests. - server.listen(); -}); - -afterEach(() => { - // Reset the request handlers between each test. - // This way the handlers we add on a per-test basis - // do not leak to other, irrelevant tests. - server.resetHandlers(); -}); - -afterAll(() => { - // Finally, disable API mocking after the tests are done. - server.close(); -}); diff --git a/libs/shared/typescript/charts-angular/src/lib/boxplot/boxplot.directive.stories.ts b/libs/shared/typescript/charts-angular/src/lib/boxplot/boxplot.directive.stories.ts index c46267139a..0e38da2d2a 100644 --- a/libs/shared/typescript/charts-angular/src/lib/boxplot/boxplot.directive.stories.ts +++ b/libs/shared/typescript/charts-angular/src/lib/boxplot/boxplot.directive.stories.ts @@ -12,9 +12,9 @@ const meta: Meta = { component: BoxplotDirective, title: 'directives/sageBoxplot', argTypes: { - pointTooltipFormatter: { - control: { type: 'function' }, - }, + // pointTooltipFormatter: { + // control: { type: 'function' }, + // }, }, render: (args: BoxplotProps) => ({ props: args, diff --git a/package.json b/package.json index 7bdd316e4d..bf0924c1ee 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,8 @@ "@angular/router": "18.2.5", "@angular/ssr": "18.2.5", "@cdktf/provider-aws": "14.0.2", + "@fortawesome/angular-fontawesome": "1.0.0", + "@fortawesome/free-solid-svg-icons": "6.7.1", "@storybook/addon-interactions": "^8.2.8", "@swc/helpers": "~0.5.11", "angular-google-tag-manager": "1.8.0", @@ -34,15 +36,20 @@ "commander": "9.4.1", "constructs": "10.2.13", "core-js": "3.36.1", + "crossfilter2": "1.5.4", "d3": "7.9.0", + "dc": "4.2.7", "debug": "4.3.7", + "dom-to-image-more": "3.5.0", "express": "~4.18.2", + "file-saver": "2.0.5", "glob": "11.0.0", "json5": "2.2.3", "lodash": "4.17.21", "mariadb": "3.3.1", "mongoose": "^8.4.4", "ngx-avatars": "1.5.0", + "ngx-cookie-service": "18.0.0", "ngx-countup": "13.1.0", "ngx-typed-js": "2.1.1", "node-cache": "5.1.2", @@ -111,8 +118,11 @@ "@testing-library/jest-dom": "^6.4.5", "@testing-library/react": "15.0.6", "@types/d3": "7.4.3", + "@types/dc": "4.2.5", "@types/debug": "4.1.12", + "@types/dom-to-image": "2.6.7", "@types/express": "4.17.14", + "@types/file-saver": "2.0.7", "@types/jest": "29.5.13", "@types/lodash": "4.14.191", "@types/node": "22.5.1", @@ -153,7 +163,7 @@ "jest-preset-angular": "14.1.1", "jsdom": "~22.1.0", "lint-staged": "15.2.10", - "msw": "2.4.8", + "msw": "2.6.0", "ng-packagr": "18.2.1", "ngx-echarts": "16.0.0", "nock": "13.2.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e3f2a041c..67ec3e6d8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,12 @@ importers: '@cdktf/provider-aws': specifier: 14.0.2 version: 14.0.2(cdktf@0.16.1(constructs@10.2.13))(constructs@10.2.13) + '@fortawesome/angular-fontawesome': + specifier: 1.0.0 + version: 1.0.0(@angular/core@18.2.5(rxjs@7.8.1)(zone.js@0.14.4)) + '@fortawesome/free-solid-svg-icons': + specifier: 6.7.1 + version: 6.7.1 '@storybook/addon-interactions': specifier: ^8.2.8 version: 8.3.2(storybook@8.3.2) @@ -68,15 +74,27 @@ importers: core-js: specifier: 3.36.1 version: 3.36.1 + crossfilter2: + specifier: 1.5.4 + version: 1.5.4 d3: specifier: 7.9.0 version: 7.9.0 + dc: + specifier: 4.2.7 + version: 4.2.7 debug: specifier: 4.3.7 version: 4.3.7(supports-color@8.1.1) + dom-to-image-more: + specifier: 3.5.0 + version: 3.5.0 express: specifier: ~4.18.2 version: 4.18.3 + file-saver: + specifier: 2.0.5 + version: 2.0.5 glob: specifier: 11.0.0 version: 11.0.0 @@ -95,6 +113,9 @@ importers: ngx-avatars: specifier: 1.5.0 version: 1.5.0(@angular/common@18.2.5(@angular/core@18.2.5(rxjs@7.8.1)(zone.js@0.14.4))(rxjs@7.8.1))(@angular/core@18.2.5(rxjs@7.8.1)(zone.js@0.14.4)) + ngx-cookie-service: + specifier: 18.0.0 + version: 18.0.0(@angular/common@18.2.5(@angular/core@18.2.5(rxjs@7.8.1)(zone.js@0.14.4))(rxjs@7.8.1))(@angular/core@18.2.5(rxjs@7.8.1)(zone.js@0.14.4)) ngx-countup: specifier: 13.1.0 version: 13.1.0(@angular/common@18.2.5(@angular/core@18.2.5(rxjs@7.8.1)(zone.js@0.14.4))(rxjs@7.8.1))(@angular/core@18.2.5(rxjs@7.8.1)(zone.js@0.14.4)) @@ -180,12 +201,6 @@ importers: '@chromatic-com/storybook': specifier: 2.0.2 version: 2.0.2(react@18.3.1) - '@fortawesome/angular-fontawesome': - specifier: 0.15.0 - version: 0.15.0(@angular/core@18.2.5(rxjs@7.8.1)(zone.js@0.14.4)) - '@fortawesome/free-solid-svg-icons': - specifier: 6.6.0 - version: 6.6.0 '@nrwl/js': specifier: 19.8.0 version: 19.8.0(@babel/traverse@7.25.6)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.5.4))(@swc/core@1.5.29(@swc/helpers@0.5.12))(@types/node@22.5.1)(debug@4.3.7)(nx@20.1.2(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.5.4))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7))(typescript@5.5.4) @@ -294,12 +309,21 @@ importers: '@types/d3': specifier: 7.4.3 version: 7.4.3 + '@types/dc': + specifier: 4.2.5 + version: 4.2.5 '@types/debug': specifier: 4.1.12 version: 4.1.12 + '@types/dom-to-image': + specifier: 2.6.7 + version: 2.6.7 '@types/express': specifier: 4.17.14 version: 4.17.14 + '@types/file-saver': + specifier: 2.0.7 + version: 2.0.7 '@types/jest': specifier: 29.5.13 version: 29.5.13 @@ -421,8 +445,8 @@ importers: specifier: 15.2.10 version: 15.2.10 msw: - specifier: 2.4.8 - version: 2.4.8(typescript@5.5.4) + specifier: 2.6.0 + version: 2.6.0(@types/node@22.5.1)(typescript@5.5.4) ng-packagr: specifier: 18.2.1 version: 18.2.1(@angular/compiler-cli@18.2.5(@angular/compiler@18.2.5(@angular/core@18.2.5(rxjs@7.8.1)(zone.js@0.14.4)))(typescript@5.5.4))(tailwindcss@3.4.3(ts-node@10.9.1(@swc/core@1.5.29(@swc/helpers@0.5.12))(@types/node@22.5.1)(typescript@5.5.4)))(tslib@2.4.1)(typescript@5.5.4) @@ -521,13 +545,13 @@ importers: dependencies: '@nx/devkit': specifier: 19.8.0 - version: 19.8.0(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7)) + version: 19.8.0(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7)) '@nx/js': specifier: 19.8.0 - version: 19.8.0(@babel/traverse@7.25.6)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(@types/node@22.5.5)(debug@4.3.7)(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7))(typescript@5.8.0-dev.20241125) + version: 19.8.0(@babel/traverse@7.25.6)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(@types/node@22.5.5)(debug@4.3.7)(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7))(typescript@5.8.0-dev.20241204) nx: specifier: 19.8.0 - version: 19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7) + version: 19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7) tslib: specifier: ^2.3.0 version: 2.4.1 @@ -2230,21 +2254,21 @@ packages: resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} - '@fortawesome/angular-fontawesome@0.15.0': - resolution: {integrity: sha512-oxmJDYGNSym5ycFR0LX4ZOPAU+wWmMAznYpkm5DNAtWWkhMLcrZl15eZQmVIEE+qruQ7JiVrg3tpo8bEkFlDgw==} + '@fortawesome/angular-fontawesome@1.0.0': + resolution: {integrity: sha512-EC2fYuXIuw2ld1kzJi+zysWus6OeGGfLQtbh0hW9zyyq5aBo8ZJkcJKBsVQ8E6Mg7nHyTWaXn+sdcXTPDWz+UQ==} peerDependencies: - '@angular/core': ^18.0.0 + '@angular/core': ^19.0.0 - '@fortawesome/fontawesome-common-types@6.6.0': - resolution: {integrity: sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==} + '@fortawesome/fontawesome-common-types@6.7.1': + resolution: {integrity: sha512-gbDz3TwRrIPT3i0cDfujhshnXO9z03IT1UKRIVi/VEjpNHtSBIP2o5XSm+e816FzzCFEzAxPw09Z13n20PaQJQ==} engines: {node: '>=6'} - '@fortawesome/fontawesome-svg-core@6.6.0': - resolution: {integrity: sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==} + '@fortawesome/fontawesome-svg-core@6.7.1': + resolution: {integrity: sha512-8dBIHbfsKlCk2jHQ9PoRBg2Z+4TwyE3vZICSnoDlnsHA6SiMlTwfmW6yX0lHsRmWJugkeb92sA0hZdkXJhuz+g==} engines: {node: '>=6'} - '@fortawesome/free-solid-svg-icons@6.6.0': - resolution: {integrity: sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==} + '@fortawesome/free-solid-svg-icons@6.7.1': + resolution: {integrity: sha512-BTKc0b0mgjWZ2UDKVgmwaE0qt0cZs6ITcDgjrti5f/ki7aF5zs+N91V6hitGo3TItCFtnKg6cUVGdTmBFICFRg==} engines: {node: '>=6'} '@humanwhocodes/config-array@0.11.14': @@ -2275,6 +2299,16 @@ packages: resolution: {integrity: sha512-oOIwPs0Dvq5220Z8lGL/6LHRTEr9TgLHmiI99Rj1PJ1p1czTys+olrgBqZk4E2qC0YTzeHprxSQmoHioVdJ7Lw==} engines: {node: '>=18'} + '@inquirer/confirm@5.0.2': + resolution: {integrity: sha512-KJLUHOaKnNCYzwVbryj3TNBxyZIrr56fR5N45v6K9IPrbT6B7DcudBMfylkV1A8PUdJE15mybkEQyp2/ZUpxUA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + + '@inquirer/core@10.1.0': + resolution: {integrity: sha512-I+ETk2AL+yAVbvuKx5AJpQmoaWhpiTFOg/UJb7ZkMAK4blmtG8ATh5ct+T/8xNld0CZG/2UhtkdMwpgvld92XQ==} + engines: {node: '>=18'} + '@inquirer/core@9.2.1': resolution: {integrity: sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg==} engines: {node: '>=18'} @@ -2291,6 +2325,10 @@ packages: resolution: {integrity: sha512-yfZzps3Cso2UbM7WlxKwZQh2Hs6plrbjs1QnzQDZhK2DgyCo6D8AaHps9olkNcUFlcYERMqU3uJSp1gmy3s/qQ==} engines: {node: '>=18'} + '@inquirer/figures@1.0.8': + resolution: {integrity: sha512-tKd+jsmhq21AP1LhexC0pPwsCxEhGgAkg28byjJAd+xhmIs8LUX8JbUc3vBf3PhLxWiB5EvyBE5X7JSPAqMAqg==} + engines: {node: '>=18'} + '@inquirer/input@2.3.0': resolution: {integrity: sha512-XfnpCStx2xgh1LIRqPXrTNEEByqQWoxsWYzNRSEUxJ5c6EQlhMogJ3vHKu8aXuTacebtaZzMAHwEL0kAflKOBw==} engines: {node: '>=18'} @@ -2327,6 +2365,12 @@ packages: resolution: {integrity: sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==} engines: {node: '>=18'} + '@inquirer/type@3.0.1': + resolution: {integrity: sha512-+ksJMIy92sOAiAccGpcKZUc3bYO07cADnscIxHBknEm3uNts3movSmBofc1908BNy5edKscxYeAdaX1NXkHS6A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -2675,8 +2719,8 @@ packages: cpu: [x64] os: [win32] - '@mswjs/interceptors@0.35.7': - resolution: {integrity: sha512-F8Rvqdr4we4i+G+NsveZ1hX87T0MvPxhRxxEEI35kpIKVdNxy/uu5qHQb3hwWixHXrIWQoFdgLAqUjeZFFztcg==} + '@mswjs/interceptors@0.36.10': + resolution: {integrity: sha512-GXrJgakgJW3DWKueebkvtYgGKkxA7s0u5B0P5syJM5rvQUnrpLPigvci8Hukl7yEM+sU06l+er2Fgvx/gmiRgg==} engines: {node: '>=18'} '@napi-rs/wasm-runtime@0.2.4': @@ -3133,6 +3177,9 @@ packages: '@prettier/plugin-xml@2.2.0': resolution: {integrity: sha512-UWRmygBsyj4bVXvDiqSccwT1kmsorcwQwaIy30yVh8T+Gspx4OlC0shX1y+ZuwXZvgnafmpRYKks0bAu9urJew==} + '@ranfdev/deepobj@1.0.2': + resolution: {integrity: sha512-FM3y6kfJaj5MCoAjdv24EDCTDbuFz+4+pgAunbjYfugwIE4O/xx8mPNji1n/ouG8pHCntSnBr1xwTOensF23Gg==} + '@redocly/ajv@8.11.2': resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} @@ -4005,102 +4052,201 @@ packages: '@types/cors@2.8.17': resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==} + '@types/d3-array@2.12.7': + resolution: {integrity: sha512-SVvxzxRVnIgtJbNTj5ZVJ9CZkVOANCpW0nQbRi7EOU5Q9G+JQQjXD2SCpr1OYCX09b3Yr7o0+CBofZAgU42rbQ==} + '@types/d3-array@3.2.1': resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} + '@types/d3-axis@2.1.6': + resolution: {integrity: sha512-X/CazlQun7XcSbRhaxwr605neUIGiUeURvsOGAIdvH1nD6o25pzkdxPNe7XcTKyRJeShlubjsUEG9tNeZZdRaQ==} + '@types/d3-axis@3.0.6': resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + '@types/d3-brush@2.1.5': + resolution: {integrity: sha512-ycizd1l+vIceUIO+JA6HAjivlXSGlDbqKXe4Q8cjUPtY/NMkz6CvpcBqzLPRa9iMDqRnUQHwSIEakb0sX+PM2A==} + '@types/d3-brush@3.0.6': resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + '@types/d3-chord@2.0.6': + resolution: {integrity: sha512-PTZyfJ7z9Ttl7joKRfyBl0icMYAMRj4n5trsE9Iinipp8Fe0DlwK6xwboWWMTEaj6Vzko68brnpvpoDl4qAKwA==} + '@types/d3-chord@3.0.6': resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + '@types/d3-color@2.0.6': + resolution: {integrity: sha512-tbaFGDmJWHqnenvk3QGSvD3RVwr631BjKRD7Sc7VLRgrdX5mk5hTyoeBL6rXZaeoXzmZwIl1D2HPogEdt1rHBg==} + '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + '@types/d3-contour@2.0.7': + resolution: {integrity: sha512-oJNOYtQzKY+04lhEr4aTnW2IrCVK5jiF2YMJf687HV5dIGsOgM8Xc15uSuu9zu4FYOJJ/FTqVaspHFR9pxVNTg==} + '@types/d3-contour@3.0.6': resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + '@types/d3-delaunay@5.3.4': + resolution: {integrity: sha512-GEQuDXVKQvHulQ+ecKyCubOmVjXrifAj7VR26rWVAER/IbWemaT/Tmo84ESiTtoDghg5ILdMZH7pYXQEt/Vu9A==} + '@types/d3-delaunay@6.0.4': resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + '@types/d3-dispatch@2.0.4': + resolution: {integrity: sha512-63uJJO3Eflu1tYXjD+Gmkk5Bc/ribIWyCnOfAY+WB9ihBw7Tdd1IRKZ34ASxy+Dzlg+lOT5+ZHCSZw0V+UNAEQ==} + '@types/d3-dispatch@3.0.6': resolution: {integrity: sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==} + '@types/d3-drag@2.0.5': + resolution: {integrity: sha512-VbvN7t3TelH6R0cKVXkOXmDiC7pRhtoodiPZ94p0n9TayGqg0Z/5vSxsPelVsZyVzloEo2kdZ7BO1n9ezWux+w==} + '@types/d3-drag@3.0.7': resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + '@types/d3-dsv@2.0.9': + resolution: {integrity: sha512-mjbmiSz8p7rCCyan4Ai6Rxqtp4MW447RfyKPfE1VaFl61l/nkLsFObF26X279eQMjHqGKDI2kdx26qEdkLAVBQ==} + '@types/d3-dsv@3.0.7': resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + '@types/d3-ease@2.0.4': + resolution: {integrity: sha512-DGh1MzShlCPTTau4+C8JLJjKt9sT9LgGZokYFx8fSxy+Z6fHns/Lc+lwTc4owuq8FwCDg7Mw2/mp0G8S5DBm7Q==} + '@types/d3-ease@3.0.2': resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + '@types/d3-fetch@2.0.5': + resolution: {integrity: sha512-azKhvVVUbAK6sJy22b9t8TtsGmPlauU9aGVLSP5cGYSWMCbtMRf3nGz58Eu+UgP1u3VEK+12JVc4HB1MMeVaSg==} + '@types/d3-fetch@3.0.7': resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + '@types/d3-force@2.1.7': + resolution: {integrity: sha512-x5pvWw0HUBrcpMaMOd70ICEL27gOeC9hyhilTc+OP+4tErgEg3w+fZWA475eTrG7gi8BB0TNdfGRprpy09Vo9A==} + '@types/d3-force@3.0.10': resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + '@types/d3-format@2.0.5': + resolution: {integrity: sha512-ntJZQfz4BK8m53vkUVk+3PE7PHr9esrfVkClxebcMNP/4N1F0rPdzv9hKNqx2gZBRHSYg1kQumeUDIrHDpQGwQ==} + '@types/d3-format@3.0.4': resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + '@types/d3-geo@2.0.7': + resolution: {integrity: sha512-RIXlxPdxvX+LAZFv+t78CuYpxYag4zuw9mZc+AwfB8tZpKU90rMEn2il2ADncmeZlb7nER9dDsJpRisA3lRvjA==} + '@types/d3-geo@3.1.0': resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + '@types/d3-hierarchy@2.0.5': + resolution: {integrity: sha512-t/xXqB6MXT6Hp0BgFV00ZonpZbs9fUtYPM3QzqOlmghefovpnnxEN7mAdUqE/mNinRI/eR8gewDAobFJA0TNBw==} + '@types/d3-hierarchy@3.1.7': resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + '@types/d3-interpolate@2.0.5': + resolution: {integrity: sha512-UINE41RDaUMbulp+bxQMDnhOi51rh5lA2dG+dWZU0UY/IwQiG/u2x8TfnWYU9+xwGdXsJoAvrBYUEQl0r91atg==} + '@types/d3-interpolate@3.0.4': resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + '@types/d3-path@2.0.4': + resolution: {integrity: sha512-jjZVLBjEX4q6xneKMmv62UocaFJFOTQSb/1aTzs3m3ICTOFoVaqGBHpNLm/4dVi0/FTltfBKgmOK1ECj3/gGjA==} + '@types/d3-path@3.1.0': resolution: {integrity: sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==} + '@types/d3-polygon@2.0.3': + resolution: {integrity: sha512-4hwYYp/KDSNDdBFhf08SifGD7YJgMyUuDulnMsAGVi9X2w5QvdB47wlXMiJr+rdiBKALMq3VJ/i8qKy9gUzCbg==} + '@types/d3-polygon@3.0.2': resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + '@types/d3-quadtree@2.0.6': + resolution: {integrity: sha512-PsbDucsVzy1tSX+y9MEqosOk3gChbolcw7QWdR87Bo/T1iwjZg8AZ0E8d1swxsNBt7cAKF/ISk0SDJNd95bMMw==} + '@types/d3-quadtree@3.0.6': resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + '@types/d3-random@2.2.3': + resolution: {integrity: sha512-Ghs4R3CcgJ3o6svszRzIH4b8PPYex/COo+rhhZjDAs+bVducXwjmVSi27WcDOaLLCBV2t3tfVH9bYXAL76IvQA==} + '@types/d3-random@3.0.3': resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + '@types/d3-scale-chromatic@2.0.4': + resolution: {integrity: sha512-OUgfg6wmoZVhs0/pV8HZhsMw7pYJnS6smfNK2S5ogMaPHfDUaTMu7JA5ssZrRupwf2vWI+haPAuUpsz+M1BOKA==} + '@types/d3-scale-chromatic@3.0.3': resolution: {integrity: sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==} + '@types/d3-scale@3.3.5': + resolution: {integrity: sha512-YOpKj0kIEusRf7ofeJcSZQsvKbnTwpe1DUF+P2qsotqG53kEsjm7EzzliqQxMkAWdkZcHrg5rRhB4JiDOQPX+A==} + '@types/d3-scale@4.0.8': resolution: {integrity: sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==} + '@types/d3-selection@2.0.5': + resolution: {integrity: sha512-71BorcY0yXl12S7lvb01JdaN9TpeUHBDb4RRhSq8U8BEkX/nIk5p7Byho+ZRTsx5nYLMpAbY3qt5EhqFzfGJlw==} + '@types/d3-selection@3.0.10': resolution: {integrity: sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg==} + '@types/d3-shape@2.1.7': + resolution: {integrity: sha512-HedHlfGHdwzKqX9+PiQVXZrdmGlwo7naoefJP7kCNk4Y7qcpQt1tUaoRa6qn0kbTdlaIHGO7111qLtb/6J8uuw==} + '@types/d3-shape@3.1.6': resolution: {integrity: sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==} + '@types/d3-time-format@3.0.4': + resolution: {integrity: sha512-or9DiDnYI1h38J9hxKEsw513+KVuFbEVhl7qdxcaudoiqWWepapUen+2vAriFGexr6W5+P4l9+HJrB39GG+oRg==} + '@types/d3-time-format@4.0.3': resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + '@types/d3-time@2.1.4': + resolution: {integrity: sha512-BTfLsxTeo7yFxI/haOOf1ZwJ6xKgQLT9dCp+EcmQv87Gox6X+oKl4mLKfO6fnWm3P22+A6DknMNEZany8ql2Rw==} + '@types/d3-time@3.0.3': resolution: {integrity: sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==} + '@types/d3-timer@2.0.3': + resolution: {integrity: sha512-jhAJzaanK5LqyLQ50jJNIrB8fjL9gwWZTgYjevPvkDLMU+kTAZkYsobI59nYoeSrH1PucuyJEi247Pb90t6XUg==} + '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/d3-transition@2.0.6': + resolution: {integrity: sha512-bbqOUh3Jcd9NmUxGLPqlynhNgwJO/Ic1kWl00k1IJ3vAxMYmrczi8kzlAL3UPCBmtiGN4B/2tATkbLDYZzyHww==} + '@types/d3-transition@3.0.8': resolution: {integrity: sha512-ew63aJfQ/ms7QQ4X7pk5NxQ9fZH/z+i24ZfJ6tJSfqxJMrYLiK01EAs2/Rtw/JreGUsS3pLPNV644qXFGnoZNQ==} + '@types/d3-zoom@2.0.7': + resolution: {integrity: sha512-JWke4E8ZyrKUQ68ESTWSK16fVb0OYnaiJ+WXJRYxKLn4aXU0o4CLYxMWBEiouUfO3TTCoyroOrGPcBG6u1aAxA==} + '@types/d3-zoom@3.0.8': resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/d3@6.7.8': + resolution: {integrity: sha512-hlPt5L0wvDzeZx9VfLdgLJ3Yr+/bAWY0ECjN88Grx7EZaDHKui+2YZXGMB2IMZMerJM+WLwoZ5pOTPHSutGEEw==} + '@types/d3@7.4.3': resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/dc@4.2.5': + resolution: {integrity: sha512-5ExeYjqMIDV+LSASfPiiCPBzBrPldy0qvIKLKj4zI0Fdoknm3P6X2Pftu8CMfEOK0o8c5Igr9i2iQWfu53HtnQ==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/dom-to-image@2.6.7': + resolution: {integrity: sha512-me5VbCv+fcXozblWwG13krNBvuEOm6kA5xoa4RrjDJCNFOZSWR3/QLtOXimBHk1Fisq69Gx3JtOoXtg1N1tijg==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -4119,6 +4265,9 @@ packages: '@types/express@4.17.21': resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} + '@types/file-saver@2.0.7': + resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==} + '@types/geojson@7946.0.14': resolution: {integrity: sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==} @@ -5756,6 +5905,9 @@ packages: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} + crossfilter2@1.5.4: + resolution: {integrity: sha512-oOGqOM0RocwQFOXJnEaUKqYV6Mc1TNCRv3LrNUa0QlofQTutGAXyQaLW1aGKLls2sfnbwBEtsa6tPD3jY+ycqQ==} + css-blank-pseudo@3.0.3: resolution: {integrity: sha512-VS90XWtsHGqoM0t4KpH053c4ehxZ2E6HtGI7x68YFV0pTo/QmkV/YFA+NnlvK8guxZVNWGQhVNJGC39Q8XF4OQ==} engines: {node: ^12 || ^14 || >=16} @@ -5922,129 +6074,225 @@ packages: engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0} hasBin: true + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + d3-array@3.2.4: resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} engines: {node: '>=12'} + d3-axis@2.1.0: + resolution: {integrity: sha512-z/G2TQMyuf0X3qP+Mh+2PimoJD41VOCjViJzT0BHeL/+JQAofkiWZbWxlwFGb1N8EN+Cl/CW+MUKbVzr1689Cw==} + d3-axis@3.0.0: resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} engines: {node: '>=12'} + d3-brush@2.1.0: + resolution: {integrity: sha512-cHLLAFatBATyIKqZOkk/mDHUbzne2B3ZwxkzMHvFTCZCmLaXDpZRihQSn8UNXTkGD/3lb/W2sQz0etAftmHMJQ==} + d3-brush@3.0.0: resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} engines: {node: '>=12'} + d3-chord@2.0.0: + resolution: {integrity: sha512-D5PZb7EDsRNdGU4SsjQyKhja8Zgu+SHZfUSO5Ls8Wsn+jsAKUUGkcshLxMg9HDFxG3KqavGWaWkJ8EpU8ojuig==} + d3-chord@3.0.1: resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} engines: {node: '>=12'} + d3-color@2.0.0: + resolution: {integrity: sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==} + d3-color@3.1.0: resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} engines: {node: '>=12'} + d3-contour@2.0.0: + resolution: {integrity: sha512-9unAtvIaNk06UwqBmvsdHX7CZ+NPDZnn8TtNH1myW93pWJkhsV25JcgnYAu0Ck5Veb1DHiCv++Ic5uvJ+h50JA==} + d3-contour@4.0.2: resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} engines: {node: '>=12'} + d3-delaunay@5.3.0: + resolution: {integrity: sha512-amALSrOllWVLaHTnDLHwMIiz0d1bBu9gZXd1FiLfXf8sHcX9jrcj81TVZOqD4UX7MgBZZ07c8GxzEgBpJqc74w==} + d3-delaunay@6.0.4: resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} engines: {node: '>=12'} + d3-dispatch@2.0.0: + resolution: {integrity: sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==} + d3-dispatch@3.0.1: resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} engines: {node: '>=12'} + d3-drag@2.0.0: + resolution: {integrity: sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==} + d3-drag@3.0.0: resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} engines: {node: '>=12'} + d3-dsv@2.0.0: + resolution: {integrity: sha512-E+Pn8UJYx9mViuIUkoc93gJGGYut6mSDKy2+XaPwccwkRGlR+LO97L2VCCRjQivTwLHkSnAJG7yo00BWY6QM+w==} + hasBin: true + d3-dsv@3.0.1: resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} engines: {node: '>=12'} hasBin: true + d3-ease@2.0.0: + resolution: {integrity: sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==} + d3-ease@3.0.1: resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} engines: {node: '>=12'} + d3-fetch@2.0.0: + resolution: {integrity: sha512-TkYv/hjXgCryBeNKiclrwqZH7Nb+GaOwo3Neg24ZVWA3MKB+Rd+BY84Nh6tmNEMcjUik1CSUWjXYndmeO6F7sw==} + d3-fetch@3.0.1: resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} engines: {node: '>=12'} + d3-force@2.1.1: + resolution: {integrity: sha512-nAuHEzBqMvpFVMf9OX75d00OxvOXdxY+xECIXjW6Gv8BRrXu6gAWbv/9XKrvfJ5i5DCokDW7RYE50LRoK092ew==} + d3-force@3.0.0: resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} engines: {node: '>=12'} + d3-format@2.0.0: + resolution: {integrity: sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA==} + d3-format@3.1.0: resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} engines: {node: '>=12'} + d3-geo@2.0.2: + resolution: {integrity: sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==} + d3-geo@3.1.1: resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} engines: {node: '>=12'} + d3-hierarchy@2.0.0: + resolution: {integrity: sha512-SwIdqM3HxQX2214EG9GTjgmCc/mbSx4mQBn+DuEETubhOw6/U3fmnji4uCVrmzOydMHSO1nZle5gh6HB/wdOzw==} + d3-hierarchy@3.1.2: resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} engines: {node: '>=12'} + d3-interpolate@2.0.1: + resolution: {integrity: sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==} + d3-interpolate@3.0.1: resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} engines: {node: '>=12'} + d3-path@2.0.0: + resolution: {integrity: sha512-ZwZQxKhBnv9yHaiWd6ZU4x5BtCQ7pXszEV9CU6kRgwIQVQGLMv1oiL4M+MK/n79sYzsj+gcgpPQSctJUsLN7fA==} + d3-path@3.1.0: resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} engines: {node: '>=12'} + d3-polygon@2.0.0: + resolution: {integrity: sha512-MsexrCK38cTGermELs0cO1d79DcTsQRN7IWMJKczD/2kBjzNXxLUWP33qRF6VDpiLV/4EI4r6Gs0DAWQkE8pSQ==} + d3-polygon@3.0.1: resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} engines: {node: '>=12'} + d3-quadtree@2.0.0: + resolution: {integrity: sha512-b0Ed2t1UUalJpc3qXzKi+cPGxeXRr4KU9YSlocN74aTzp6R/Ud43t79yLLqxHRWZfsvWXmbDWPpoENK1K539xw==} + d3-quadtree@3.0.1: resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} engines: {node: '>=12'} + d3-random@2.2.2: + resolution: {integrity: sha512-0D9P8TRj6qDAtHhRQn6EfdOtHMfsUWanl3yb/84C4DqpZ+VsgfI5iTVRNRbELCfNvRfpMr8OrqqUTQ6ANGCijw==} + d3-random@3.0.1: resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} engines: {node: '>=12'} + d3-scale-chromatic@2.0.0: + resolution: {integrity: sha512-LLqy7dJSL8yDy7NRmf6xSlsFZ6zYvJ4BcWFE4zBrOPnQERv9zj24ohnXKRbyi9YHnYV+HN1oEO3iFK971/gkzA==} + d3-scale-chromatic@3.1.0: resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} engines: {node: '>=12'} + d3-scale@3.3.0: + resolution: {integrity: sha512-1JGp44NQCt5d1g+Yy+GeOnZP7xHo0ii8zsQp6PGzd+C1/dl0KGsp9A7Mxwp+1D1o4unbTTxVdU/ZOIEBoeZPbQ==} + d3-scale@4.0.2: resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} engines: {node: '>=12'} + d3-selection@2.0.0: + resolution: {integrity: sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==} + d3-selection@3.0.0: resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} engines: {node: '>=12'} + d3-shape@2.1.0: + resolution: {integrity: sha512-PnjUqfM2PpskbSLTJvAzp2Wv4CZsnAgTfcVRTwW03QR3MkXF8Uo7B1y/lWkAsmbKwuecto++4NlsYcvYpXpTHA==} + d3-shape@3.2.0: resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} engines: {node: '>=12'} + d3-time-format@3.0.0: + resolution: {integrity: sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==} + d3-time-format@4.1.0: resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} engines: {node: '>=12'} + d3-time@2.1.1: + resolution: {integrity: sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==} + d3-time@3.1.0: resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} engines: {node: '>=12'} + d3-timer@2.0.0: + resolution: {integrity: sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==} + d3-timer@3.0.1: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + d3-transition@2.0.0: + resolution: {integrity: sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==} + peerDependencies: + d3-selection: '2' + d3-transition@3.0.1: resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} engines: {node: '>=12'} peerDependencies: d3-selection: 2 - 3 + d3-zoom@2.0.0: + resolution: {integrity: sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw==} + d3-zoom@3.0.0: resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} engines: {node: '>=12'} + d3@6.7.0: + resolution: {integrity: sha512-hNHRhe+yCDLUG6Q2LwvR/WdNFPOJQ5VWqsJcwIYVeI401+d2/rrCjxSXkiAdIlpx7/73eApFB4Olsmh3YN7a6g==} + d3@7.9.0: resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} engines: {node: '>=12'} @@ -6091,6 +6339,9 @@ packages: dayjs@1.11.11: resolution: {integrity: sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg==} + dc@4.2.7: + resolution: {integrity: sha512-83vzVpBmXFCW7V2uVhBolcPX22d19k4GV7zJWu8jkyNhMstAb/XYl2Eld3Kp71eLD8YM9RsIRgtrmLViQeZTuQ==} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -6229,6 +6480,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delaunator@4.0.1: + resolution: {integrity: sha512-WNPWi1IRKZfCt/qIDMfERkDp93+iZEmOxN2yy4Jg+Xhv8SLk2UTqqbe1sfiipn0and9QrE914/ihdx82Y/Giag==} + delaunator@5.0.1: resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} @@ -6349,6 +6603,9 @@ packages: dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dom-to-image-more@3.5.0: + resolution: {integrity: sha512-VF/vwfHsPNMHJb5W/5sAmco3UIlEWSEFLppInQwqwN4joUvBULDwE3CqVcUDkUWleke/nZ5KwIVSrrFlGw7WPA==} + domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} @@ -7047,6 +7304,9 @@ packages: peerDependencies: webpack: ^4.0.0 || ^5.0.0 + file-saver@2.0.5: + resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==} + file-type@16.5.4: resolution: {integrity: sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==} engines: {node: '>=10'} @@ -7854,6 +8114,9 @@ packages: resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} engines: {node: '>= 0.4'} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + internmap@2.0.3: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} @@ -9251,8 +9514,8 @@ packages: msgpackr@1.11.0: resolution: {integrity: sha512-I8qXuuALqJe5laEBYoFykChhSXLikZmUhccjGsPuSJ/7uPip2TJ7lwdIQwWSAi0jGZDXv4WOP8Qg65QZRuXxXw==} - msw@2.4.8: - resolution: {integrity: sha512-a+FUW1m5yT8cV9GBy0L/cbNg0EA4//SKEzgu3qFrpITrWYeZmqfo7dqtM74T2lAl69jjUjjCaEhZKaxG2Ns8DA==} + msw@2.6.0: + resolution: {integrity: sha512-n3tx2w0MZ3H4pxY0ozrQ4sNPzK/dGtlr2cIIyuEsgq2Bhy4wvcW6ZH2w/gXM9+MEUY6HC1fWhqtcXDxVZr5Jxw==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -9272,6 +9535,10 @@ packages: resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -9337,6 +9604,12 @@ packages: '@angular/common': ^15.0.0 '@angular/core': ^15.0.0 + ngx-cookie-service@18.0.0: + resolution: {integrity: sha512-hkkUckzZTXXWtFgvVkT2hg6mwYMLXioXDZWBsVCOy9gYkADjsj0N5VViO7eo2izQ0VcMPd/Etog1trf/T4oZMQ==} + peerDependencies: + '@angular/common': ^18.0.0-rc.0 + '@angular/core': ^18.0.0-rc.0 + ngx-countup@13.1.0: resolution: {integrity: sha512-5U1heict3J3F+MTvwhEM3w/j9JuSp4Jqm530yrIzZtQC4huOPCTyZ4ZwBhGk+ZkBkb8HOCcm1xXu90/sydt0Qg==} peerDependencies: @@ -12241,6 +12514,9 @@ packages: tslib@2.6.3: resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsscmp@1.0.6: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} @@ -12346,8 +12622,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@5.8.0-dev.20241125: - resolution: {integrity: sha512-hZERLOYr8LieXCWI1B0nP7c17/OQHWiPAJ0QtpPT5vQ3fSPhWNdHvYQR7sP8JQ0c2hRgQ8ADHD+LdkY5WwdbRw==} + typescript@5.8.0-dev.20241204: + resolution: {integrity: sha512-UK6ysqPu2ZobQlSUBtxyKSUrPQplZgxNeq8TUB3pXmi/OvIcq1Ua5rpQNg07EYgGY2rotPKz7b+L27ZzFRwV/g==} engines: {node: '>=14.17'} hasBin: true @@ -15089,21 +15365,21 @@ snapshots: '@fastify/busboy@2.1.1': {} - '@fortawesome/angular-fontawesome@0.15.0(@angular/core@18.2.5(rxjs@7.8.1)(zone.js@0.14.4))': + '@fortawesome/angular-fontawesome@1.0.0(@angular/core@18.2.5(rxjs@7.8.1)(zone.js@0.14.4))': dependencies: '@angular/core': 18.2.5(rxjs@7.8.1)(zone.js@0.14.4) - '@fortawesome/fontawesome-svg-core': 6.6.0 - tslib: 2.6.3 + '@fortawesome/fontawesome-svg-core': 6.7.1 + tslib: 2.8.1 - '@fortawesome/fontawesome-common-types@6.6.0': {} + '@fortawesome/fontawesome-common-types@6.7.1': {} - '@fortawesome/fontawesome-svg-core@6.6.0': + '@fortawesome/fontawesome-svg-core@6.7.1': dependencies: - '@fortawesome/fontawesome-common-types': 6.6.0 + '@fortawesome/fontawesome-common-types': 6.7.1 - '@fortawesome/free-solid-svg-icons@6.6.0': + '@fortawesome/free-solid-svg-icons@6.7.1': dependencies: - '@fortawesome/fontawesome-common-types': 6.6.0 + '@fortawesome/fontawesome-common-types': 6.7.1 '@humanwhocodes/config-array@0.11.14': dependencies: @@ -15137,6 +15413,26 @@ snapshots: '@inquirer/core': 9.2.1 '@inquirer/type': 1.5.5 + '@inquirer/confirm@5.0.2(@types/node@22.5.1)': + dependencies: + '@inquirer/core': 10.1.0(@types/node@22.5.1) + '@inquirer/type': 3.0.1(@types/node@22.5.1) + '@types/node': 22.5.1 + + '@inquirer/core@10.1.0(@types/node@22.5.1)': + dependencies: + '@inquirer/figures': 1.0.8 + '@inquirer/type': 3.0.1(@types/node@22.5.1) + ansi-escapes: 4.3.2 + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.2 + transitivePeerDependencies: + - '@types/node' + '@inquirer/core@9.2.1': dependencies: '@inquirer/figures': 1.0.6 @@ -15166,6 +15462,8 @@ snapshots: '@inquirer/figures@1.0.6': {} + '@inquirer/figures@1.0.8': {} + '@inquirer/input@2.3.0': dependencies: '@inquirer/core': 9.2.1 @@ -15224,6 +15522,10 @@ snapshots: dependencies: mute-stream: 1.0.0 + '@inquirer/type@3.0.1(@types/node@22.5.1)': + dependencies: + '@types/node': 22.5.1 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -15808,7 +16110,7 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': optional: true - '@mswjs/interceptors@0.35.7': + '@mswjs/interceptors@0.36.10': dependencies: '@open-draft/deferred-promise': 2.2.0 '@open-draft/logger': 0.3.0 @@ -15940,9 +16242,9 @@ snapshots: transitivePeerDependencies: - nx - '@nrwl/devkit@19.8.0(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7))': + '@nrwl/devkit@19.8.0(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7))': dependencies: - '@nx/devkit': 19.8.0(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7)) + '@nx/devkit': 19.8.0(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7)) transitivePeerDependencies: - nx @@ -15967,9 +16269,9 @@ snapshots: - typescript - verdaccio - '@nrwl/js@19.8.0(@babel/traverse@7.25.6)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(@types/node@22.5.5)(debug@4.3.7)(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7))(typescript@5.8.0-dev.20241125)': + '@nrwl/js@19.8.0(@babel/traverse@7.25.6)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(@types/node@22.5.5)(debug@4.3.7)(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7))(typescript@5.8.0-dev.20241204)': dependencies: - '@nx/js': 19.8.0(@babel/traverse@7.25.6)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(@types/node@22.5.5)(debug@4.3.7)(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7))(typescript@5.8.0-dev.20241125) + '@nx/js': 19.8.0(@babel/traverse@7.25.6)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(@types/node@22.5.5)(debug@4.3.7)(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7))(typescript@5.8.0-dev.20241204) transitivePeerDependencies: - '@babel/traverse' - '@swc-node/register' @@ -15991,9 +16293,9 @@ snapshots: - '@swc/core' - debug - '@nrwl/tao@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7)': + '@nrwl/tao@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7)': dependencies: - nx: 19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7) + nx: 19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7) tslib: 2.4.1 transitivePeerDependencies: - '@swc-node/register' @@ -16008,9 +16310,9 @@ snapshots: - '@swc/core' - debug - '@nrwl/workspace@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7)': + '@nrwl/workspace@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7)': dependencies: - '@nx/workspace': 19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7) + '@nx/workspace': 19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7) transitivePeerDependencies: - '@swc-node/register' - '@swc/core' @@ -16166,14 +16468,14 @@ snapshots: tslib: 2.4.1 yargs-parser: 21.1.1 - '@nx/devkit@19.8.0(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7))': + '@nx/devkit@19.8.0(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7))': dependencies: - '@nrwl/devkit': 19.8.0(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7)) + '@nrwl/devkit': 19.8.0(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7)) ejs: 3.1.10 enquirer: 2.3.6 ignore: 5.3.2 minimatch: 9.0.3 - nx: 19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7) + nx: 19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7) semver: 7.6.3 tmp: 0.2.3 tslib: 2.4.1 @@ -16352,7 +16654,7 @@ snapshots: - supports-color - typescript - '@nx/js@19.8.0(@babel/traverse@7.25.6)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(@types/node@22.5.5)(debug@4.3.7)(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7))(typescript@5.8.0-dev.20241125)': + '@nx/js@19.8.0(@babel/traverse@7.25.6)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(@types/node@22.5.5)(debug@4.3.7)(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7))(typescript@5.8.0-dev.20241204)': dependencies: '@babel/core': 7.25.2 '@babel/plugin-proposal-decorators': 7.24.7(@babel/core@7.25.2) @@ -16361,9 +16663,9 @@ snapshots: '@babel/preset-env': 7.25.3(@babel/core@7.25.2) '@babel/preset-typescript': 7.24.7(@babel/core@7.25.2) '@babel/runtime': 7.25.0 - '@nrwl/js': 19.8.0(@babel/traverse@7.25.6)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(@types/node@22.5.5)(debug@4.3.7)(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7))(typescript@5.8.0-dev.20241125) - '@nx/devkit': 19.8.0(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7)) - '@nx/workspace': 19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7) + '@nrwl/js': 19.8.0(@babel/traverse@7.25.6)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(@types/node@22.5.5)(debug@4.3.7)(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7))(typescript@5.8.0-dev.20241204) + '@nx/devkit': 19.8.0(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7)) + '@nx/workspace': 19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7) babel-plugin-const-enum: 1.2.0(@babel/core@7.25.2) babel-plugin-macros: 2.8.0 babel-plugin-transform-typescript-metadata: 0.3.2(@babel/core@7.25.2)(@babel/traverse@7.25.6) @@ -16380,7 +16682,7 @@ snapshots: ora: 5.3.0 semver: 7.6.3 source-map-support: 0.5.19 - ts-node: 10.9.1(@swc/core@1.5.29(@swc/helpers@0.5.12))(@types/node@22.5.5)(typescript@5.8.0-dev.20241125) + ts-node: 10.9.1(@swc/core@1.5.29(@swc/helpers@0.5.12))(@types/node@22.5.5)(typescript@5.8.0-dev.20241204) tsconfig-paths: 4.2.0 tslib: 2.4.1 transitivePeerDependencies: @@ -16825,13 +17127,13 @@ snapshots: - '@swc/core' - debug - '@nx/workspace@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7)': + '@nx/workspace@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7)': dependencies: - '@nrwl/workspace': 19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7) - '@nx/devkit': 19.8.0(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7)) + '@nrwl/workspace': 19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7) + '@nx/devkit': 19.8.0(nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7)) chalk: 4.1.2 enquirer: 2.3.6 - nx: 19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7) + nx: 19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7) tslib: 2.4.1 yargs-parser: 21.1.1 transitivePeerDependencies: @@ -16985,6 +17287,8 @@ snapshots: '@xml-tools/parser': 1.0.11 prettier: 3.3.3 + '@ranfdev/deepobj@1.0.2': {} + '@redocly/ajv@8.11.2': dependencies: fast-deep-equal: 3.1.3 @@ -17810,7 +18114,7 @@ snapshots: - '@swc/types' - supports-color - '@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125)': + '@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204)': dependencies: '@swc-node/core': 1.13.3(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9) '@swc-node/sourcemap-support': 0.5.1 @@ -17819,7 +18123,7 @@ snapshots: debug: 4.3.7(supports-color@8.1.1) pirates: 4.0.6 tslib: 2.6.3 - typescript: 5.8.0-dev.20241125 + typescript: 5.8.0-dev.20241204 transitivePeerDependencies: - '@swc/types' - supports-color @@ -18060,90 +18364,207 @@ snapshots: dependencies: '@types/node': 22.5.1 + '@types/d3-array@2.12.7': {} + '@types/d3-array@3.2.1': {} + '@types/d3-axis@2.1.6': + dependencies: + '@types/d3-selection': 2.0.5 + '@types/d3-axis@3.0.6': dependencies: '@types/d3-selection': 3.0.10 + '@types/d3-brush@2.1.5': + dependencies: + '@types/d3-selection': 2.0.5 + '@types/d3-brush@3.0.6': dependencies: '@types/d3-selection': 3.0.10 + '@types/d3-chord@2.0.6': {} + '@types/d3-chord@3.0.6': {} + '@types/d3-color@2.0.6': {} + '@types/d3-color@3.1.3': {} + '@types/d3-contour@2.0.7': + dependencies: + '@types/d3-array': 2.12.7 + '@types/geojson': 7946.0.14 + '@types/d3-contour@3.0.6': dependencies: '@types/d3-array': 3.2.1 '@types/geojson': 7946.0.14 + '@types/d3-delaunay@5.3.4': {} + '@types/d3-delaunay@6.0.4': {} + '@types/d3-dispatch@2.0.4': {} + '@types/d3-dispatch@3.0.6': {} + '@types/d3-drag@2.0.5': + dependencies: + '@types/d3-selection': 2.0.5 + '@types/d3-drag@3.0.7': dependencies: '@types/d3-selection': 3.0.10 + '@types/d3-dsv@2.0.9': {} + '@types/d3-dsv@3.0.7': {} + '@types/d3-ease@2.0.4': {} + '@types/d3-ease@3.0.2': {} + '@types/d3-fetch@2.0.5': + dependencies: + '@types/d3-dsv': 2.0.9 + '@types/d3-fetch@3.0.7': dependencies: '@types/d3-dsv': 3.0.7 + '@types/d3-force@2.1.7': {} + '@types/d3-force@3.0.10': {} + '@types/d3-format@2.0.5': {} + '@types/d3-format@3.0.4': {} + '@types/d3-geo@2.0.7': + dependencies: + '@types/geojson': 7946.0.14 + '@types/d3-geo@3.1.0': dependencies: '@types/geojson': 7946.0.14 + '@types/d3-hierarchy@2.0.5': {} + '@types/d3-hierarchy@3.1.7': {} + '@types/d3-interpolate@2.0.5': + dependencies: + '@types/d3-color': 2.0.6 + '@types/d3-interpolate@3.0.4': dependencies: '@types/d3-color': 3.1.3 + '@types/d3-path@2.0.4': {} + '@types/d3-path@3.1.0': {} + '@types/d3-polygon@2.0.3': {} + '@types/d3-polygon@3.0.2': {} + '@types/d3-quadtree@2.0.6': {} + '@types/d3-quadtree@3.0.6': {} + '@types/d3-random@2.2.3': {} + '@types/d3-random@3.0.3': {} + '@types/d3-scale-chromatic@2.0.4': {} + '@types/d3-scale-chromatic@3.0.3': {} + '@types/d3-scale@3.3.5': + dependencies: + '@types/d3-time': 2.1.4 + '@types/d3-scale@4.0.8': dependencies: '@types/d3-time': 3.0.3 + '@types/d3-selection@2.0.5': {} + '@types/d3-selection@3.0.10': {} + '@types/d3-shape@2.1.7': + dependencies: + '@types/d3-path': 2.0.4 + '@types/d3-shape@3.1.6': dependencies: '@types/d3-path': 3.1.0 + '@types/d3-time-format@3.0.4': {} + '@types/d3-time-format@4.0.3': {} + '@types/d3-time@2.1.4': {} + '@types/d3-time@3.0.3': {} + '@types/d3-timer@2.0.3': {} + '@types/d3-timer@3.0.2': {} + '@types/d3-transition@2.0.6': + dependencies: + '@types/d3-selection': 2.0.5 + '@types/d3-transition@3.0.8': dependencies: '@types/d3-selection': 3.0.10 + '@types/d3-zoom@2.0.7': + dependencies: + '@types/d3-interpolate': 2.0.5 + '@types/d3-selection': 2.0.5 + '@types/d3-zoom@3.0.8': dependencies: '@types/d3-interpolate': 3.0.4 '@types/d3-selection': 3.0.10 + '@types/d3@6.7.8': + dependencies: + '@types/d3-array': 2.12.7 + '@types/d3-axis': 2.1.6 + '@types/d3-brush': 2.1.5 + '@types/d3-chord': 2.0.6 + '@types/d3-color': 2.0.6 + '@types/d3-contour': 2.0.7 + '@types/d3-delaunay': 5.3.4 + '@types/d3-dispatch': 2.0.4 + '@types/d3-drag': 2.0.5 + '@types/d3-dsv': 2.0.9 + '@types/d3-ease': 2.0.4 + '@types/d3-fetch': 2.0.5 + '@types/d3-force': 2.1.7 + '@types/d3-format': 2.0.5 + '@types/d3-geo': 2.0.7 + '@types/d3-hierarchy': 2.0.5 + '@types/d3-interpolate': 2.0.5 + '@types/d3-path': 2.0.4 + '@types/d3-polygon': 2.0.3 + '@types/d3-quadtree': 2.0.6 + '@types/d3-random': 2.2.3 + '@types/d3-scale': 3.3.5 + '@types/d3-scale-chromatic': 2.0.4 + '@types/d3-selection': 2.0.5 + '@types/d3-shape': 2.1.7 + '@types/d3-time': 2.1.4 + '@types/d3-time-format': 3.0.4 + '@types/d3-timer': 2.0.3 + '@types/d3-transition': 2.0.6 + '@types/d3-zoom': 2.0.7 + '@types/d3@7.4.3': dependencies: '@types/d3-array': 3.2.1 @@ -18177,10 +18598,16 @@ snapshots: '@types/d3-transition': 3.0.8 '@types/d3-zoom': 3.0.8 + '@types/dc@4.2.5': + dependencies: + '@types/d3': 6.7.8 + '@types/debug@4.1.12': dependencies: '@types/ms': 0.7.34 + '@types/dom-to-image@2.6.7': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 8.56.10 @@ -18214,6 +18641,8 @@ snapshots: '@types/qs': 6.9.15 '@types/serve-static': 1.15.7 + '@types/file-saver@2.0.7': {} + '@types/geojson@7946.0.14': {} '@types/glob@7.2.0': @@ -19006,7 +19435,7 @@ snapshots: dependencies: call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.23.3 + es-abstract: 1.23.5 es-array-method-boxes-properly: 1.0.0 es-object-atoms: 1.0.0 is-string: 1.0.7 @@ -20204,6 +20633,10 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crossfilter2@1.5.4: + dependencies: + '@ranfdev/deepobj': 1.0.2 + css-blank-pseudo@3.0.3(postcss@8.4.38): dependencies: postcss: 8.4.38 @@ -20428,12 +20861,26 @@ snapshots: untildify: 4.0.0 yauzl: 2.10.0 + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + d3-array@3.2.4: dependencies: internmap: 2.0.3 + d3-axis@2.1.0: {} + d3-axis@3.0.0: {} + d3-brush@2.1.0: + dependencies: + d3-dispatch: 2.0.0 + d3-drag: 2.0.0 + d3-interpolate: 2.0.1 + d3-selection: 2.0.0 + d3-transition: 2.0.0(d3-selection@2.0.0) + d3-brush@3.0.0: dependencies: d3-dispatch: 3.0.1 @@ -20442,70 +20889,142 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) + d3-chord@2.0.0: + dependencies: + d3-path: 2.0.0 + d3-chord@3.0.1: dependencies: d3-path: 3.1.0 + d3-color@2.0.0: {} + d3-color@3.1.0: {} + d3-contour@2.0.0: + dependencies: + d3-array: 2.12.1 + d3-contour@4.0.2: dependencies: d3-array: 3.2.4 + d3-delaunay@5.3.0: + dependencies: + delaunator: 4.0.1 + d3-delaunay@6.0.4: dependencies: delaunator: 5.0.1 + d3-dispatch@2.0.0: {} + d3-dispatch@3.0.1: {} + d3-drag@2.0.0: + dependencies: + d3-dispatch: 2.0.0 + d3-selection: 2.0.0 + d3-drag@3.0.0: dependencies: d3-dispatch: 3.0.1 d3-selection: 3.0.0 + d3-dsv@2.0.0: + dependencies: + commander: 2.20.3 + iconv-lite: 0.4.24 + rw: 1.3.3 + d3-dsv@3.0.1: dependencies: commander: 7.2.0 iconv-lite: 0.6.3 rw: 1.3.3 + d3-ease@2.0.0: {} + d3-ease@3.0.1: {} + d3-fetch@2.0.0: + dependencies: + d3-dsv: 2.0.0 + d3-fetch@3.0.1: dependencies: d3-dsv: 3.0.1 + d3-force@2.1.1: + dependencies: + d3-dispatch: 2.0.0 + d3-quadtree: 2.0.0 + d3-timer: 2.0.0 + d3-force@3.0.0: dependencies: d3-dispatch: 3.0.1 d3-quadtree: 3.0.1 d3-timer: 3.0.1 + d3-format@2.0.0: {} + d3-format@3.1.0: {} + d3-geo@2.0.2: + dependencies: + d3-array: 2.12.1 + d3-geo@3.1.1: dependencies: d3-array: 3.2.4 + d3-hierarchy@2.0.0: {} + d3-hierarchy@3.1.2: {} + d3-interpolate@2.0.1: + dependencies: + d3-color: 2.0.0 + d3-interpolate@3.0.1: dependencies: d3-color: 3.1.0 + d3-path@2.0.0: {} + d3-path@3.1.0: {} + d3-polygon@2.0.0: {} + d3-polygon@3.0.1: {} + d3-quadtree@2.0.0: {} + d3-quadtree@3.0.1: {} + d3-random@2.2.2: {} + d3-random@3.0.1: {} + d3-scale-chromatic@2.0.0: + dependencies: + d3-color: 2.0.0 + d3-interpolate: 2.0.1 + d3-scale-chromatic@3.1.0: dependencies: d3-color: 3.1.0 d3-interpolate: 3.0.1 + d3-scale@3.3.0: + dependencies: + d3-array: 2.12.1 + d3-format: 2.0.0 + d3-interpolate: 2.0.1 + d3-time: 2.1.1 + d3-time-format: 3.0.0 + d3-scale@4.0.2: dependencies: d3-array: 3.2.4 @@ -20514,22 +21033,47 @@ snapshots: d3-time: 3.1.0 d3-time-format: 4.1.0 + d3-selection@2.0.0: {} + d3-selection@3.0.0: {} + d3-shape@2.1.0: + dependencies: + d3-path: 2.0.0 + d3-shape@3.2.0: dependencies: d3-path: 3.1.0 + d3-time-format@3.0.0: + dependencies: + d3-time: 2.1.1 + d3-time-format@4.1.0: dependencies: d3-time: 3.1.0 + d3-time@2.1.1: + dependencies: + d3-array: 2.12.1 + d3-time@3.1.0: dependencies: d3-array: 3.2.4 + d3-timer@2.0.0: {} + d3-timer@3.0.1: {} + d3-transition@2.0.0(d3-selection@2.0.0): + dependencies: + d3-color: 2.0.0 + d3-dispatch: 2.0.0 + d3-ease: 2.0.0 + d3-interpolate: 2.0.1 + d3-selection: 2.0.0 + d3-timer: 2.0.0 + d3-transition@3.0.1(d3-selection@3.0.0): dependencies: d3-color: 3.1.0 @@ -20539,6 +21083,14 @@ snapshots: d3-selection: 3.0.0 d3-timer: 3.0.1 + d3-zoom@2.0.0: + dependencies: + d3-dispatch: 2.0.0 + d3-drag: 2.0.0 + d3-interpolate: 2.0.1 + d3-selection: 2.0.0 + d3-transition: 2.0.0(d3-selection@2.0.0) + d3-zoom@3.0.0: dependencies: d3-dispatch: 3.0.1 @@ -20547,6 +21099,39 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) + d3@6.7.0: + dependencies: + d3-array: 2.12.1 + d3-axis: 2.1.0 + d3-brush: 2.1.0 + d3-chord: 2.0.0 + d3-color: 2.0.0 + d3-contour: 2.0.0 + d3-delaunay: 5.3.0 + d3-dispatch: 2.0.0 + d3-drag: 2.0.0 + d3-dsv: 2.0.0 + d3-ease: 2.0.0 + d3-fetch: 2.0.0 + d3-force: 2.1.1 + d3-format: 2.0.0 + d3-geo: 2.0.2 + d3-hierarchy: 2.0.0 + d3-interpolate: 2.0.1 + d3-path: 2.0.0 + d3-polygon: 2.0.0 + d3-quadtree: 2.0.0 + d3-random: 2.2.2 + d3-scale: 3.3.0 + d3-scale-chromatic: 2.0.0 + d3-selection: 2.0.0 + d3-shape: 2.1.0 + d3-time: 2.1.1 + d3-time-format: 3.0.0 + d3-timer: 2.0.0 + d3-transition: 2.0.0(d3-selection@2.0.0) + d3-zoom: 2.0.0 + d3@7.9.0: dependencies: d3-array: 3.2.4 @@ -20629,6 +21214,10 @@ snapshots: dayjs@1.11.11: {} + dc@4.2.7: + dependencies: + d3: 6.7.0 + debug@2.6.9: dependencies: ms: 2.0.0 @@ -20787,6 +21376,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delaunator@4.0.1: {} + delaunator@5.0.1: dependencies: robust-predicates: 3.0.2 @@ -20881,6 +21472,8 @@ snapshots: domhandler: 5.0.3 entities: 4.5.0 + dom-to-image-more@3.5.0: {} + domelementtype@2.3.0: {} domexception@4.0.0: @@ -20928,7 +21521,7 @@ snapshots: dependencies: semver: 7.6.3 shelljs: 0.8.5 - typescript: 5.8.0-dev.20241125 + typescript: 5.8.0-dev.20241204 duplexer@0.1.2: {} @@ -21080,7 +21673,7 @@ snapshots: is-subset: 0.1.1 lodash.escape: 4.0.1 lodash.isequal: 4.5.0 - object-inspect: 1.13.2 + object-inspect: 1.13.3 object-is: 1.1.6 object.assign: 4.1.5 object.entries: 1.1.8 @@ -21959,6 +22552,8 @@ snapshots: schema-utils: 3.3.0 webpack: 5.93.0(@swc/core@1.5.29(@swc/helpers@0.5.12))(esbuild@0.23.0) + file-saver@2.0.5: {} + file-type@16.5.4: dependencies: readable-web-to-node-stream: 3.0.2 @@ -22948,6 +23543,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.0.6 + internmap@1.0.1: {} + internmap@2.0.3: {} interpret@1.4.0: {} @@ -24685,13 +25282,14 @@ snapshots: optionalDependencies: msgpackr-extract: 3.0.3 - msw@2.4.8(typescript@5.5.4): + msw@2.6.0(@types/node@22.5.1)(typescript@5.5.4): dependencies: '@bundled-es-modules/cookie': 2.0.0 '@bundled-es-modules/statuses': 1.0.1 '@bundled-es-modules/tough-cookie': 0.1.6 - '@inquirer/confirm': 3.2.0 - '@mswjs/interceptors': 0.35.7 + '@inquirer/confirm': 5.0.2(@types/node@22.5.1) + '@mswjs/interceptors': 0.36.10 + '@open-draft/deferred-promise': 2.2.0 '@open-draft/until': 2.1.0 '@types/cookie': 0.6.0 '@types/statuses': 2.0.5 @@ -24706,6 +25304,8 @@ snapshots: yargs: 17.7.2 optionalDependencies: typescript: 5.5.4 + transitivePeerDependencies: + - '@types/node' multicast-dns@7.2.5: dependencies: @@ -24716,6 +25316,8 @@ snapshots: mute-stream@1.0.0: {} + mute-stream@2.0.0: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -24802,6 +25404,12 @@ snapshots: ts-md5: 1.3.1 tslib: 2.6.3 + ngx-cookie-service@18.0.0(@angular/common@18.2.5(@angular/core@18.2.5(rxjs@7.8.1)(zone.js@0.14.4))(rxjs@7.8.1))(@angular/core@18.2.5(rxjs@7.8.1)(zone.js@0.14.4)): + dependencies: + '@angular/common': 18.2.5(@angular/core@18.2.5(rxjs@7.8.1)(zone.js@0.14.4))(rxjs@7.8.1) + '@angular/core': 18.2.5(rxjs@7.8.1)(zone.js@0.14.4) + tslib: 2.6.3 + ngx-countup@13.1.0(@angular/common@18.2.5(@angular/core@18.2.5(rxjs@7.8.1)(zone.js@0.14.4))(rxjs@7.8.1))(@angular/core@18.2.5(rxjs@7.8.1)(zone.js@0.14.4)): dependencies: '@angular/common': 18.2.5(@angular/core@18.2.5(rxjs@7.8.1)(zone.js@0.14.4))(rxjs@7.8.1) @@ -25093,10 +25701,10 @@ snapshots: transitivePeerDependencies: - debug - nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7): + nx@19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7): dependencies: '@napi-rs/wasm-runtime': 0.2.4 - '@nrwl/tao': 19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7) + '@nrwl/tao': 19.8.0(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204))(@swc/core@1.5.29(@swc/helpers@0.5.12))(debug@4.3.7) '@yarnpkg/lockfile': 1.1.0 '@yarnpkg/parsers': 3.0.0-rc.46 '@zkochan/js-yaml': 0.0.7 @@ -25141,7 +25749,7 @@ snapshots: '@nx/nx-linux-x64-musl': 19.8.0 '@nx/nx-win32-arm64-msvc': 19.8.0 '@nx/nx-win32-x64-msvc': 19.8.0 - '@swc-node/register': 1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241125) + '@swc-node/register': 1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.12))(@swc/types@0.1.9)(typescript@5.8.0-dev.20241204) '@swc/core': 1.5.29(@swc/helpers@0.5.12) transitivePeerDependencies: - debug @@ -28234,7 +28842,7 @@ snapshots: optionalDependencies: '@swc/core': 1.5.29(@swc/helpers@0.5.12) - ts-node@10.9.1(@swc/core@1.5.29(@swc/helpers@0.5.12))(@types/node@22.5.5)(typescript@5.8.0-dev.20241125): + ts-node@10.9.1(@swc/core@1.5.29(@swc/helpers@0.5.12))(@types/node@22.5.5)(typescript@5.8.0-dev.20241204): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 @@ -28248,7 +28856,7 @@ snapshots: create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 5.8.0-dev.20241125 + typescript: 5.8.0-dev.20241204 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optionalDependencies: @@ -28293,6 +28901,8 @@ snapshots: tslib@2.6.3: {} + tslib@2.8.1: {} + tsscmp@1.0.6: {} tsutils@3.21.0(typescript@5.5.4): @@ -28394,7 +29004,7 @@ snapshots: typescript@5.5.4: {} - typescript@5.8.0-dev.20241125: {} + typescript@5.8.0-dev.20241204: {} ua-parser-js@1.0.38: {} diff --git a/tsconfig.base.json b/tsconfig.base.json index 9c6bc20219..1201276f64 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -19,7 +19,12 @@ "paths": { "@sagebionetworks/agora/about": ["libs/agora/about/src/index.ts"], "@sagebionetworks/agora/api-client-angular": ["libs/agora/api-client-angular/src/index.ts"], + "@sagebionetworks/agora/charts": ["libs/agora/charts/src/index.ts"], "@sagebionetworks/agora/config": ["libs/agora/config/src/index.ts"], + "@sagebionetworks/agora/gene-comparison-tool": [ + "libs/agora/gene-comparison-tool/src/index.ts" + ], + "@sagebionetworks/agora/gene-details": ["libs/agora/genes/src/index.ts"], "@sagebionetworks/agora/genes": ["libs/agora/genes/src/index.ts"], "@sagebionetworks/agora/home": ["libs/agora/home/src/index.ts"], "@sagebionetworks/agora/models": ["libs/agora/models/index.ts"], @@ -27,10 +32,12 @@ "@sagebionetworks/agora/nominated-targets": ["libs/agora/nominated-targets/src/index.ts"], "@sagebionetworks/agora/not-found": ["libs/agora/not-found/src/index.ts"], "@sagebionetworks/agora/services": ["libs/agora/services/src/index.ts"], + "@sagebionetworks/agora/shared": ["libs/agora/shared/src/index.ts"], "@sagebionetworks/agora/teams": ["libs/agora/teams/src/index.ts"], "@sagebionetworks/agora/testing": ["libs/agora/testing/src/index.ts"], "@sagebionetworks/agora/ui": ["libs/agora/ui/src/index.ts"], "@sagebionetworks/agora/util": ["libs/agora/util/src/index.ts"], + "@sagebionetworks/agora/wiki": ["libs/agora/wiki/src/index.ts"], "@sagebionetworks/model-ad/api-client-angular": [ "libs/model-ad/api-client-angular/src/index.ts" ],