diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..59d9a3a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..59aab62 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,47 @@ +name: Tests + +on: + push: + branches: + - main + - dev + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Build, lint and test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install dependencies + run: npm ci + + # In dependency order: the diagram and the demo app import the style + # library from dist/, so nothing else type-checks until it is built. + - name: Build the libraries + run: npm run build:libs + + - name: Lint + run: npm run check:lint + + - name: Test + run: npm test + + - name: Build the demo app + run: npx ng build ngx-reactome-base + + # What would be published: a package that packs is a package that installs. + - name: Pack the libraries + run: | + for lib in ngx-reactome-cytoscape-style ngx-reactome-style ngx-reactome-diagram; do + (cd "dist/$lib" && npm pack --dry-run) + done diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..15be1e0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log +*/node_modules +*/package-lock.json + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings + +# System files +.DS_Store +Thumbs.db +/.vscode/ +/.angular/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..77b3745 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 + "recommendations": ["angular.ng-template"] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..925af83 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "ng serve", + "type": "chrome", + "request": "launch", + "preLaunchTask": "npm: start", + "url": "http://localhost:4200/" + }, + { + "name": "ng test", + "type": "chrome", + "request": "launch", + "preLaunchTask": "npm: test", + "url": "http://localhost:9876/debug.html" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..a298b5b --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,42 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "start", + "isBackground": true, + "problemMatcher": { + "owner": "typescript", + "pattern": "$tsc", + "background": { + "activeOnStart": true, + "beginsPattern": { + "regexp": "(.*?)" + }, + "endsPattern": { + "regexp": "bundle generation complete" + } + } + } + }, + { + "type": "npm", + "script": "test", + "isBackground": true, + "problemMatcher": { + "owner": "typescript", + "pattern": "$tsc", + "background": { + "activeOnStart": true, + "beginsPattern": { + "regexp": "(.*?)" + }, + "endsPattern": { + "regexp": "bundle generation complete" + } + } + } + } + ] +} diff --git a/README.md b/README.md index e82bc9a..c7c2198 100644 --- a/README.md +++ b/README.md @@ -1 +1,23 @@ -# ngx-reactome-base \ No newline at end of file +# NgxReactomeBase + +This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 17.3.6. + +## Development server + +Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. + +## Code scaffolding + +Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. + +## Build + +Run `npm run build` to build the libraries and the demo app, in the order they depend on each other (`npm run build:libs` for the libraries alone). The build artifacts are stored in `dist/`. + +## Running unit tests + +Run `npm test` to run every project's unit tests with [Vitest](https://vitest.dev) through Angular's unit-test builder, or `ng test ` for one. The diagram's tests use the built style library, so run `npm run build:libs` first. + +## Further help + +To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. diff --git a/angular.json b/angular.json new file mode 100644 index 0000000..23e4e63 --- /dev/null +++ b/angular.json @@ -0,0 +1,193 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "ngx-reactome-base": { + "projectType": "application", + "schematics": {}, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "outputPath": "dist/ngx-reactome-base", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": [ + "zone.js" + ], + "tsConfig": "tsconfig.app.json", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.css" + ], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "1mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "4kb" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "ngx-reactome-base:build:production" + }, + "development": { + "buildTarget": "ngx-reactome-base:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular/build:extract-i18n", + "options": { + "buildTarget": "ngx-reactome-base:build" + } + }, + "test": { + "builder": "@angular/build:unit-test", + "options": { + "tsConfig": "tsconfig.spec.json" + } + } + } + }, + "ngx-reactome-style": { + "projectType": "library", + "root": "projects/ngx-reactome-style", + "sourceRoot": "projects/ngx-reactome-style/src", + "prefix": "lib", + "architect": { + "build": { + "builder": "@angular/build:ng-packagr", + "options": { + "project": "projects/ngx-reactome-style/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "projects/ngx-reactome-style/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "projects/ngx-reactome-style/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + } + } + }, + "ngx-reactome-diagram": { + "projectType": "library", + "root": "projects/ngx-reactome-diagram", + "sourceRoot": "projects/ngx-reactome-diagram/src", + "prefix": "lib", + "architect": { + "build": { + "builder": "@angular/build:ng-packagr", + "options": { + "project": "projects/ngx-reactome-diagram/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "projects/ngx-reactome-diagram/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "projects/ngx-reactome-diagram/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + }, + "test": { + "builder": "@angular/build:unit-test", + "options": { + "tsConfig": "projects/ngx-reactome-diagram/tsconfig.spec.json" + } + } + } + }, + "ngx-reactome-cytoscape-style": { + "projectType": "library", + "root": "projects/ngx-reactome-cytoscape-style", + "sourceRoot": "projects/ngx-reactome-cytoscape-style/src", + "prefix": "lib", + "architect": { + "build": { + "builder": "@angular/build:ng-packagr", + "options": { + "project": "projects/ngx-reactome-cytoscape-style/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "projects/ngx-reactome-cytoscape-style/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "projects/ngx-reactome-cytoscape-style/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + }, + "test": { + "builder": "@angular/build:unit-test", + "options": { + "tsConfig": "projects/ngx-reactome-cytoscape-style/tsconfig.spec.json" + } + } + } + } + }, + "cli": { + "analytics": false + }, + "schematics": { + "@schematics/angular:component": { + "type": "component" + }, + "@schematics/angular:directive": { + "type": "directive" + }, + "@schematics/angular:service": { + "type": "service" + }, + "@schematics/angular:guard": { + "typeSeparator": "." + }, + "@schematics/angular:interceptor": { + "typeSeparator": "." + }, + "@schematics/angular:module": { + "typeSeparator": "." + }, + "@schematics/angular:pipe": { + "typeSeparator": "." + }, + "@schematics/angular:resolver": { + "typeSeparator": "." + } + } +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..24e2b72 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,191 @@ +// @ts-check +/** + * Lint configuration, the same as reactome/WebsiteAngular's, where these + * libraries were developed. + * + * error - rules that catch bugs, or that the code is already clean of. A + * violation fails the build. + * warn - the rest: style, and rules with too many existing violations to + * fix at once (mostly `any` flowing through the code). They are + * counted against lint-baseline.json by scripts/check-lint.mjs, + * which fails when a count goes up, so they only shrink. + * + * The Angular migrations WebsiteAngular has completed -- inject(), signal + * inputs and queries, output() -- are warnings here until this code makes them. + */ +const eslint = require('@eslint/js'); +const tseslint = require('typescript-eslint'); +const angular = require('angular-eslint'); + +module.exports = tseslint.config( + { + // Generated, vendored, or build output: nothing to say about it. + ignores: [ + '**/dist/**', + '**/node_modules/**', + '**/.angular/**', + '**/out-tsc/**', + '**/coverage/**', + '**/*.d.ts', + ], + }, + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + // projectService rather than a fixed `project` list: this is a + // multi-project workspace, and pointing at tsconfig.app.json alone left + // 178 files -- every library, every spec -- unparseable and therefore + // unlinted. + projectService: true, + tsconfigRootDir: __dirname, + }, + }, + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommended, + ...angular.configs.tsRecommended, + ], + processor: angular.processInlineTemplates, + // Severity here means one thing: whether the codebase is already clean of + // it. Rules with no existing violations stay errors, so a new one fails the + // build immediately. Rules that already have violations are warnings, + // counted against a baseline by scripts/check-lint.mjs so they can only go + // down. Fix a rule's last violation and it should be promoted to error. + rules: { + // Already clean -- left as errors by the recommended sets. + 'no-empty': ['error', { allowEmptyCatch: true }], + + // Now clean, so promoted to error: a promise nobody handles swallows its + // own failure, which is how a broken request renders an empty page with a + // clean console. All 58 were dealt with, so a new one is a regression. + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-misused-promises': 'error', + // TypeScript's noUnusedLocals/noUnusedParameters cover the app and the + // libraries; this covers what no tsconfig does (specs, e2e, scripts). + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' }, + ], + // Published libraries print to their consumers' consoles. Debug logging + // on every diagram load and state change was removed; warnings and + // errors (and failed assertions on the data) are what a library should + // say there. + 'no-console': ['error', { allow: ['warn', 'error', 'assert'] }], + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-non-null-assertion': 'warn', + // `any` flowing through the code: an API answer read as any, then passed, + // returned or dereferenced as if its shape were known. Warned, and held + // at today's count rule by rule (scripts/check-lint.mjs), so new code + // types what it reads while the existing ones (990, tests and e2e + // included) are worked down. + '@typescript-eslint/no-unsafe-assignment': 'warn', + '@typescript-eslint/no-unsafe-member-access': 'warn', + '@typescript-eslint/no-unsafe-call': 'warn', + '@typescript-eslint/no-unsafe-return': 'warn', + '@typescript-eslint/no-unsafe-argument': 'warn', + '@typescript-eslint/no-empty-object-type': 'warn', + '@typescript-eslint/no-namespace': 'warn', + '@typescript-eslint/no-unused-expressions': 'warn', + '@typescript-eslint/no-require-imports': 'warn', + 'prefer-const': 'warn', + 'no-useless-escape': 'warn', + // Migrated in full with ng generate @angular/core:inject-migration, so + // any new constructor injection is a step backwards. + '@angular-eslint/prefer-inject': 'warn', + // Migrated in full (signal inputs, outputs and queries, by Angular's own + // migrations, and every signal field readonly), so a decorator input or + // a reassignable signal field is new. + '@angular-eslint/prefer-signals': 'warn', + '@angular-eslint/prefer-output-emitter-ref': 'warn', + // Every deprecated API the code called has been replaced, so any use of + // one is new. Deprecated is how Angular, and the libraries, say what the + // next major version removes. + '@typescript-eslint/no-deprecated': 'error', + // A thrown string has no stack, and Angular does not wait for an async + // lifecycle hook, so its errors escape. The code has neither. + '@typescript-eslint/only-throw-error': 'error', + '@typescript-eslint/prefer-promise-reject-errors': 'error', + '@typescript-eslint/await-thenable': 'error', + // `a && a.b` guards one thing and says it twice; the rule rewrites only + // where the result cannot change, and each of the rest was checked by hand. + '@typescript-eslint/prefer-optional-chain': 'error', + // An object in a template literal becomes "[object Object]", an array + // "a,b" -- which is how a two-species filter put "9606,10090" into the + // PDF report's address and got a 404. + '@typescript-eslint/restrict-template-expressions': 'error', + // A switch over a union with no default that misses a member. A default + // branch counts as handling the rest: that is its job. + '@typescript-eslint/switch-exhaustiveness-check': [ + 'error', + { considerDefaultExhaustiveForUnions: true }, + ], + '@angular-eslint/no-async-lifecycle-method': 'error', + '@angular-eslint/prefer-standalone': 'warn', + '@angular-eslint/no-output-native': 'warn', + '@angular-eslint/no-input-rename': 'warn', + // All twelve removed, so a new empty hook is a new mistake. + '@angular-eslint/no-empty-lifecycle-method': 'error', + '@angular-eslint/use-lifecycle-interface': 'warn', + '@typescript-eslint/ban-ts-comment': 'warn', + '@typescript-eslint/no-wrapper-object-types': 'warn', + '@typescript-eslint/no-extra-non-null-assertion': 'warn', + '@typescript-eslint/prefer-as-const': 'warn', + '@angular-eslint/no-output-rename': 'warn', + 'no-useless-assignment': 'warn', + 'no-unused-vars': 'warn', + 'no-empty-pattern': 'warn', + }, + }, + { + files: ['**/*.html'], + extends: [...angular.configs.templateRecommended], + rules: { + // All nine were same-type comparisons, so `===` was a no-op change; a new + // `==` is a new chance of coercion. + '@angular-eslint/template/eqeqeq': 'error', + // Catches `!(value | async)`, which is truthy while the observable is + // still pending. The four there were meant exactly that, and now say so: + // `(value | async) !== true`. (The rule's own `=== false` would not be the + // same -- it is false while pending.) + '@angular-eslint/template/no-negated-async': 'error', + '@angular-eslint/template/prefer-control-flow': 'warn', + // A +
+ + + + + + + \ No newline at end of file diff --git a/projects/ngx-reactome-diagram/src/lib/diagram/diagram.component.scss b/projects/ngx-reactome-diagram/src/lib/diagram/diagram.component.scss new file mode 100644 index 0000000..dfd9767 --- /dev/null +++ b/projects/ngx-reactome-diagram/src/lib/diagram/diagram.component.scss @@ -0,0 +1,234 @@ +.variables { + position: absolute; + inset: 0 0 40px 0; + --compartment-opacity: 0.08; + --structure-opacity: [[130,0], [150,100]]; + + --shadow-luminosity: 40; + --on-surface: #001F24; + --primary: #006782; + --on-primary: #FFFFFF; + --on-tertiary: #FFFFFF; + --positive: #0C9509; + --negative: #BA1A1A; + --negative-contrast: #ea7d7d; + --select-node: #6EB3E4; + --select-edge: #0561A6; + --hover-node: #78E076; + --hover-edge: #04B601; + --interactor-fill: #68297C; + --interactor-stroke: #9f5cb5; + --flag: #DE75B4; + + + --compartment: #E5834A; + + --primary-contrast-1: #001F29; + --primary-contrast-2: #003545; + --primary-contrast-3: #004D62; + --primary-contrast-4: #006782; + + --tertiary-contrast-1: #00315C; + --tertiary-contrast-2: #004882; + --tertiary-contrast-3: #1660A5; + + --drug-contrast-1: #3E001D; + --drug-contrast-2: #610B33; + --drug-contrast-3: #7E2549; + --drug-contrast-4: #BB557A; + + &.dark { + --compartment-opacity: 0.08; + --shadow-luminosity: 70; + --shadow-opacity: [[20, 40], [40, 0]]; + + + --on-surface: #97F0FF; + --primary: #5CD4FF; + --on-primary: #0D1617; + --on-tertiary: #0D1317; + --positive: #10d70b; + --negative: #ea2323; + --select-node: #00ffff; + --negative-contrast: #8f0000; + --select-edge: #1d85cc; + --hover-node: #ffff00; + --hover-edge: #ffff00; + //--interactor-stroke: #68297C; + //--interactor-fill: #9f5cb5; + --flag: #DA429E; + + --compartment: #5e232d; + + --primary-contrast-1: #5CD4FF; + --primary-contrast-2: #20B9E5; + --primary-contrast-3: #009DC4; + --primary-contrast-4: #0081A2; + + --tertiary-contrast-1: #a48ee0; + --tertiary-contrast-2: #9b73d3; + --tertiary-contrast-3: #8c63c5; + + --drug-contrast-1: #FFB1C8; + --drug-contrast-2: #F988AE; + --drug-contrast-3: #DA6E94; + --drug-contrast-4: #c4527b; + } +} + +#controls { + position: absolute; + inset: calc(100vh - 40px) 0 0 0; + + * { + background: var(--surface); + color: var(--on-surface); + } + + background: var(--surface); + +} + +#cytoscape { + position: absolute; + inset: 0 0 0 0; + background: var(--surface); +} + + +#legend-boundary { + --legend-width: 400px; + --border-width: 2px; + --handle-width: 20px; + + position: absolute; + pointer-events: none; + right: calc(-1 * var(--legend-width) - var(--border-width)); + height: 100%; + width: calc(2 * var(--legend-width) + var(--handle-width)); + + #legend-container { + position: relative; + z-index: 1; + display: flex; + align-items: center; + width: fit-content; + left: calc(var(--legend-width) - var(--border-width)); + pointer-events: all; + height: 100%; + } + + #legend-handle { + width: var(--handle-width); + text-orientation: upright; + writing-mode: vertical-lr; + + border-radius: 8.5px 0 0 8.5px; + background: color-mix(in srgb, var(--surface) 80%, transparent); + backdrop-filter: blur(5px); + border-left: var(--border-width) solid var(--primary); + border-top: var(--border-width) solid var(--primary); + border-bottom: var(--border-width) solid var(--primary); + border-right: 0; + cursor: ew-resize; + color: var(--on-surface); + } + + #legend { + width: var(--legend-width); + height: 100%; + //border-left: var(--border-width) solid var(--primary); + background: color-mix(in srgb, var(--surface) 80%, transparent); + backdrop-filter: blur(5px); + + &:before { + content: ""; + height: 100%; + width: 2px; + background-color: var(--primary); + position: absolute; + left: -1px; + z-index: 4; + } + } +} + +.drag-container { + position: absolute; + left: 10%; + width: 100%; + pointer-events: all; + //position: relative; + z-index: 1; + display: flex; + align-items: center; + height: 100%; +} + +#handle-limits { + --width: 500px; + --height: 500px; + position: absolute; + left: calc(-1 / 2 * var(--width)); + top: calc(50% - 1 / 2 * var(--height)); + width: var(--width); + height: var(--height); + //background:#000000aa; + z-index: 5; + pointer-events: none; + + &.active { + pointer-events: all; + cursor: ew-resize; + } +} + +.drag-handle { + --width: 4px; + --height: 30px; + + position: absolute; + left: calc(50% - 1 / 2 * var(--width) - 4px); + top: calc(50% - 1 / 2 * var(--height) - 4px); + + border-radius: var(--height); + width: var(--width); + height: var(--height); + border: 4px solid var(--primary); + cursor: ew-resize; + color: var(--on-surface); + pointer-events: all; + filter: drop-shadow(-2px 0px 0px var(--primary-container)) drop-shadow(2px 0px 0px var(--error-container)); + background: var(--surface); + +} + + +#cytoscape-compare { + box-sizing: border-box; + position: absolute; + //border-left: 4px solid var(--error); + height: 100%; + width: 100%; + inset: 0 0 0 0; + background-color: color-mix(in srgb, var(--error) 5%, color-mix(in srgb, var(--surface) 50%, transparent)); + + &:before { + content: ""; + height: 100%; + width: 4px; + background-color: var(--primary); + filter: drop-shadow(-2px 0px 0px var(--primary-container)) drop-shadow(2px 0px 0px var(--error-container)); + position: absolute; + left: -2px; + z-index: 4; + } +} + +.option-content { + width: 100%; + display: flex; + justify-content: space-between; + gap: 100px; + align-items: center; +} diff --git a/projects/ngx-reactome-diagram/src/lib/diagram/diagram.component.spec.ts b/projects/ngx-reactome-diagram/src/lib/diagram/diagram.component.spec.ts new file mode 100644 index 0000000..c73822d --- /dev/null +++ b/projects/ngx-reactome-diagram/src/lib/diagram/diagram.component.spec.ts @@ -0,0 +1,29 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideRouter } from '@angular/router'; + +import { DiagramComponent } from './diagram.component'; +import { DIAGRAM_CONFIG_TOKEN } from '../services/diagram.service'; + +describe('DiagramComponent', () => { + // jsdom has no matchMedia, which the theme reads. + beforeEach(() => { + vi.stubGlobal('matchMedia', (media: string) => ({ media, matches: false })); + }); + afterEach(() => vi.unstubAllGlobals()); + + it('is created with what it injects', () => { + TestBed.configureTestingModule({ + imports: [DiagramComponent], + providers: [ + provideRouter([]), + provideHttpClient(), + provideHttpClientTesting(), + { provide: DIAGRAM_CONFIG_TOKEN, useValue: {} }, + ], + }); + const fixture = TestBed.createComponent(DiagramComponent); + expect(fixture.componentInstance).toBeTruthy(); + }); +}); diff --git a/projects/ngx-reactome-diagram/src/lib/diagram/diagram.component.ts b/projects/ngx-reactome-diagram/src/lib/diagram/diagram.component.ts new file mode 100644 index 0000000..d6c00e4 --- /dev/null +++ b/projects/ngx-reactome-diagram/src/lib/diagram/diagram.component.ts @@ -0,0 +1,618 @@ +import { + AfterViewInit, + Component, + ElementRef, Input, + OnChanges, + Output, + SimpleChanges, + ViewChild +} from '@angular/core'; +import { DiagramService } from "../services/diagram.service"; +import cytoscape from "cytoscape"; +// @ts-ignore +import { interactivityOf, ReactomeEvent, ReactomeEventTypes, Style } from "ngx-reactome-cytoscape-style"; +import { DarkService } from "../services/dark.service"; +import { delay, distinctUntilChanged, filter, Observable, share, Subject } from "rxjs"; +import { FormControl } from "@angular/forms"; +import { DiagramStateService } from "../services/diagram-state.service"; +import { UntilDestroy } from "@ngneat/until-destroy"; +import { MatDialog } from "@angular/material/dialog"; +import { CommonModule } from '@angular/common'; +import { CdkDrag } from '@angular/cdk/drag-drop'; + +@UntilDestroy({checkProperties: true}) +@Component({ + selector: 'cr-diagram', + templateUrl: './diagram.component.html', + styleUrls: ['./diagram.component.scss'], + standalone: true, + imports: [ + CommonModule, + CdkDrag + ] +}) +export class DiagramComponent implements AfterViewInit, OnChanges { + title = 'pathway-browser'; + @ViewChild('cytoscape') cytoscapeContainer?: ElementRef; + @ViewChild('cytoscapeCompare') compareContainer?: ElementRef; + @ViewChild('legend') legendContainer?: ElementRef; + + comparing: boolean = false; + selectedPsicquicResource = new FormControl(); + isDataFromPsicquicLoading: boolean = false; + + + constructor(private diagram: DiagramService, public dark: DarkService, private state: DiagramStateService, public dialog: MatDialog) { + } + + cy!: cytoscape.Core; + cyCompare!: cytoscape.Core; + legend!: cytoscape.Core; + reactomeStyle!: Style; + reactomeStyleCompare!: Style; + + private _reactomeEvents$: Subject = new Subject(); + private _ignore = false; + + @Output() + public reactomeEvents$: Observable = this._reactomeEvents$.asObservable().pipe( + distinctUntilChanged((prev, current) => prev.type === current.type && prev.detail.reactomeId === current.detail.reactomeId), + filter(() => !this._ignore), + share() + ); + + + @Input('id') diagramId: string = ''; + + // Flag to control if dbId should be used as primary ids + // This should be useful in the context of the curator tool since + // dbIds are used as primary ids. Not all instances have stable ids. + @Input() usedbId: boolean = false; + // There are many issues having URL changes for selection state or other + // state changes in the curator tool environment (e.g conflict with tree URL) + // therefore, we'd like to block the change by flagging this to true. + @Input() + set blockRouterChange(block: boolean) { + if (this.state) + this.state.blockRouterChange = block; + }; + + ngOnChanges(changes: SimpleChanges): void { + if (changes['diagramId']) this.loadDiagram(); + } + + ngAfterViewInit(): void { + this.dark.$dark.subscribe(this.updateStyle.bind(this)) + + const container = this.cytoscapeContainer!.nativeElement; + const compareContainer = this.compareContainer!.nativeElement; + const legendContainer = this.legendContainer!.nativeElement; + + Object.values(ReactomeEventTypes).forEach((type) => { + container.addEventListener(type, (e) => this._reactomeEvents$.next(e as ReactomeEvent)) + compareContainer.addEventListener(type, (e) => this._reactomeEvents$.next(e as ReactomeEvent)) + legendContainer.addEventListener(type, (e) => this._reactomeEvents$.next(e as ReactomeEvent)) + }) + + this.reactomeStyle = new Style(container); + + this.diagram.getLegend() + .subscribe(legend => { + this.legend = cytoscape({ + container: legendContainer, + elements: legend, + style: this.reactomeStyle?.getStyleSheet(), + layout: {name: "preset"}, + boxSelectionEnabled: false + }); + this.reactomeStyle?.bindToCytoscape(this.legend); + this.legend.zoomingEnabled(false); + this.legend.panningEnabled(false); + this.legend.minZoom(0) + }); + + this.loadDiagram(); + + //this.getPsicquicResources(); + } + + loadDiagram() { + if (!this.cytoscapeContainer) return; + + if (!this.diagramId || this.diagramId.trim().length == 0) + return; // Nothing to do. + + this.diagram.getDiagram(this.diagramId) + .subscribe(elements => { + this.displayNetwork(elements); + }) + } + + displayNetwork(elements: any) { + const container = this.cytoscapeContainer!.nativeElement; + this.comparing = (elements.nodes?.some((node: any) => node.data['isFadeOut'])) || + (elements.edges?.some((edge: any) => edge.data['isFadeOut'])) + this.cy = cytoscape({ + container: container, + elements: elements, + style: this.reactomeStyle?.getStyleSheet(), + layout: {name: "preset"}, + userPanningEnabled: false, + userZoomingEnabled: false, + autoungrabify: false + }); + this.reactomeStyle.bindToCytoscape(this.cy); + this.reactomeStyle.clearCache(); + + this.loadCompare(elements, container); + + this.stateToDiagram(); + + // Fire an event after this network is displayed. + const event = new CustomEvent("network_displayed"); + container.dispatchEvent(event); + } + + private initialiseReplaceElements() { + if (this.comparing) + this.cy.batch(() => { + this.cy.elements('[!isBackground]').style('visibility', 'hidden') + this.cy.edges('.shadow').style('underlay-padding', 0) + this.lastIndex = 0; + this.updateReplacementVisibility(); + this.cy.elements('.Compartment').style('visibility', 'visible') + }) + } + + private loadCompare(elements: cytoscape.ElementsDefinition, container: HTMLDivElement) { + + const getPosition = (e: cytoscape.SingularElementArgument) => e.is('.Shadow') ? e.data('triggerPosition') : e.boundingBox().x1; + if (this.comparing) { + this.cy.elements('[!isBackground]').style('visibility', 'hidden') + this.replacedElements = this.cy! + .elements('[?replacedBy]') + .add('[?isCrossed]') + .sort((a, b) => getPosition(a) - getPosition(b)) + .style('visibility', 'hidden') + .toArray(); + + this.replacedElementsPosition = this.replacedElements.map(getPosition); + + + this.cy.on('add', e => { + const addedElement = e.target; + if (addedElement.data('replacedBy') || addedElement.data('isCrossed')) { + const x = getPosition(addedElement); + let index = this.replacedElementsPosition.findIndex(x1 => x1 >= x); + if (index === -1) index = this.replacedElements.length; + + this.replacedElements.splice(index, 0, addedElement); + this.replacedElementsPosition.splice(index, 0, x); + addedElement.style('visibility', 'hidden'); + } + }) + + this.cy.on('remove', e => { + const removedElement = e.target; + const index = this.replacedElements.indexOf(removedElement); + if (index > -1) { + this.replacedElements.splice(index, 1); + this.replacedElementsPosition.splice(index, 1); + } + }) + + const compareContainer = this.compareContainer!.nativeElement; + this.cyCompare = cytoscape({ + container: compareContainer, + elements: elements, + style: this.reactomeStyle?.getStyleSheet(), + layout: { name: "preset" }, + }); + + this.cyCompare.elements('[?isFadeOut]').remove(); + this.cyCompare.elements('.Compartment').remove(); + this.cy!.nodes('.crossed').removeClass('crossed'); + + this.cyCompare!.on('viewport', () => this.syncViewports(this.cyCompare, compareContainer, this.cy, container)) + this.cy!.on('viewport', () => this.syncViewports(this.cy, container, this.cyCompare, compareContainer)) + + this.reactomeStyleCompare = new Style(compareContainer); + this.reactomeStyleCompare?.bindToCytoscape(this.cyCompare); + this.cyCompare.minZoom(this.cy!.minZoom()) + this.cyCompare.maxZoom(this.cy!.maxZoom()) + + setTimeout(() => { + this.syncViewports(this.cy!, container, this.cyCompare, compareContainer) + this.initialiseReplaceElements(); + }) + } + } + + + avoidSideEffect(m: () => any) { + this._ignore = true; + m(); + this._ignore = false; + } + + flagging = this.state.onChange.flag$.subscribe((value) => this.avoidSideEffect( + () => [this.cy, this.cyCompare].forEach(cy => {if (cy) {this.flag(value, cy)} }) + )); + selecting = this.state.onChange.select$.subscribe((value) => this.avoidSideEffect( + () => [this.cy, this.cyCompare].forEach(cy => {if (cy) {this.select(value, cy)} }) + )); + + private stateToDiagram() { + for (const cy of [this.cy, this.cyCompare].filter(cy => cy !== undefined)) { + this.flag(this.state.get('flag'), cy); + this.select(this.state.get("select"), cy); + } + } + + readonly classRegex = /class:(\w+)([!.]drug)?/ + + getElements(tokens: (string | number)[], cy: cytoscape.Core): cytoscape.CollectionArgument { + let elements: cytoscape.Collection; + + elements = cy.collection() + tokens.forEach(token => { + if (typeof token === 'string') { + // An empty token should be ignored. If this is not done, + // all objects will be selected since [acc=""] matches all elements without acc attribute. + if (token.trim().length === 0) return; + if (token.startsWith('R-')) { + elements = elements.or(`[graph.stId="${token}"]`) + } else { + const matchArray = token.match(this.classRegex); + if (matchArray) { + const [_, clazz, drug] = matchArray; + if (drug === '.drug') { // Drug physical entity + elements = elements.or(`.${clazz}`).and('.drug'); + } else if (drug === '!drug') { // Non drug physical entity + elements = elements.or(`.${clazz}`).not('.drug'); + } else { // Non physical entity + elements = elements.or(`.${clazz}`); + } + } + else if (this.usedbId) { + // It is possible dbId is encoded in string + elements = elements.or(`[reactomeId=${token}]`); + } + else { + elements = elements.or(`[acc=${token}]`) + } + } + } else { + elements = elements.or(`[acc=${token}]`).or(`[reactomeId=${token}]`) + } + }); + return elements; + } + + // Apparently the selected state is kept when a new diagram is loaded. + // This method is used to reset the state + resetState() { + this.state.set('select', ''); + this.state.set('flag', []); + } + + select(tokens: (string | number), cy: cytoscape.Core): cytoscape.CollectionArgument { + cy.elements(':selected').unselect(); + let selected = this.getElements([tokens], cy); + selected.select(); + if ("connectedNodes" in selected) { + selected = selected.add(selected.connectedNodes()); + } + + if (this._ignore) { + cy.animate({ + fit: { + eles: selected, + padding: 100 + }, + duration: 1000, + easing: "ease-in-out" + }) + } + + return selected; + } + + flag(accs: (string | number)[], cy: cytoscape.Core): cytoscape.CollectionArgument { + return this.flagElements(this.getElements(accs, cy), cy) + } + + flagElements(toFlag: cytoscape.CollectionArgument, cy: cytoscape.Core): cytoscape.CollectionArgument { + if (toFlag.nonempty()) { + cy.batch(() => { + this.setSubPathwayVisibility(false, cy); + cy.elements().removeClass('flag') + toFlag.addClass('flag') + .edges().style({'underlay-opacity': 1}) + }) + + return toFlag + } else { + cy.batch(() => { + this.setSubPathwayVisibility(true, cy); + cy.elements().removeClass('flag'); + }) + + return cy.collection() + } + } + + setSubPathwayVisibility(visible: boolean, cy: cytoscape.Core) { + const shadowNodes = cy.nodes('.Shadow'); + const shadowEdges = cy.edges('[?color]'); + const trivials = cy.elements('.trivial'); + + if (visible) { + shadowNodes.style({opacity: 1}) + trivials.style({opacity: 1}) + shadowEdges.addClass('shadow') + // The handlers bound to this graph: cy.data('reactome').interactivity is + // whichever graph its Style was bound to last. + const onZoomShadow = interactivityOf(cy)?.onZoom.shadow; + if (onZoomShadow) { + cy.on('zoom', onZoomShadow) + onZoomShadow() + } + } else { + shadowNodes.style({opacity: 0}) + shadowEdges.removeClass('shadow') + const onZoomShadow = interactivityOf(cy)?.onZoom.shadow; + if (onZoomShadow) cy.off('zoom', onZoomShadow) + trivials.style({opacity: 1}) + cy.edges().style({'underlay-opacity': 0}) + } + } + + + applyEvent(event: ReactomeEvent, affectedElements: cytoscape.NodeCollection | cytoscape.EdgeCollection) { + switch (event.type) { + case ReactomeEventTypes.hover: + affectedElements.addClass('hover'); + break; + case ReactomeEventTypes.leave: + affectedElements.removeClass('hover'); + break; + case ReactomeEventTypes.select: + affectedElements.select(); + break; + case ReactomeEventTypes.unselect: + affectedElements.unselect(); + break; + } + } + + + ratio = 0.384; + + replacedElements!: cytoscape.SingularElementArgument[]; + replacedElementsPosition: number[] = []; + + lastIndex = 0; + underlayPadding = 0; + +private updateReplacementVisibility() { + + // // Calculate the position of the element that is to the right of the separation + + const extent = this.cyCompare!.extent(); + let limitIndex = this.replacedElementsPosition.findIndex(x1 => x1 >= extent.x1); + if (limitIndex === -1) limitIndex = this.replacedElements.length; + + /// Alternative calculation. In theory more optimised, but seems worse when console is opened for some reason + + // const currentPos = this.cyCompare!.extent().x1; + // let limitIndex = this.lastIndex; + // let i = this.lastIndex; + // if (currentPos > this.lastPosition) { // Dragging to the right + // while (i >= 0 && this.replacedElementsPosition[i] < currentPos) i++; + // limitIndex = i; + // } else if (currentPos < this.lastPosition) { // Dragging to the left + // do i--; + // while (i < this.replacedElementsPosition.length && this.replacedElementsPosition[i] >= currentPos) + // limitIndex = i+1; + // } + // + // this.lastPosition = currentPos; + // --------- + + if (this.lastIndex !== limitIndex) { + // If at least one element is switched from left to right + if (limitIndex < this.lastIndex) this.replacedElements.slice(limitIndex, this.lastIndex) + .map(e => e.style('visibility', 'hidden')) // Hide the range of elements + .filter(e => e.is('.Shadow')) // And if it is an shadow + .forEach(shadow => shadow.data('edges').style('underlay-padding', 0)) // Hide as well the associated reaction underlay + // If at least one element is switched from right to left + if (limitIndex > this.lastIndex) this.replacedElements.slice(this.lastIndex, limitIndex) + .map(e => e.style('visibility', 'visible')) // Show the range of elements + .filter(e => e.is('.Shadow')) // And if it is an shadow + .forEach(shadow => shadow.data('edges').style('underlay-padding', this.underlayPadding)) // Show as well the associated reaction underlay + } + this.lastIndex = limitIndex + } + + syncing = false; + syncViewports = (source: cytoscape.Core, sourceContainer: HTMLElement, target: cytoscape.Core, targetContainer: HTMLElement) => { + if (this.syncing) return; + this.syncing = true; + this.updateReplacementVisibility(); + + const position = {...source.pan()}; + const sourceX = sourceContainer.getBoundingClientRect().x; + const targetX = targetContainer.getBoundingClientRect().x; + position.x += sourceX - targetX; + target.viewport({ + zoom: source.zoom(), + pan: position, + }) + this.syncing = false; + }; + + + updateStyle() { + this.cy ? setTimeout(() => this.reactomeStyle?.update(this.cy), 5) : null; + this.cyCompare ? setTimeout(() => this.reactomeStyle?.update(this.cyCompare), 5) : null; + this.legend ? setTimeout(() => this.reactomeStyle?.update(this.legend), 5) : null; + } + + compareDragging = false; + + dragStart() { + this.compareDragging = true; + } + + dragEnd() { + this.compareDragging = false; + } + + dragMove($event: MouseEvent, compareContainer: HTMLDivElement, container: HTMLDivElement) { + if (!this.compareDragging) return; + compareContainer.style['left'] = $event.x - container.getBoundingClientRect().x + 'px'; + this.cyCompare.resize() + this.syncViewports(this.cy!, this.cytoscapeContainer!.nativeElement, this.cyCompare!, this.compareContainer!.nativeElement); + } + + updateLegend() { + this.legend.resize() + this.legend.panningEnabled(true) + this.legend.zoomingEnabled(true) + this.legend.fit(this.legend.elements(), 2) + this.legend.panningEnabled(false) + this.legend.zoomingEnabled(false) + } + + // ----- Event Syncing ----- + + // stateToDiagramSub = this.state.state$.subscribe(() => this.stateToDiagram()); + + compareBackgroundSync = this.reactomeEvents$.pipe( + filter(() => this.comparing), + filter((e) => e.detail.cy !== this.legend) + ).subscribe(event => { + const src = event.detail.cy; + const tgt = src === this.cy ? this.cyCompare : this.cy; + + let replacedBy = event.detail.element.data('replacedBy'); + replacedBy = replacedBy || event.detail.element.data('replacement'); + replacedBy = replacedBy || (event.detail.element.data('isBackground') && !event.detail.element.data('isFadeOut') && event.detail.element.data('id')); + + if (!replacedBy) return; + + let replacements = tgt.getElementById(replacedBy); + if (event.detail.type === 'reaction') { + // Need to check if replaceBy is an object or a number + if (typeof replacedBy === 'object') { + // If replacedBy is an object, try to get its id property + replacedBy = replacedBy.reactomeId; + } + if (typeof replacedBy === 'number') // Looks like there is a bug in cytoscape. check for object will return all elements. + replacements = replacements.add(tgt.elements(`[reactionId=${replacedBy}]`)) + } + + this.applyEvent(event, replacements) + }); + + // interactorHandling = this.reactomeEvents$ + // .pipe( + // filter((e) => e.detail.cy !== this.legend), + // filter(e => [ReactomeEventTypes.open, ReactomeEventTypes.close].includes(e.type as ReactomeEventTypes)), + // filter(e => e.detail.type === 'Interactor'), + // ).subscribe(e => { + // this.interactorsService.addInteractorNodes(e.detail.element.nodes(), this.cy); + // this.reactomeStyle.interactivity.updateProteins(); + // this.reactomeStyle.interactivity.onZoom(); + // } + // ); + + diagram2legend = this.reactomeEvents$.pipe( + filter((e) => e.detail.cy !== this.legend), + ).subscribe(event => { + const classes = event.detail.element.classes(); + let matchingElement: cytoscape.NodeCollection | cytoscape.EdgeCollection = this.legend.elements(`.${classes[0]}`); + + if (event.detail.type === 'PhysicalEntity') { + if (classes.includes('drug')) matchingElement = matchingElement.nodes('.drug') + else matchingElement = matchingElement.not('.drug') + } else if (event.detail.type === 'reaction') { + const reaction = event.detail.element.nodes('.reaction'); + matchingElement = this.legend.nodes(`.${reaction.classes()[0]}`).first() + matchingElement = matchingElement.add(matchingElement.connectedEdges()) + } + + this._ignore = true; + this.applyEvent(event, matchingElement); + this._ignore = false; + }); + + diagramSelect2state = this.reactomeEvents$.pipe( + filter((e) => e.detail.cy !== this.legend), + delay(0) + ).subscribe(e => { + if (e.type !== ReactomeEventTypes.select) return; + let elements: cytoscape.NodeSingular = e.detail.element; + if (e.detail.type === 'reaction') { + elements = e.detail.cy.elements('node.reaction:selected') + } + let reactomeIds = [] + if (this.usedbId) + reactomeIds = elements.map(el => el.data('reactomeId')); + else + reactomeIds = elements.map(el => el.data('graph.stId')); + // Make sure reactomeIds don't contain duplicated element + const uniqueSet = new Set(reactomeIds); + reactomeIds = Array.from(uniqueSet); + this.state.set('select', reactomeIds[0] || ''); + } + ); + + legend2state = this.reactomeEvents$.pipe( + filter((e) => e.detail.cy === this.legend), + filter(() => !this._ignore), + ).subscribe((e) => { + const event = e as ReactomeEvent; + const classes = event.detail.element.classes(); + let matchingElement: cytoscape.NodeCollection | cytoscape.EdgeCollection = this.cy.elements(`.${classes[0]}`); + + if (event.detail.type === 'PhysicalEntity' || event.detail.type === 'Pathway') { + if (classes.includes('drug')) matchingElement = matchingElement.nodes('.drug') + else matchingElement = matchingElement.not('.drug') + } else if (event.detail.type === 'reaction') { + const reaction = event.detail.element.nodes('.reaction'); + matchingElement = this.cy.nodes(`.${reaction.classes()[0]}`) + matchingElement = matchingElement.add(matchingElement.connectedEdges()) + } + + switch (event.type) { + case ReactomeEventTypes.select: + this.state.set('flag', ['class:' + classes[0] + (classes.includes('drug') ? '.' : '!') + 'drug']) + this.stateToDiagram(); + break; + case ReactomeEventTypes.unselect: + this.state.set('flag', []) + this.stateToDiagram(); + break; + case ReactomeEventTypes.hover: + matchingElement.addClass('hover') + break; + case ReactomeEventTypes.leave: + matchingElement.removeClass('hover') + break; + } + }); + + logProteins() { + // eslint-disable-next-line no-console -- logging is what it is called for + console.debug(new Set(this.cy.nodes(".Protein").map(node => node.data("acc") || node.data("iAcc")))) + } + + /** + * Somehow DiagramService cannot be exported for use out of this package. Therefore, use this method to get this instance. + */ + getDiagramService(): DiagramService { + return this.diagram; + } +} diff --git a/projects/ngx-reactome-diagram/src/lib/model/diagram.model.ts b/projects/ngx-reactome-diagram/src/lib/model/diagram.model.ts new file mode 100644 index 0000000..adb544c --- /dev/null +++ b/projects/ngx-reactome-diagram/src/lib/model/diagram.model.ts @@ -0,0 +1,137 @@ +export interface Position { + x: number; + y: number; +} + +export interface Prop extends Position { + width: number; + height: number; +} + +export type Rectangle = { left: number, right: number, top: number, bottom: number } + +export type Segment = { from: Position, to: Position }; + +export interface Diagram { + displayName: string; + edges: Edge[]; + nodes: Node[]; + compartments: Compartment[]; + links: Link[]; + /** + * The list of contained shadows(subpathways) + */ + shadows: SubPathway[]; + forNormalDraw: boolean; +} + + +export interface ReactionShape { + a: Position; + b: Position; + centre: Position; + type: string; +} + +export interface ConnectorHolder { + [k: string]: EdgeConnector[] + + catalysts: EdgeConnector[]; + inputs: EdgeConnector[]; + outputs: EdgeConnector[]; + inhibitors: EdgeConnector[]; + activators: EdgeConnector[]; +} + +export type Entity = { + id: number; + reactomeId: number; + displayName: string; + position: Position; + renderableClass: string; + schemaClass: string; + isDisease: boolean; + isFadeOut?: boolean; + isCrossed?: boolean; + // Added by service + rect?: Rectangle; + isBackground?: boolean; +} + +// reactions +export type Edge = { + reactionShape: ReactionShape; + reactionType: string; + segments: Segment[]; +} & ConnectorHolder & Entity; + + +export interface EdgeConnector { + id: number, + points?: Position[], + stoichiometry?: number + isFadeOut?: boolean +} + +export interface NodeConnector { + edgeId: number; + type: 'INPUT' | 'OUTPUT' | 'CATALYST' | 'ACTIVATOR' | 'INHIBITOR'; + segments: Segment[] + stoichiometry: { value: number } + endShape: { centre: Position } + isFadeOut?: boolean +} + +// entities +export interface Node extends Entity { + prop: Prop, + connectors: NodeConnector[] + interactorsSummary: InteractorsSummary; + nodeAttachments?: Attachment[]; + needDashedBorder?: boolean; + trivial?: boolean; +} + +export interface Compartment extends Entity { + componentIds: number[]; + prop: Prop, + insets: Prop; + textPosition: Position; +} + +export interface Link { + id: number; + inputs: EdgeConnector[]; + outputs: EdgeConnector[]; + renderableClass: string; + segments: Segment[]; + isFadeOut?: boolean; + isDisease?: boolean; +} + +interface SubPathway extends Entity { + prop: Prop; + colour: string; +} + + +interface InteractorsSummary { + shape: Shape; + type: string; +} + +interface Shape { + type: string; + a: Position; + b: Position; + centre: Position; + empty: boolean; + s: string; +} + +interface Attachment { + description: string; + label: string; + reactomeId: number; + shape: Shape +} diff --git a/projects/ngx-reactome-diagram/src/lib/model/graph.model.ts b/projects/ngx-reactome-diagram/src/lib/model/graph.model.ts new file mode 100644 index 0000000..bf577b0 --- /dev/null +++ b/projects/ngx-reactome-diagram/src/lib/model/graph.model.ts @@ -0,0 +1,32 @@ + +export interface Graph { + dbId: number; + stId: string; + speciesName: string; + edges: Edge[]; + nodes: Node[]; + subpathways: SubPathway[]; +} + +export type Entity = { + dbId: number; + stId: string + displayName: string; +} + +export interface Edge extends Entity { + schemaClass: string; +} + +export interface Node extends Entity { + diagramIds?: number[]; + identifier: string + parents: number[]; + children: number[]; + schemaClass: string; + referenceType: string +} + +export interface SubPathway extends Entity { + events: number[] +} diff --git a/projects/ngx-reactome-diagram/src/lib/services/dark.service.ts b/projects/ngx-reactome-diagram/src/lib/services/dark.service.ts new file mode 100644 index 0000000..4ac6a6e --- /dev/null +++ b/projects/ngx-reactome-diagram/src/lib/services/dark.service.ts @@ -0,0 +1,41 @@ +import {Injectable} from '@angular/core'; +import {BehaviorSubject} from "rxjs"; + +@Injectable({ + providedIn: 'root' +}) +export class DarkService { + + private _body: HTMLBodyElement | null; + private _isDark: boolean = false; + + private readonly $_dark = new BehaviorSubject(this._isDark); + public readonly $dark = this.$_dark.asObservable(); + + constructor() { + this._body = document.querySelector('body'); + + // Update theme if other tabs are changing it + // window.addEventListener('storage', (e) => { + // if (e.key === 'is-dark') this.isDark = JSON.parse(e.newValue || 'false'); + // }); + + const localValue = localStorage.getItem('is-dark'); + if (localValue) this.isDark = JSON.parse(localValue); + else if (window.matchMedia('(prefers-color-scheme)').media !== 'not all') { + this.isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + } + } + + get isDark(): boolean { + return this._isDark; + } + + set isDark(value: boolean) { + this._isDark = value; + localStorage.setItem('is-dark', JSON.stringify(value)); + if (value) this._body?.classList.add('dark'); + else this._body?.classList.remove('dark'); + this.$_dark.next(this._isDark); + } +} diff --git a/projects/ngx-reactome-diagram/src/lib/services/diagram-state.service.spec.ts b/projects/ngx-reactome-diagram/src/lib/services/diagram-state.service.spec.ts new file mode 100644 index 0000000..34706f4 --- /dev/null +++ b/projects/ngx-reactome-diagram/src/lib/services/diagram-state.service.spec.ts @@ -0,0 +1,57 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter, Router } from '@angular/router'; + +import { DiagramStateService } from './diagram-state.service'; + +describe('DiagramStateService', () => { + let service: DiagramStateService; + let router: Router; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [provideRouter([])] }); + service = TestBed.inject(DiagramStateService); + router = TestBed.inject(Router); + }); + + it('reads what to flag from the address, numbers as numbers', async () => { + await router.navigateByUrl('/?FLG=PKM,123'); + expect(service.get('flag')).toEqual(['PKM', 123]); + }); + + it('tells its subscribers when the address changes the state', async () => { + const flagged: unknown[] = []; + service.onChange.flag$.subscribe((flag) => flagged.push(flag)); + await router.navigateByUrl('/?FLG=PKM'); + expect(flagged.at(-1)).toEqual(['PKM']); + }); + + it('tells its subscribers what it was set to, once the address has it', async () => { + const selected: unknown[] = []; + service.onChange.select$.subscribe((select) => selected.push(select)); + service.set('select', 'R-HSA-2'); + await vi.waitFor(() => expect(router.url).toContain('select=R-HSA-2')); + // The diagram selects every copy of an entity, and fits the view to them, + // from this -- not from the click that set it. + await vi.waitFor(() => expect(selected.at(-1)).toBe('R-HSA-2')); + }); + + it('changes neither the address nor its subscribers while the router is blocked', async () => { + const selected: unknown[] = []; + service.blockRouterChange = true; + service.onChange.select$.subscribe((select) => selected.push(select)); + service.set('select', 'R-HSA-2'); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(router.url).not.toContain('select'); + expect(selected).toEqual(['']); + expect(service.get('select')).toBe('R-HSA-2'); + }); + + it('does not tell them when the address leaves the state as it was', async () => { + let emitted = 0; + await router.navigateByUrl('/?select=R-HSA-1'); + service.state$.subscribe(() => emitted++); + await router.navigateByUrl('/?select=R-HSA-1&unrelated=1'); + // Once on subscribing, and not again. + expect(emitted).toBe(1); + }); +}); diff --git a/projects/ngx-reactome-diagram/src/lib/services/diagram-state.service.ts b/projects/ngx-reactome-diagram/src/lib/services/diagram-state.service.ts new file mode 100644 index 0000000..abc2a7a --- /dev/null +++ b/projects/ngx-reactome-diagram/src/lib/services/diagram-state.service.ts @@ -0,0 +1,115 @@ +import {Injectable} from '@angular/core'; +import {ActivatedRoute, Router} from "@angular/router"; +import {BehaviorSubject, distinctUntilChanged, map, Observable} from "rxjs"; +import {isArray} from "lodash"; + + +export interface UrlParam { + value: T + otherTokens?: string[] +} + +export type State = { + [token: string]: UrlParam + select: UrlParam<(string | number)> + flag: UrlParam<(string | number)[]> + path: UrlParam + flagInteractors: UrlParam + overlay: UrlParam + analysis: UrlParam + analysisProfile: UrlParam +}; + +type ObservableState = { [K in keyof State as `${K & string}$`]: Observable }; + +@Injectable({ + providedIn: 'root' +}) +export class DiagramStateService { + + private ignore = false; + blockRouterChange = false; + + private state: State = { + select: {otherTokens: ['SEL'], value: ''}, + flag: {otherTokens: ['FLG'], value: []}, + path: {otherTokens: ['PATH'], value: []}, + flagInteractors: {otherTokens: ['FLGINT'], value: false}, + overlay: {value: ''}, + analysis: {value: null, otherTokens: ['ANALYSIS']}, + analysisProfile: {value: null}, + }; + + private _state$ = new BehaviorSubject(this.state); + public state$ = this._state$.asObservable(); + public onChange: ObservableState = Object.keys(this.state) + .reduce((properties, prop: keyof State) => { + properties[`${prop}$`] = this.state$.pipe( + map(state => state[prop].value), + distinctUntilChanged((v1, v2) => v1?.toString() === v2?.toString()), + // share() + ) + return properties; + }, {} as ObservableState); + + constructor(route: ActivatedRoute, private router: Router) { + route.queryParamMap.subscribe(params => { + if (this.ignore) return; + let change = false; + for (const mainToken in this.state) { + const param = this.state[mainToken]; + const tokens: string[] = [mainToken, ...param.otherTokens || []]; + const token = tokens.find(token => params.has(token)); + if (token) { + const formerValue = param.value; + if (isArray(param.value)) { + const rawValue = params.get(token)!; + // A database id is a number; anything else, such as a gene name, a string. + param.value = rawValue.split(',').map(v => /^\d+$/.test(v) ? parseInt(v) : v); + } else { + param.value = params.get(token)!; + } + // Changed when it differs from before. This was inverted, so a state + // read from the address reached no one and an unchanged one did. + change = change || formerValue?.toString() !== param.value?.toString(); + } + } + if (change) this._state$.next(this.state); + }); + } + + get(token: T): State[T]['value'] { + return this.state[token].value + } + + set(token: T, value: State[T]['value']): void { + this.state[token].value = value; + // N.B. by GW: Not sure why this.ignore is here. Nothing changes here!!! + // Most likely this.ignore = true below + this.ignore = false; + if (!this.blockRouterChange) { + // Subscribers hear of it once the address has it. They used to through + // the address itself, by way of the inverted change check that read an + // unchanged value as a change. + void this.onPropertyModified().then(() => { + this.ignore = false; + this._state$.next(this.state); + }); + } + } + + onPropertyModified() { + return this.router.navigate([], { + queryParams: { + ...Object.entries(this.state) + .filter(([_token, param]) => param.value && param.value.length !== 0) + .reduce((acc, [token, param]) => ({ + ...acc, + [token]: Array.isArray(param.value) ? param.value.join(',') : param.value + }), {}) + } + }) + } + + +} diff --git a/projects/ngx-reactome-diagram/src/lib/services/diagram.service.spec.ts b/projects/ngx-reactome-diagram/src/lib/services/diagram.service.spec.ts new file mode 100644 index 0000000..d59ba3e --- /dev/null +++ b/projects/ngx-reactome-diagram/src/lib/services/diagram.service.spec.ts @@ -0,0 +1,36 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; + +import { DIAGRAM_CONFIG_TOKEN, DiagramService } from './diagram.service'; + +function setUp(config: unknown) { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: DIAGRAM_CONFIG_TOKEN, useValue: config }, + ], + }); + return { + service: TestBed.inject(DiagramService), + http: TestBed.inject(HttpTestingController), + }; +} + +describe('DiagramService', () => { + it('loads a diagram and its graph from the configured address', () => { + const { service, http } = setUp({ diagramUrl: 'https://example.org/diagram' }); + service.getDiagram('R-HSA-1').subscribe(); + http.expectOne('https://example.org/diagram/R-HSA-1.json'); + http.expectOne('https://example.org/diagram/R-HSA-1.graph.json'); + }); + + it('loads from its default address when the configuration does not give one', () => { + const { service, http } = setUp({}); + service.getDiagram('R-HSA-1').subscribe(); + const requested = http.match(() => true).map((r) => r.request.url); + expect(requested.length).toBe(2); + for (const url of requested) expect(url).toMatch(/^https:\/\//); + }); +}); diff --git a/projects/ngx-reactome-diagram/src/lib/services/diagram.service.ts b/projects/ngx-reactome-diagram/src/lib/services/diagram.service.ts new file mode 100644 index 0000000..c0a275a --- /dev/null +++ b/projects/ngx-reactome-diagram/src/lib/services/diagram.service.ts @@ -0,0 +1,802 @@ +import {Inject, Injectable, InjectionToken} from '@angular/core'; +import {forkJoin, map, Observable, of} from "rxjs"; +import {HttpClient} from "@angular/common/http"; +import {Diagram, Edge, Node, NodeConnector, Position, Prop, Rectangle} from "../model/diagram.model"; +import {Edge as GraphEdge, Graph, Node as GraphNode} from "../model/graph.model"; +import {Style, Types} from "ngx-reactome-cytoscape-style"; +import legend from "../../assets/json/legend.json" +import {array} from "vectorious"; + +import cytoscape from "cytoscape"; +import cytoscapeFcose, {FcoseLayoutOptions} from "cytoscape-fcose"; +type NodeDefinition = Types.NodeDefinition; +type ReactionDefinition = Types.ReactionDefinition; +type EdgeTypeDefinition = Types.EdgeTypeDefinition; + +cytoscape.use(cytoscapeFcose) + + +type RelativePosition = { distances: number[], weights: number[] }; + +// Above the service, which names it in its constructor's @Inject: declared +// after it, the decorator read it before it existed. +export const DIAGRAM_CONFIG_TOKEN = new InjectionToken('DIAGRAM_CONFIG_TOKEN'); + +const posToStr = (edge: Edge, pos: Position) => `${edge.id}-${pos.x},${pos.y}` + +const pointToStr = (point: Position) => `${point.x};${point.y}`; + +const scale = (pos: T, scale = 2): T => { + if (typeof pos === 'number') return pos * scale as T + return { + x: pos.x * scale, + y: pos.y * scale + } as T +} + +const equal = (pos1: Position, pos2: Position) => pos1.x === pos2.x && pos1.y === pos2.y; + +const avg = (positions: Position[]): Position => { + const sum = {x: 0, y: 0}; + positions.forEach(pos => { + sum.x += pos.x; + sum.y += pos.y; + }); + sum.x /= positions.length; + sum.y /= positions.length; + return sum; +} +const squaredDist = (pos1: Position, pos2: Position) => { + return Math.pow(pos2.x - pos1.x, 2) + Math.pow(pos2.y - pos1.y, 2) +} + +const dist = (pos1: Position, pos2: Position) => Math.sqrt(squaredDist(pos1, pos2)) + +const closestToAverage = (positions: Position[]): Position => { + const average = avg(positions); + let closest = positions[0]; + let min = squaredDist(closest, average); + for (let i = 1; i < positions.length; i++) { + const pos = positions[i]; + const dist = squaredDist(pos, average); + if (dist < min) { + min = dist; + closest = pos + } + } + return closest; +} + +@Injectable({ + providedIn: 'root' +}) +export class DiagramService { + + extraLine: Map = new Map(); + reverseExtraLine: Map = new Map(); + + constructor(private http: HttpClient, @Inject(DIAGRAM_CONFIG_TOKEN) private config: any) { + // This is a service object. Therefore, we have to extract configure here if any + // The default stands when the configuration does not name an address; it + // was overwritten with undefined, and every request went to "undefined/...". + this.diagramUrl = this.config?.diagramUrl ?? this.diagramUrl; + } + + nodeTypeMap = new Map([ + ['Gene', ['Gene', 'PhysicalEntity']], + ['RNA', ['RNA', 'PhysicalEntity']], + ['Protein', ['Protein', 'PhysicalEntity']], + ['Entity', ['GenomeEncodedEntity', 'PhysicalEntity']], + ['Complex', ['Complex', 'PhysicalEntity']], + ['EntitySet', ['EntitySet', 'PhysicalEntity']], + ['Chemical', ['Molecule', 'PhysicalEntity']], + ['Cell', ['Cell', 'PhysicalEntity']], + + ['ProteinDrug', ['Protein', 'PhysicalEntity', 'drug']], + ['ComplexDrug', ['Complex', 'PhysicalEntity', 'drug']], + ['ChemicalDrug', ['Molecule', 'PhysicalEntity', 'drug']], + ['RNADrug', ['RNA', 'PhysicalEntity', 'drug']], + ['EntitySetDrug', ['EntitySet', 'PhysicalEntity', 'drug']], + + ['ProcessNode', ['SUB', 'Pathway']], + ['EncapsulatedNode', ['Interacting', 'Pathway']] + ] + ) + + reactionTypeMap = new Map([ + [undefined, ['transition', 'reaction']], + ['transition', ['transition', 'reaction']], + ['Transition', ['transition', 'reaction']], + ['Process', ['transition', 'reaction']], + + ['binding', ['association', 'reaction']], + ['Association', ['association', 'reaction']], + + ['dissociation', ['dissociation', 'reaction']], + ['Dissociation', ['dissociation', 'reaction']], + + ['omitted', ['omitted', 'reaction']], + ['Omitted Process', ['omitted', 'reaction']], + + ['uncertain', ['uncertain', 'reaction']], + ['Uncertain Process', ['uncertain', 'reaction']], + ] + ) + + edgeTypeMap = new Map([ + ['INPUT', ['consumption', 'incoming', 'reaction']], + ['ACTIVATOR', ['positive-regulation', 'incoming', 'reaction']], + ['REQUIRED', ['positive-regulation', 'incoming', 'reaction']], + ['INHIBITOR', ['negative-regulation', 'incoming', 'reaction']], + ['CATALYST', ['catalysis', 'incoming', 'reaction']], + ['OUTPUT', ['production', 'outgoing', 'reaction']], + ] + ) + + edgeTypeToStr = new Map([ + ['INPUT', '-'], + ['ACTIVATOR', '+'], + ['REQUIRED', '+>'], + ['INHIBITOR', '|'], + ['CATALYST', 'o'], + ['OUTPUT', '>'], + ] + ) + + + linkClassMap = new Map([ + ['EntitySetAndMemberLink', ['set-to-member', 'incoming']], + ['EntitySetAndEntitySetLink', ['set-to-member', 'incoming']], + ['Interaction', ['production', 'outgoing']], + ['FlowLine', ['production', 'outgoing']] + ]) + + + random(min: number, max: number) { + return Math.floor((Math.random()) * (max - min + 1)) + min; + } + + pick(values: T[]): T { + return values[this.random(0, values.length - 1)]; + } + + private readonly COMPARTMENT_SHIFT = 35; + + // Where diagrams are loaded from, unless the configuration says otherwise: + // Reactome's public release. + private diagramUrl: string = 'https://reactome.org/download/current/diagram'; + + public getLegend(): Observable { + return of(legend) + } + + //TODO: Don't do anything if id is undefined here - GW + public getDiagram(id: number | string): Observable { + return forkJoin({ + diagram: this.http.get(`${this.diagramUrl}/${id}.json`), + graph: this.http.get(`${this.diagramUrl}/${id}.graph.json`) + }).pipe( + map((response) => { + + const data = response.diagram + const graph = response.graph + + + const idToEdges = new Map(data.edges.map(edge => [edge.id, edge])); + const idToNodes = new Map(data.nodes.map(node => [node.id, node])); + const reactomeIdToEdge = new Map( + [ + // ...data.nodes.map(node => [node.reactomeId, node]), + ...data.edges.map(edge => [edge.reactomeId, edge]) + ] as [number, Edge][] + ); + + const edgeIds = new Map(); + const forwardArray = data.edges.flatMap(edge => edge.segments.map(segment => [posToStr(edge, scale(segment.from)), scale(segment.to)])) as [string, Position][]; + this.extraLine = new Map(forwardArray); + console.assert(forwardArray.length === this.extraLine.size, "Some edge data have been lost because 2 segments are starting from the same point") + + const backwardArray = data.edges.flatMap(edge => edge.segments.map(segment => [posToStr(edge, scale(segment.to)), scale(segment.from)])) as [string, Position][]; + this.reverseExtraLine = new Map(backwardArray); + console.assert(backwardArray.length == this.reverseExtraLine.size, "Some edge data have been lost because 2 segments are ending at the same point") + + + const subpathwayIds = new Set(data.shadows.map((shadow) => shadow.reactomeId)) + + const eventIdToSubPathwayId = new Map(graph.subpathways?.flatMap(subpathway => subpathway.events + .map(event => [event, subpathway.dbId]) + .filter(entry => subpathwayIds.has(entry[1]))) as [number, number][] || []) + + const subpathwayIdToEventIds = new Map(graph.subpathways?.map(subpathway => [subpathway.dbId, subpathway.events])); + + // create a node id - graph node mapping + const dbIdToGraphNode = new Map(graph.nodes.map(node => [node.dbId, node])) + const mappingList: [number, GraphNode][] = graph.nodes.flatMap(node => { + if (node.children?.length === 1) { + return node.diagramIds?.map(id => [id, dbIdToGraphNode.get(node.children[0])]).filter(entry => entry[1] !== undefined) as [number, GraphNode][] + } else return node.diagramIds?.map(id => [id, node]) as [number, GraphNode][] + }).filter(entry => entry !== undefined); + + const idToGraphNodes = new Map([...mappingList]); + const idToGraphEdges = new Map(graph.edges.map(edge => [edge.dbId, edge])); + + const dbIdToGraphEdge = new Map(graph.edges.map(edge => [edge.dbId, edge])) + + const hasFadeOut = data.nodes.some(node => node.isFadeOut); + const normalNodes = data.nodes.filter(node => node.isFadeOut); + const specialNodes = data.nodes.filter(node => !node.isFadeOut); + const posToNormalNode = new Map(normalNodes.map(node => [pointToStr(node.position), node])); + const posToSpecialNode = new Map(specialNodes.map(node => [pointToStr(node.position), node])); + + const normalEdges = data.edges.filter(edge => edge.isFadeOut); + const specialEdges = data.edges.filter(edge => !edge.isFadeOut); + const posToNormalEdge = new Map(normalEdges.map(edge => [pointToStr(edge.position), edge])); + const posToSpecialEdge = new Map(specialEdges.map(edge => [pointToStr(edge.position), edge])); + + //compartment nodes + const compartmentNodes: cytoscape.NodeDefinition[] = data?.compartments.flatMap(item => { + const propToRects = (prop: Prop): { [p: string]: number } => ({ + left: scale(prop.x), + top: scale(prop.y), + right: scale(prop.x + prop.width), + bottom: scale(prop.x + prop.height), + }) + + const innerCR = 10; + let outerCR + if (item.insets) { + const rects = [propToRects(item.prop), propToRects(item.insets)] + outerCR = Object.keys(rects[0]).reduce((smallest, key) => Math.min(smallest, Math.abs(rects[0][key] - rects[1][key])), Number.MAX_SAFE_INTEGER); + outerCR = innerCR + Math.min(outerCR, 100) + } + + const layers: cytoscape.NodeDefinition[] = [ + { + data: { + id: item.id + '-outer', + displayName: item.displayName, + textX: scale(item.textPosition.x - (item.prop.x + item.prop.width)) + this.COMPARTMENT_SHIFT, + textY: scale(item.textPosition.y - (item.prop.y + item.prop.height)) + this.COMPARTMENT_SHIFT, + width: scale(item.prop.width), + height: scale(item.prop.height), + radius: outerCR + }, + classes: ['Compartment', 'outer'], + position: scale(item.position), + selectable: false, + } + ]; + + if (item.insets) { + layers.push({ + data: { + id: item.id + '-inner', + width: scale(item.insets.width), + height: scale(item.insets.height), + radius: innerCR + }, + classes: ['Compartment', 'inner'], + position: scale({x: item.insets.x + item.insets.width / 2, y: item.insets.y + item.insets.height / 2}), + selectable: false, + }) + } + return layers; + }); + + const replacementMap = new Map(); + + //reaction nodes + const reactionNodes: cytoscape.NodeDefinition[] = data?.edges.map(item => { + let replacement, replacedBy; + if (item.isFadeOut) { + replacedBy = posToSpecialEdge.get(pointToStr(item.position))?.id.toString() || specialEdges.find(edge => squaredDist(scale(edge.position), scale(item.position)) < 5 ** 2)?.id.toString(); + if (replacedBy) { + replacementMap.set(item.id.toString(), replacedBy) + replacementMap.set(replacedBy, item.id.toString()) + } + } + if (!item.isFadeOut) replacement = posToNormalEdge.get(pointToStr(item.position)) || normalEdges.find(edge => squaredDist(scale(edge.position), scale(item.position)) < 5 ** 2)?.id.toString(); + return ({ + data: { + id: item.id + '', + // displayName: item.displayName, + inputs: item.inputs, + output: item.outputs, + isFadeOut: item.isFadeOut, + isBackground: item.isFadeOut, + reactomeId: item.reactomeId, + reactionId: item.id, + graph: idToGraphEdges.get(item.reactomeId), + replacement, replacedBy + }, + classes: this.reactionTypeMap.get(item.reactionType), + position: scale(item.position) + }); + }); + + + //entity nodes + const entityNodes: cytoscape.NodeDefinition[] = data?.nodes.flatMap(item => { + const classes = [...(this.nodeTypeMap.get(item.renderableClass) || [item.renderableClass.toLowerCase()])]; + let replacedBy: string | undefined; + let replacement: string | undefined; + if (item.isDisease) classes.push('disease'); + if (item.isCrossed) classes.push('crossed'); + if (item.trivial) classes.push('trivial'); + if (item.needDashedBorder) classes.push('loss-of-function'); + if (item.isFadeOut) { + replacedBy = posToSpecialNode.get(pointToStr(item.position))?.id.toString() + if (!replacedBy) { + replacedBy = specialNodes.find(node => overlapLimited(item, node, 0.8))?.id.toString(); + } + if (replacedBy) { + replacementMap.set(item.id.toString(), replacedBy) + replacementMap.set(replacedBy, item.id.toString()) + } + } + if (!item.isFadeOut) replacement = posToNormalNode.get(pointToStr(item.position))?.id.toString() //|| normalNodes.find(node => overlap(item, node))?.id.toString(); + if (classes.some(clazz => clazz === 'RNA')) item.prop.height -= 10; + if (classes.some(clazz => clazz === 'Cell')) item.prop.height /= 2; + + const isBackground = item.isFadeOut || classes.some(clazz => clazz === 'Pathway') || item.connectors.some(connector => connector.isFadeOut); + item.isBackground = isBackground; + let html = undefined; + const width = scale(item.prop.width); + const height = scale(item.prop.height); + const uniprotId = idToGraphNodes.get(item.id)?.identifier; + if (classes.some(clazz => clazz === 'Protein')) { + html = this.getStructureVideoHtml({...item, type: 'Protein'}, width, height, uniprotId); + } + if (isBackground && !item.isFadeOut) { + replacementMap.set(item.id.toString(), item.id.toString()) + } + const isFadeOut = !item.isCrossed && item.isFadeOut; + const nodes: cytoscape.NodeDefinition[] = [ + { + data: { + id: item.id + '', + reactomeId: item.reactomeId, + displayName: item.displayName.replace(/([/,:;-])/g, "$1\u200b"), + height: height, + width: width, + graph: idToGraphNodes.get(item.id), + acc: uniprotId, + html, + isFadeOut, + isBackground, + replacement, + replacedBy + }, + classes: classes, + position: scale(item.position) + } + ]; + if (item.nodeAttachments) { + nodes.push(...item.nodeAttachments.map(ptm => ({ + data: { + id: item.id + '-' + ptm.reactomeId, + reactomeId: ptm.reactomeId, + nodeId: item.id, + nodeReactomeId: item.reactomeId, + displayName: ptm.label, + height: scale(ptm.shape.b.y - ptm.shape.a.y), + width: scale(ptm.shape.b.x - ptm.shape.a.x), + isFadeOut, + isBackground, + replacement, + replacedBy + }, + classes: "Modification", + position: scale(ptm.shape.centre) + }))) + } + return nodes + }); + + //sub pathways + const shadowNodes: cytoscape.NodeDefinition[] = data?.shadows.map(item => { + return { + data: { + id: item.id + '', + displayName: item.displayName, + height: scale(item.prop.height), + width: scale(item.prop.width), + reactomeId: item.reactomeId, + }, + classes: ['Shadow'], + position: closestToAverage(subpathwayIdToEventIds.get(item.reactomeId)!.map(reactionId => reactomeIdToEdge.get(reactionId)!).map(edge => scale(edge!.position))) + } + }); + + avoidOverlap(shadowNodes); + + const T = 4; + const ARROW_MULT = 1.5; + const EDGE_MARGIN = 6; + const REACTION_RADIUS = 3 * T; + const MIN_DIST = EDGE_MARGIN; + + + /** + * iterate nodes connectors to get all edges information based on the connector type. + * + */ + const edges: cytoscape.EdgeDefinition[] = + data.nodes.flatMap(node => { + return node.connectors.map(connector => { + const reaction = idToEdges.get(connector.edgeId)!; + + const reactionP = scale(reaction.position); + const nodeP = scale(node.position); + + const [source, target] = connector.type !== 'OUTPUT' ? + [node, reaction] : + [reaction, node]; + + const sourceP = scale(source.position); + const targetP = scale(target.position); + + const points = connector.segments + .flatMap((segment, i) => i === 0 ? [segment.from, segment.to] : [segment.to]) + .map(pos => scale(pos)); + if (connector.type === 'OUTPUT') points.reverse(); + if (points.length === 0) points.push(reactionP); + + this.addEdgeInfo(reaction, points, 'backward', sourceP); + this.addEdgeInfo(reaction, points, 'forward', targetP); + + let [from, to] = [points.shift()!, points.pop()!] + from = from ?? nodeP; // Quick fix to avoid problem with reaction without visible outputs like R-HSA-2424252 in R-HSA-1474244 + to = to ?? reactionP; // Quick fix to avoid problem with reaction without visible outputs like R-HSA-2424252 in R-HSA-1474244 + if (connector.type === 'CATALYST') { + to = scale(connector.endShape.centre); + } + + // points = addRoundness(from, to, points); + const relatives = this.absoluteToRelative(from, to, points); + + const classes = [...this.edgeTypeMap.get(connector.type)!]; + if (reaction.isDisease) classes.push('disease'); + if (node.trivial) classes.push('trivial'); + if (eventIdToSubPathwayId.has(reaction.reactomeId)) classes.push('shadow'); + + let d = dist(from, to); + if (equal(from, reactionP) || equal(to, reactionP)) d -= REACTION_RADIUS; + if (classes.includes('positive-regulation') || classes.includes('catalysis') || classes.includes('production')) d -= ARROW_MULT * T; + // console.assert(d > MIN_DIST, `The edge between reaction: R-HSA-${reaction.reactomeId} and entity: R-HSA-${node.reactomeId} in pathway ${id} has a visible length of ${d} which is shorter than ${MIN_DIST}`) + console.assert(d > MIN_DIST, `${id}\t${data.displayName}\t${hasFadeOut}\tR-HSA-${reaction.reactomeId}\tR-HSA-${node.reactomeId}\thttps://release.reactome.org/PathwayBrowser/#/${id}&SEL=R-HSA-${reaction.reactomeId}&FLG=R-HSA-${node.reactomeId}\thttps://reactome-pwp.github.io/PathwayBrowser/${id}?select=${reaction.reactomeId}&flag=${node.reactomeId}`) + + let replacement, replacedBy; + if (connector.isFadeOut) { + // First case: same node is used both special and normal context + // replacedBy = node.connectors.find(otherConnector => otherConnector !== connector && !otherConnector.isFadeOut && samePoint(idToEdges.get(otherConnector.edgeId)!.position, reaction.position))?.edgeId; + // Second case: different nodes are used between special and normal context + // replacedBy = replacedBy || (posToSpecialNode.get(pointToStr(node.position)) && posToSpecialEdge.get(pointToStr(reaction.position)))?.id; + + replacedBy = replacementMap.get(node.id.toString()) && replacementMap.get(reaction.id.toString()) + } + if (!connector.isFadeOut) { + // First case: same node is used both special and normal context + replacement = node.connectors.find(otherConnector => otherConnector !== connector && otherConnector.isFadeOut && samePoint(idToEdges.get(otherConnector.edgeId)!.position, reaction.position))?.edgeId; + // Second case: different nodes are used between special and normal context + replacement = replacement || (posToNormalNode.get(pointToStr(node.position)) && posToNormalEdge.get(pointToStr(reaction.position)))?.id; + } + const edge: cytoscape.EdgeDefinition = { + data: { + id: this.getEdgeId(source, connector, target, edgeIds), + graph: dbIdToGraphEdge.get(reaction.reactomeId), + source: source.id + '', + target: target.id + '', + stoichiometry: connector.stoichiometry.value, + weights: relatives.weights.join(" "), + distances: relatives.distances.join(" "), + sourceEndpoint: this.endpoint(sourceP, from), + targetEndpoint: this.endpoint(targetP, to), + pathway: eventIdToSubPathwayId.get(reaction.reactomeId), + reactomeId: reaction.reactomeId, + reactionId: reaction.id, + isFadeOut: reaction.isFadeOut, + isBackground: reaction.isFadeOut, + replacedBy, replacement + }, + classes: classes + }; + return edge + }); + } + ); + + const linkEdges: cytoscape.EdgeDefinition[] = data.links + ?.filter(link => !link.renderableClass.includes('EntitySet') || link.inputs[0].id !== link.outputs[0].id) + ?.map(link => { + const source = idToNodes.get(link.inputs[0].id)!; + const target = idToNodes.get(link.outputs[0].id)!; + + const sourceP = scale(source.position); + const targetP = scale(target.position); + + const points = link.segments + .flatMap((segment, i) => i === 0 ? [segment.from, segment.to] : [segment.to]) + .map(pos => scale(pos)); + + let [from, to] = [points.shift()!, points.pop()!] + from = from ?? sourceP; // Quick fix to avoid problem with reaction without visible outputs like R-HSA-2424252 in R-HSA-1474244 + to = to ?? targetP; // Quick fix to avoid problem with reaction without visible outputs like R-HSA-2424252 in R-HSA-1474244 + + // points = addRoundness(from, to, points); + const relatives = this.absoluteToRelative(from, to, points); + + const classes = [...this.linkClassMap.get(link.renderableClass)!]; + if (link.isDisease) classes.push('disease'); + const isBackground = link.isFadeOut || + idToNodes.get(link.inputs[0].id)?.isBackground && + idToNodes.get(link.outputs[0].id)?.isBackground; + + return { + data: { + id: link.id + '', + source: link.inputs[0].id + '', + target: link.outputs[0].id + '', + weights: relatives.weights.join(" "), + distances: relatives.distances.join(" "), + sourceEndpoint: this.endpoint(sourceP, from), + targetEndpoint: this.endpoint(targetP, to), + isFadeOut: link.isFadeOut, + isBackground: isBackground + }, + classes: classes, + selectable: false + } + } + ) + + return { + nodes: [...compartmentNodes, ...reactionNodes, ...entityNodes, ...shadowNodes], + edges: [...edges, ...linkEdges] + }; + }), + ) + + } + + getStructureVideoHtml(item: { + id: string | number, + type: string + }, width: number, height: number, uniprotId: string | undefined) { + if (item.type === 'Protein') + return ``; + return undefined; + } + + private getEdgeId(source: Edge | Node, connector: NodeConnector, target: Edge | Node, edgeIds: Map) { + let edgeId = `${source.id} --${this.edgeTypeToStr.get(connector.type)} ${target.id}`; + + if (edgeIds.has(edgeId)) { + let count = edgeIds.get(edgeId)!; + edgeIds.set(edgeId, count++); + edgeId += ` (${count})`; + console.warn('Conflicting edge id: ', edgeId) + } else { + edgeIds.set(edgeId, 0) + } + return edgeId; + } + + private addEdgeInfo(edge: Edge, points: Position[], direction: 'forward' | 'backward', stop: Position) { + const stopPos = posToStr(edge, stop); + if (direction === 'forward') { + const map = this.extraLine; + let pos = posToStr(edge, points.at(-1)!) + while (map.has(pos) && pos !== stopPos) { + points.push(map.get(pos)!) + pos = posToStr(edge, points.at(-1)!) + } + } else { + const map = this.reverseExtraLine; + let pos = posToStr(edge, points.at(0)!) + while (map.has(pos) && pos !== stopPos) { + points.unshift(map.get(pos)!) + pos = posToStr(edge, points.at(0)!) + } + } + } + + private endpoint(source: Position, point: Position): string { + return `${point.x - source.x} ${point.y - source.y}` + } + + + lastSelectedResource: string | undefined + + /** + * Use Matrix power to convert points from an absolute coordinate system to an edge relative system + * + * Visually explained by https://youtu.be/kYB8IZa5AuE?si=vJKi-MUv2dCRQ5oA
+ * Short version ==> https://math.stackexchange.com/q/1855051/683621 + * @param source Position position of the edge source: {x:number, y:number} + * @param target Position position of the edge target: {x:number, y:number} + * @param toConvert Array of Position to convert to the edge-relative system + * @return The points converted to relative coordinates {distances: number[], weights: number[]} + */ + private absoluteToRelative(source: Position, target: Position, toConvert: Position[]): RelativePosition { + const relatives: RelativePosition = {distances: [], weights: []}; + if (toConvert.length === 0) return relatives; + + const mainVector = array([target.x - source.x, target.y - source.y]); // Edge vector + const orthoVector = array([-mainVector.y, mainVector.x]) // Perpendicular vector + .normalize(); //Normalized to have the distance expressed in pixels https://math.stackexchange.com/a/413235/683621 + const transform = array([ + [mainVector.x, mainVector.y], + [orthoVector.x, orthoVector.y], + ]).inv(); // Should always be invertible if the ortho vector is indeed perpendicular + + for (const coord of toConvert) { + const absolute = array([[coord.x - source.x, coord.y - source.y]]); + const relative = absolute.multiply(transform); + relatives.weights.push(relative.get(0, 0)) + relatives.distances.push(relative.get(0, 1)) + } + return relatives; + } + + public randomNetwork(): Observable { + const amount = 100; + const peTypes = ['Protein', 'EntitySet', 'GenomeEncodedEntity', 'RNA', 'Gene', 'Complex', 'Molecule']; + // const peTypes = ['Gene']; + const reactionTypes = ['association', 'dissociation', 'transition', 'uncertain', 'omitted']; + + const physicalEntities: cytoscape.NodeDefinition[] = Array.from({length: amount}, (_x, i) => { + const clazz = this.pick(peTypes); + return { + group: 'nodes', + data: { + id: i.toString(), + width: this.random(150, 300), + height: this.random(50, 150), + displayName: clazz, + parent: 'Compartment' + }, + classes: [clazz, "PhysicalEntity", this.pick(["drug", "", ""])] + }; + }); + + const reactions: cytoscape.NodeDefinition[] = physicalEntities.map((_node, i) => + ({ + group: 'nodes', + data: { + id: `${i}-react`, + parent: 'Compartment' + }, + classes: [this.pick(reactionTypes), 'reaction'] + }) + ); + + const nodes: cytoscape.NodeDefinition[] = physicalEntities.flatMap((node, i) => + [node, reactions[i]] + ); + + + const inOut: cytoscape.EdgeDefinition[] = physicalEntities.flatMap((_node, i) => [ + { + group: 'edges', + data: { + source: `${i}`, + target: `${i}-react`, + stoichiometry: this.pick([undefined, -1, 0, 1, 2]) + }, + classes: ['consumption'] + }, + { + group: 'edges', + data: { + source: `${i}-react`, + target: `${(i + 1) % amount}`, + stoichiometry: this.pick([undefined, -1, 0, 1, 2]) + }, + + classes: ['production'] + }, + ]) + + const additionalIn: cytoscape.EdgeDefinition[] = Array.from({length: amount / 4}).map(() => ({ + group: 'edges', + data: { + source: this.pick(physicalEntities).data.id!, + target: this.pick(reactions).data.id!, + }, + classes: this.pick(['catalysis', 'positive-regulation', 'negative-regulation', 'set-to-member']) + })); + + + const edges: cytoscape.EdgeDefinition[] = [...inOut, ...additionalIn]; + + return of({ // list of graph elements to start with + nodes: [ + { + data: {id: 'Compartment'}, + classes: ['Compartment'], + pannable: true, + grabbable: false, + selectable: false + }, + ...nodes + ] + , edges + }) + } +} + +function samePoint(p1: Position, p2: Position) { + return p1.x === p2.x && p1.y === p2.y +} + +function overlapLimited(nodeA: Node, nodeB: Node, limit: number = 0.8): boolean { + if (nodeA.position.x === nodeB.position.x && nodeA.position.y === nodeB.position.y) return true; + const rectA = getRect(nodeA), rectB = getRect(nodeB); + const o: Rectangle = { + left: Math.max(rectA.left, rectB.left), + right: Math.min(rectA.right, rectB.right), + top: Math.max(rectA.top, rectB.top), + bottom: Math.min(rectA.bottom, rectB.bottom) + } + return (o.left < o.right && o.top < o.bottom) && ((area(o) / area(rectA)) > limit); +} + +function area(rect: Rectangle) { + return (rect.right - rect.left) * (rect.bottom - rect.top) +} + +function getRect(node: Node): Rectangle { + if (node.rect) return node.rect + const halfWidth = node.prop.width / 2; + const halfHeight = node.prop.height / 2; + node.rect = { + left: node.position.x - halfWidth, + right: node.position.x + halfWidth, + top: node.position.y - halfHeight, + bottom: node.position.y + halfHeight, + } + return node.rect; +} + +/** + * Create a temporary cytoscape session to apply a layout to the nodes in order to avoid them to overlap each others + */ +function avoidOverlap(definitions: cytoscape.NodeDefinition[]) { + const container = document.createElement("div"); + + const style = new Style(container, {}); + const cy = cytoscape({ + container: container, + style: style.getStyleSheet(), + elements: definitions, + layout: {name: 'preset'} + }); + + const nodes = cy.nodes(); + nodes.forEach(node => { + const bb = node.boundingBox({includeLabels: true, includeNodes: false}); + node.style({width: bb.w, height: bb.h}) + }) + + const layout = nodes.layout({ + name: 'fcose', + nodeRepulsion: 15, + animate: false, + fit: true, + packComponents: false, + randomize: false, + tile: false, + } as FcoseLayoutOptions); + layout.run() + + definitions.forEach(def => def.position = cy.getElementById(def.data.id!).position()); + + cy.destroy() + container.remove() +} + + diff --git a/projects/ngx-reactome-diagram/src/lib/services/roundness.ts b/projects/ngx-reactome-diagram/src/lib/services/roundness.ts new file mode 100644 index 0000000..80804fc --- /dev/null +++ b/projects/ngx-reactome-diagram/src/lib/services/roundness.ts @@ -0,0 +1,69 @@ +import {Position} from "cytoscape"; +import {add, array, NDArray, subtract} from "vectorious"; + +/** + * Add roundness to a segment edge by adding for each anchor position an additional point just before and just after + * at a distance of radius from the original anchor point. + * + * With Cytoscape Bezier edges, this renders as a segmented edges with smoothed corner. + * For a schematic explanation, @see https://raw.githubusercontent.com/reactome-pwp/PathwayBrowser/main/doc/smooth-segments.png + * + * @param source The position of the source node + * @param target The position of the target node + * @param anchors The intermediate segment points + * @param radius How far away the new points need to be from the anchor points. + * A bigger radius means smoother corners, + * but it shouldn't be bigger than the segment + * @private + */ +export function addRoundness(source: Position, target: Position, anchors: Position[], radius = 60): Position[] { + const output: NDArray[] = [] + if (anchors.length === 0) return output; + + const points = [source, ...anchors, target]; + for (let i = 1; i < points.length - 1; i++) { + let previousPoint = toVector(points[i - 1]); + let currentPoint = toVector(points[i]); + let nextPoint = toVector(points[i + 1]); + + const previousVector = subtract(previousPoint.copy(), currentPoint); + const nextVector = subtract(nextPoint.copy(), currentPoint); + + // Scaling to fit to radius + scaleTo([previousVector, nextVector], radius); + + previousPoint = add(currentPoint.copy(), previousVector); + nextPoint = add(currentPoint.copy(), nextVector); + + output.push(previousPoint, currentPoint, nextPoint); + } + return output.map(toPosition); +} + +/** + * Scale a list of vectors to a desired length. + * + * If limit is true, limiting the resulting length to half the minimum norm of all vectors, in order to avoid clashing + * @param vectors + * @param length + * @param limit + * @private + */ +function scaleTo(vectors: NDArray[], length: number, limit = true): NDArray[] { + const norms = vectors.map(vector => vector.norm()); + if (limit) { + const minNorm = Math.min(...norms); + if (length >= minNorm) length = minNorm / 2.1; + } + return vectors.map((vector, i) => vector.scale(length / norms[i])); +} + + +function toVector(position: Position): NDArray { + return array([position.x, position.y]); +} + +function toPosition(vector: NDArray): Position { + return {x: vector.x, y: vector.y} +} + diff --git a/projects/ngx-reactome-diagram/src/public-api.ts b/projects/ngx-reactome-diagram/src/public-api.ts new file mode 100644 index 0000000..bf27df9 --- /dev/null +++ b/projects/ngx-reactome-diagram/src/public-api.ts @@ -0,0 +1,9 @@ +/* + * Public API Surface of ngx-reactome-diagram + */ + + +// export default {}; +export {DiagramComponent} from './lib/diagram/diagram.component'; +export {DiagramService} from './lib/services/diagram.service'; +export {DIAGRAM_CONFIG_TOKEN} from './lib/services/diagram.service' \ No newline at end of file diff --git a/projects/ngx-reactome-diagram/tsconfig.lib.json b/projects/ngx-reactome-diagram/tsconfig.lib.json new file mode 100644 index 0000000..f26e583 --- /dev/null +++ b/projects/ngx-reactome-diagram/tsconfig.lib.json @@ -0,0 +1,15 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/lib", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "resolveJsonModule": true, + "types": [], + }, + "exclude": [ + "**/*.spec.ts" + ] +} diff --git a/projects/ngx-reactome-diagram/tsconfig.lib.prod.json b/projects/ngx-reactome-diagram/tsconfig.lib.prod.json new file mode 100644 index 0000000..06de549 --- /dev/null +++ b/projects/ngx-reactome-diagram/tsconfig.lib.prod.json @@ -0,0 +1,10 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/projects/ngx-reactome-diagram/tsconfig.spec.json b/projects/ngx-reactome-diagram/tsconfig.spec.json new file mode 100644 index 0000000..ff608bb --- /dev/null +++ b/projects/ngx-reactome-diagram/tsconfig.spec.json @@ -0,0 +1,14 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/spec", + "types": [ + "vitest/globals" + ] + }, + "include": [ + "**/*.spec.ts", + "**/*.d.ts" + ] +} diff --git a/projects/ngx-reactome-style/README.md b/projects/ngx-reactome-style/README.md new file mode 100644 index 0000000..8a15583 --- /dev/null +++ b/projects/ngx-reactome-style/README.md @@ -0,0 +1,25 @@ +# NgxReactomeStyle + +This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 17.3.0. + +## Code scaffolding + +Run `ng generate component component-name --project ngx-reactome-style` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project ngx-reactome-style`. + +> Note: Don't forget to add `--project ngx-reactome-style` or else it will be added to the default project in your `angular.json` file. + +## Build + +Run `ng build ngx-reactome-style` to build the project. The build artifacts will be stored in the `dist/` directory. + +## Publishing + +After building your library with `ng build ngx-reactome-style`, go to the dist folder `cd dist/ngx-reactome-style` and run `npm publish`. + +## Running unit tests + +This library is Sass only and has no unit tests. + +## Further help + +To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. diff --git a/projects/ngx-reactome-style/ng-package.json b/projects/ngx-reactome-style/ng-package.json new file mode 100644 index 0000000..69dbff3 --- /dev/null +++ b/projects/ngx-reactome-style/ng-package.json @@ -0,0 +1,14 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/ngx-reactome-style", + "lib": { + "entryFile": "src/public-api.ts" + }, + "assets": [ + { + "glob": "**/*", + "input": "/src/assets/", + "output": "/" + } + ] +} diff --git a/projects/ngx-reactome-style/package.json b/projects/ngx-reactome-style/package.json new file mode 100644 index 0000000..8cd6830 --- /dev/null +++ b/projects/ngx-reactome-style/package.json @@ -0,0 +1,9 @@ +{ + "name": "ngx-reactome-style", + "version": "0.1.0", + "description": "Shared Reactome Angular Material theme (Sass only).", + "peerDependencies": { + "@angular/material": ">=19.0.0" + }, + "sideEffects": false +} diff --git a/projects/ngx-reactome-style/src/assets/_index.scss b/projects/ngx-reactome-style/src/assets/_index.scss new file mode 100644 index 0000000..309d262 --- /dev/null +++ b/projects/ngx-reactome-style/src/assets/_index.scss @@ -0,0 +1,45 @@ +//// Custom Theming for Angular Material +//// For more information: https://material.angular.io/guide/theming +@use '@angular/material' as mat; +@use 'theme-colors' as colors; +@use 'variables'; + +// The selectors are parameters for the embeddable diagram, whose theme lives on +// its shadow host (:host, :host(.dark)) rather than on the partner's page. +@mixin theme($root: ':root', $dark: 'body.dark', $vars: 'body') { + #{$root} { + color-scheme: light; + @include mat.theme( + ( + color: ( + primary: colors.$primary-palette, + tertiary: colors.$tertiary-palette, + theme-type: light, + ), + typography: Roboto, + density: 0, + ) + ); + font: var(--mat-sys-body-large); + } + + #{$dark} { + color-scheme: dark; + @include mat.theme( + ( + color: ( + primary: colors.$primary-palette, + tertiary: colors.$tertiary-palette, + theme-type: dark, + ), + typography: Roboto, + density: 0, + ) + ); + } + + .high-contrast { + @include colors.high-contrast-overrides('color-scheme'); + } + @include variables.variables($vars); +} diff --git a/projects/ngx-reactome-style/src/assets/_theme-colors.scss b/projects/ngx-reactome-style/src/assets/_theme-colors.scss new file mode 100644 index 0000000..ea87526 --- /dev/null +++ b/projects/ngx-reactome-style/src/assets/_theme-colors.scss @@ -0,0 +1,206 @@ +// This file was generated by running 'ng generate @angular/material:theme-color'. +// Proceed with caution if making changes to this file. + +@use 'sass:map'; +@use '@angular/material' as mat; + +// Note: Color palettes are generated from primary: #20b9e5, secondary: #20b9e5, tertiary: #5793db, neutral: #009fb6 +$_palettes: ( + primary: ( + 0: #000000, + 10: #001f29, + 20: #003545, + 25: #004153, + 30: #004d62, + 35: #005a72, + 40: #006781, + 50: #0081a2, + 60: #009dc4, + 70: #20b9e5, + 80: #5bd4ff, + 90: #baeaff, + 95: #def4ff, + 98: #f3faff, + 99: #fafdff, + 100: #ffffff, + ), + secondary: ( + 0: #000000, + 10: #001f29, + 20: #003545, + 25: #004153, + 30: #004d62, + 35: #005a72, + 40: #006781, + 50: #0081a2, + 60: #009dc4, + 70: #20b9e5, + 80: #5bd4ff, + 90: #baeaff, + 95: #def4ff, + 98: #f3faff, + 99: #fafdff, + 100: #ffffff, + ), + tertiary: ( + 0: #000000, + 10: #001c38, + 20: #00315c, + 25: #003c6e, + 30: #004882, + 35: #005496, + 40: #1660a5, + 50: #3a79c0, + 60: #5793db, + 70: #73aef8, + 80: #a3c9ff, + 90: #d3e4ff, + 95: #eaf1ff, + 98: #f8f9ff, + 99: #fdfcff, + 100: #ffffff, + ), + neutral: ( + 0: #000000, + 10: #001f25, + 20: #00363f, + 25: #00424d, + 30: #004e5b, + 35: #005b69, + 40: #006878, + 50: #008396, + 60: #009fb6, + 70: #3dbad2, + 80: #60d6ee, + 90: #a6eeff, + 95: #d6f6ff, + 98: #effbff, + 99: #f7fdff, + 100: #ffffff, + 4: #001115, + 6: #00161b, + 12: #00242a, + 17: #002f37, + 22: #003b44, + 24: #003f4a, + 87: #83e8ff, + 92: #baf1ff, + 94: #cdf5ff, + 96: #def8ff, + ), + neutral-variant: ( + 0: #000000, + 10: #121d22, + 20: #273237, + 25: #323d42, + 30: #3d484e, + 35: #49545a, + 40: #546066, + 50: #6d797f, + 60: #879399, + 70: #a1adb3, + 80: #bcc8cf, + 90: #d8e4eb, + 95: #e6f3f9, + 98: #f3faff, + 99: #fafdff, + 100: #ffffff, + ), + error: ( + 0: #000000, + 10: #410002, + 20: #690005, + 25: #7e0007, + 30: #93000a, + 35: #a80710, + 40: #ba1a1a, + 50: #de3730, + 60: #ff5449, + 70: #ff897d, + 80: #ffb4ab, + 90: #ffdad6, + 95: #ffedea, + 98: #fff8f7, + 99: #fffbff, + 100: #ffffff, + ), +); + +$_rest: ( + secondary: map.get($_palettes, secondary), + neutral: map.get($_palettes, neutral), + neutral-variant: map.get($_palettes, neutral-variant), + error: map.get($_palettes, error), +); + +$primary-palette: map.merge(map.get($_palettes, primary), $_rest); +$tertiary-palette: map.merge(map.get($_palettes, tertiary), $_rest); + +@function _high-contrast-value($light, $dark, $theme-type) { + @if ($theme-type == light) { + @return $light; + } + @if ($theme-type == dark) { + @return $dark; + } + @if ($theme-type == color-scheme) { + @return light-dark(#{$light}, #{$dark}); + } + + @error 'Unknown theme-type #{$theme-type}. Expected light, dark, or color-scheme'; +} + +@mixin high-contrast-overrides($theme-type) { + @include mat.theme-overrides( + ( + primary: _high-contrast-value(#00313f, #ddf4ff, $theme-type), + on-primary: _high-contrast-value(#ffffff, #000000, $theme-type), + primary-container: _high-contrast-value(#005065, #4ad1fe, $theme-type), + on-primary-container: _high-contrast-value(#ffffff, #000d13, $theme-type), + inverse-primary: _high-contrast-value(#5bd4ff, #004f64, $theme-type), + primary-fixed: _high-contrast-value(#005065, #baeaff, $theme-type), + primary-fixed-dim: _high-contrast-value(#003848, #5bd4ff, $theme-type), + on-primary-fixed: _high-contrast-value(#ffffff, #000000, $theme-type), + on-primary-fixed-variant: _high-contrast-value(#ffffff, #00141b, $theme-type), + secondary: _high-contrast-value(#00313f, #ddf4ff, $theme-type), + on-secondary: _high-contrast-value(#ffffff, #000000, $theme-type), + secondary-container: _high-contrast-value(#005065, #4ad1fe, $theme-type), + on-secondary-container: _high-contrast-value(#ffffff, #000d13, $theme-type), + secondary-fixed: _high-contrast-value(#005065, #baeaff, $theme-type), + secondary-fixed-dim: _high-contrast-value(#003848, #5bd4ff, $theme-type), + on-secondary-fixed: _high-contrast-value(#ffffff, #000000, $theme-type), + on-secondary-fixed-variant: _high-contrast-value(#ffffff, #00141b, $theme-type), + tertiary: _high-contrast-value(#002d54, #e9f0ff, $theme-type), + on-tertiary: _high-contrast-value(#ffffff, #000000, $theme-type), + tertiary-container: _high-contrast-value(#004a86, #9bc5ff, $theme-type), + on-tertiary-container: _high-contrast-value(#ffffff, #000c1d, $theme-type), + tertiary-fixed: _high-contrast-value(#004a86, #d3e4ff, $theme-type), + tertiary-fixed-dim: _high-contrast-value(#003460, #a3c9ff, $theme-type), + on-tertiary-fixed: _high-contrast-value(#ffffff, #000000, $theme-type), + on-tertiary-fixed-variant: _high-contrast-value(#ffffff, #001227, $theme-type), + background: _high-contrast-value(#effbff, #00161b, $theme-type), + on-background: _high-contrast-value(#001f25, #a6eeff, $theme-type), + surface: _high-contrast-value(#effbff, #00161b, $theme-type), + surface-dim: _high-contrast-value(#4fc8e0, #00161b, $theme-type), + surface-bright: _high-contrast-value(#effbff, #005866, $theme-type), + surface-container-lowest: _high-contrast-value(#ffffff, #000000, $theme-type), + surface-container: _high-contrast-value(#a6eeff, #00363f, $theme-type), + surface-container-high: _high-contrast-value(#70e4fd, #00424d, $theme-type), + surface-container-highest: _high-contrast-value(#60d6ee, #004e5b, $theme-type), + on-surface: _high-contrast-value(#000000, #ffffff, $theme-type), + shadow: _high-contrast-value(#000000, #000000, $theme-type), + scrim: _high-contrast-value(#000000, #000000, $theme-type), + surface-tint: _high-contrast-value(#006781, #5bd4ff, $theme-type), + inverse-surface: _high-contrast-value(#00363f, #a6eeff, $theme-type), + inverse-on-surface: _high-contrast-value(#ffffff, #000000, $theme-type), + outline: _high-contrast-value(#222e33, #e6f2f9, $theme-type), + outline-variant: _high-contrast-value(#3f4b50, #b8c4cb, $theme-type), + error: _high-contrast-value(#600004, #ffece9, $theme-type), + on-error: _high-contrast-value(#ffffff, #000000, $theme-type), + error-container: _high-contrast-value(#98000a, #ffaea4, $theme-type), + on-error-container: _high-contrast-value(#ffffff, #220001, $theme-type), + surface-variant: _high-contrast-value(#d8e4eb, #3d484e, $theme-type), + on-surface-variant: _high-contrast-value(#000000, #ffffff, $theme-type), + ) + ); +} diff --git a/projects/ngx-reactome-style/src/assets/_variables.scss b/projects/ngx-reactome-style/src/assets/_variables.scss new file mode 100644 index 0000000..d7c7934 --- /dev/null +++ b/projects/ngx-reactome-style/src/assets/_variables.scss @@ -0,0 +1,49 @@ +@use '@angular/material' as mat; +@use 'sass:map'; + +@mixin variables($selector: 'body') { + #{$selector} { + --primary: var(--mat-sys-primary); + --on-primary: var(--mat-sys-on-primary); + --primary-container: var(--mat-sys-primary-container); + --on-primary-container: var(--mat-sys-on-primary-container); + + --secondary: var(--mat-sys-secondary); + --on-secondary: var(--mat-sys-on-secondary); + --secondary-container: var(--mat-sys-secondary-container); + --on-secondary-container: var(--mat-sys-on-secondary-container); + + --tertiary: var(--mat-sys-tertiary); + --on-tertiary: var(--mat-sys-on-tertiary); + --tertiary-container: var(--mat-sys-tertiary-container); + --on-tertiary-container: var(--mat-sys-on-tertiary-container); + + --error: var(--mat-sys-error); + --on-error: var(--mat-sys-on-error); + --error-container: var(--mat-sys-error-container); + --on-error-container: var(--mat-sys-on-error-container); + + --warn: light-dark(#c67c10, #ff9300); + + --background: var(--mat-sys-background); + --on-background: var(--mat-sys-on-background); + --surface: var(--mat-sys-surface); + --on-surface: var(--mat-sys-on-surface); + + --surface-variant: var(--mat-sys-surface-variant); + --on-surface-variant: var(--mat-sys-on-surface-variant); + --outline: var(--mat-sys-outline); + --outline-variant: var(--mat-sys-outline-variant); + + --surface1: color-mix(in lch, var(--primary) 5%, var(--surface) 95%); + --surface2: color-mix(in lch, var(--primary) 8%, var(--surface) 92%); + --surface3: color-mix(in lch, var(--primary) 11%, var(--surface) 89%); + --surface4: color-mix(in lch, var(--primary) 12%, var(--surface) 88%); + --surface5: color-mix(in lch, var(--primary) 14%, var(--surface) 86%); + + --card: var(--mat-sys-neutral10); + + --icon-positive: #0c9509; + --hover: rgba(0, 0, 0, 0.04); + } +} diff --git a/projects/ngx-reactome-style/src/public-api.ts b/projects/ngx-reactome-style/src/public-api.ts new file mode 100644 index 0000000..bb8a86c --- /dev/null +++ b/projects/ngx-reactome-style/src/public-api.ts @@ -0,0 +1,8 @@ +/* + * Public API Surface of ngx-reactome-style + * + * This library is Sass only -- the theme lives in src/assets and is consumed + * via `@use "ngx-reactome-style"`. There is deliberately no TypeScript surface; + * the published package has always exported an empty object here. + */ +export default {}; diff --git a/projects/ngx-reactome-style/tsconfig.lib.json b/projects/ngx-reactome-style/tsconfig.lib.json new file mode 100644 index 0000000..543fd47 --- /dev/null +++ b/projects/ngx-reactome-style/tsconfig.lib.json @@ -0,0 +1,14 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/lib", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "**/*.spec.ts" + ] +} diff --git a/projects/ngx-reactome-style/tsconfig.lib.prod.json b/projects/ngx-reactome-style/tsconfig.lib.prod.json new file mode 100644 index 0000000..06de549 --- /dev/null +++ b/projects/ngx-reactome-style/tsconfig.lib.prod.json @@ -0,0 +1,10 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/scripts/check-lint.mjs b/scripts/check-lint.mjs new file mode 100644 index 0000000..2815c1e --- /dev/null +++ b/scripts/check-lint.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +/** + * Lint gate for a codebase that had no linter until now. + * + * Two things are enforced: + * + * errors must stay at zero. eslint.config.js keeps a rule at "error" only + * when the codebase is already clean of it, so any error means a new + * violation of something previously spotless. + * warnings must not increase, **rule by rule**. Each warned rule's count is + * recorded here and can only go down. It used to be one total, and + * a total lets a new violation of one rule hide behind a fix to + * another -- which, with several large rules on the ratchet at once, + * is the ordinary case rather than the unlucky one. + * + * Fixing the last violation of a warned rule is the cue to promote it to error + * in eslint.config.js; the check says so when it happens. + * + * Run with --update after reducing a count to lock in the improvement. + */ +import { execFile } from 'node:child_process'; +import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { isEntry } from './is-entry.mjs'; +import { dirname, join, relative } from 'node:path'; +import { tmpdir } from 'node:os'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const BASELINE = join(ROOT, 'lint-baseline.json'); + +/** Warnings per rule in an ESLint JSON report. */ +export function countWarnings(report) { + const rules = {}; + for (const file of report) { + for (const m of file.messages) { + if (m.severity !== 1) continue; + const rule = m.ruleId ?? '(no rule)'; + rules[rule] = (rules[rule] ?? 0) + 1; + } + } + return rules; +} + +/** + * Compare per-rule warning counts with the baseline's. + * A rule the baseline does not list is held at zero: a newly warned rule has + * to be recorded with --update, deliberately, before it may have any. + * @param {Record} counts + * @param {Record} baseline + */ +export function compare(counts, baseline) { + const rules = [...new Set([...Object.keys(counts), ...Object.keys(baseline)])].sort(); + const increased = []; + const decreased = []; + const cleared = []; + for (const rule of rules) { + const now = counts[rule] ?? 0; + const was = baseline[rule] ?? 0; + if (now > was) increased.push({ rule, was, now }); + else if (now < was) { + decreased.push({ rule, was, now }); + if (now === 0) cleared.push(rule); + } + } + return { increased, decreased, cleared }; +} + +const total = (rules) => Object.values(rules).reduce((a, b) => a + b, 0); + +function sorted(rules) { + return Object.fromEntries(Object.entries(rules).sort(([a], [b]) => a.localeCompare(b))); +} + +async function main() { + const reportFile = join(tmpdir(), `eslint-report-${process.pid}.json`); + await new Promise((resolve) => { + execFile( + 'npx', + ['eslint', '.', '--format', 'json', '-o', reportFile], + { cwd: ROOT, maxBuffer: 64 * 1024 * 1024 }, + () => resolve() // a non-zero exit just means findings; the report is what matters + ); + }); + + let report; + try { + report = JSON.parse(readFileSync(reportFile, 'utf8')); + } catch (cause) { + console.error('eslint produced no report; it probably failed to start.'); + console.error(cause.message); + process.exit(1); + } + try { + unlinkSync(reportFile); + } catch { + // Leaving a temp file behind is not worth failing over. + } + + const errors = report.flatMap((file) => + file.messages + .filter((m) => m.severity === 2) + .map( + (m) => + `${relative(ROOT, file.filePath)}:${m.line} ${m.ruleId ?? 'parse-error'} ${m.message}` + ) + ); + const counts = countWarnings(report); + + if (process.argv.includes('--update')) { + writeFileSync( + BASELINE, + `${JSON.stringify({ warnings: total(counts), rules: sorted(counts) }, null, 2)}\n` + ); + console.log( + `Baseline set to ${total(counts)} warnings across ${Object.keys(counts).length} rules.` + ); + process.exit(errors.length > 0 ? 1 : 0); + } + + let baseline; + try { + baseline = JSON.parse(readFileSync(BASELINE, 'utf8')).rules; + } catch { + baseline = undefined; + } + if (!baseline) { + console.error( + `No per-rule baseline at ${BASELINE}. Create it with: npm run check:lint -- --update` + ); + process.exit(1); + } + + if (errors.length > 0) { + console.error( + `${errors.length} lint error(s). These rules had no violations, so each of ` + + 'these is new:\n' + ); + console.error(errors.join('\n')); + process.exit(1); + } + + const { increased, decreased, cleared } = compare(counts, baseline); + if (increased.length > 0) { + console.error('Lint warnings went up:\n'); + for (const { rule, was, now } of increased) console.error(` ${rule}: ${was} -> ${now}`); + console.error( + '\nRun `npx eslint .` to see them, and fix the ones you introduced. A rule' + + ' the baseline does not list is held at zero.' + ); + process.exit(1); + } + + if (decreased.length > 0) { + console.log( + `${total(counts)} warnings, down from ${total(baseline)}. ` + + 'Lock that in with: npm run check:lint -- --update' + ); + for (const rule of cleared) { + console.log(` ${rule} has none left: promote it to "error" in eslint.config.js.`); + } + process.exit(0); + } + + console.log(`No errors; ${total(counts)} warnings, unchanged from the baseline.`); +} + +if (isEntry(import.meta.url)) await main(); diff --git a/scripts/is-entry.mjs b/scripts/is-entry.mjs new file mode 100644 index 0000000..725f22f --- /dev/null +++ b/scripts/is-entry.mjs @@ -0,0 +1,22 @@ +/** + * Whether the module at `moduleUrl` is the script node was asked to run. + * + * The checkers export their logic for tests and run it only when executed. The + * usual test, `import.meta.url === pathToFileURL(process.argv[1]).href`, is + * false whenever the path to the script goes through a symlink: node resolves + * the module's own URL to the real file, and argv[1] is left as typed. The + * check then never runs, prints nothing, and exits 0 -- a gate that passes by + * not looking. Both sides are compared as real paths. + */ +import { realpathSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +export function isEntry(moduleUrl) { + const invoked = process.argv[1]; + if (!invoked) return false; + try { + return realpathSync(invoked) === realpathSync(fileURLToPath(moduleUrl)); + } catch { + return false; + } +} diff --git a/src/app/app.component.css b/src/app/app.component.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/app.component.html b/src/app/app.component.html new file mode 100644 index 0000000..36093e1 --- /dev/null +++ b/src/app/app.component.html @@ -0,0 +1,336 @@ + + + + + + + + + + + +
+
+
+ +

Hello, {{ title }}

+

Congratulations! Your app is running. 🎉

+
+ +
+
+ @for (item of [ + { title: 'Explore the Docs', link: 'https://angular.dev' }, + { title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' }, + { title: 'CLI Docs', link: 'https://angular.dev/tools/cli' }, + { title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' }, + { title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' }, + ]; track item.title) { + + {{ item.title }} + + + + + } +
+ +
+
+
+ + + + + + + + + + + diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts new file mode 100644 index 0000000..361a6d7 --- /dev/null +++ b/src/app/app.component.spec.ts @@ -0,0 +1,29 @@ +import { TestBed } from '@angular/core/testing'; +import { AppComponent } from './app.component'; + +describe('AppComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AppComponent], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it(`should have the 'ngx-reactome-base' title`, () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app.title).toEqual('ngx-reactome-base'); + }); + + it('should render title', () => { + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('h1')?.textContent).toContain('Hello, ngx-reactome-base'); + }); +}); diff --git a/src/app/app.component.ts b/src/app/app.component.ts new file mode 100644 index 0000000..2f2470f --- /dev/null +++ b/src/app/app.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-root', + imports: [RouterOutlet], + templateUrl: './app.component.html', + styleUrl: './app.component.css' +}) +export class AppComponent { + title = 'ngx-reactome-base'; +} diff --git a/src/app/app.config.ts b/src/app/app.config.ts new file mode 100644 index 0000000..6c6ef60 --- /dev/null +++ b/src/app/app.config.ts @@ -0,0 +1,8 @@ +import { ApplicationConfig } from '@angular/core'; +import { provideRouter } from '@angular/router'; + +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [provideRouter(routes)] +}; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts new file mode 100644 index 0000000..dc39edb --- /dev/null +++ b/src/app/app.routes.ts @@ -0,0 +1,3 @@ +import { Routes } from '@angular/router'; + +export const routes: Routes = []; diff --git a/src/favicon.ico b/src/favicon.ico new file mode 100644 index 0000000..57614f9 Binary files /dev/null and b/src/favicon.ico differ diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..302eb80 --- /dev/null +++ b/src/index.html @@ -0,0 +1,15 @@ + + + + + NgxReactomeBase + + + + + + + + + + diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..36000de --- /dev/null +++ b/src/main.ts @@ -0,0 +1,7 @@ +import { provideZoneChangeDetection } from "@angular/core"; +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent, {...appConfig, providers: [provideZoneChangeDetection(), ...appConfig.providers]}) + .catch((err) => console.error(err)); diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..7e7239a --- /dev/null +++ b/src/styles.css @@ -0,0 +1,4 @@ +/* You can add global styles to this file, and also import other style files */ + +html, body { height: 100%; } +body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..ef214f3 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,15 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [], + "resolveJsonModule": true + }, + "files": [ + "src/main.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fc3ca7a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,57 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "isolatedModules": true, + "skipLibCheck": true, + "paths": { + // vectorious's "exports" lists its plain module before its types, so + // bundler resolution finds no declarations; point straight at them. + "vectorious": [ + "./node_modules/vectorious/dist/index.d.ts" + ], + "ngx-reactome-cytoscape-style": [ + "./dist/ngx-reactome-cytoscape-style" + ], + "ngx-reactome-diagram": [ + "./dist/ngx-reactome-diagram" + ], + "ngx-reactome-style": [ + "./dist/ngx-reactome-style" + ] + }, + "esModuleInterop": true, + "sourceMap": true, + "declaration": false, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "useDefineForClassFields": false + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true, + "typeCheckHostBindings": true, + "strictStandalone": true, + "extendedDiagnostics": { + "defaultCategory": "error", + "checks": { + "nullishCoalescingNotNullable": "warning", + "optionalChainNotNullable": "warning" + } + } + } +} diff --git a/tsconfig.spec.json b/tsconfig.spec.json new file mode 100644 index 0000000..ef606cb --- /dev/null +++ b/tsconfig.spec.json @@ -0,0 +1,14 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "vitest/globals" + ] + }, + "include": [ + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +}