From 259f29d936b86ee085daeca474d1742d443137c4 Mon Sep 17 00:00:00 2001 From: Bhushan Khope Date: Fri, 13 Sep 2024 11:37:23 -0400 Subject: [PATCH 01/23] initial setup of library + wc in apps --- .eslintrc.json | 4 + apps/node-dist-vis-wc-e2e/.eslintrc.json | 10 + apps/node-dist-vis-wc-e2e/cypress.config.ts | 7 + apps/node-dist-vis-wc-e2e/project.json | 29 +++ apps/node-dist-vis-wc-e2e/src/e2e/app.cy.ts | 13 ++ .../src/fixtures/example.json | 5 + .../src/support/app.po.ts | 1 + .../src/support/commands.ts | 35 +++ apps/node-dist-vis-wc-e2e/src/support/e2e.ts | 17 ++ apps/node-dist-vis-wc-e2e/tsconfig.json | 17 ++ apps/node-dist-vis-wc/.eslintrc.json | 33 +++ apps/node-dist-vis-wc/jest.config.ts | 22 ++ apps/node-dist-vis-wc/project.json | 90 +++++++ apps/node-dist-vis-wc/public/favicon.ico | Bin 0 -> 15086 bytes apps/node-dist-vis-wc/src/app/.gitkeep | 0 apps/node-dist-vis-wc/src/index.html | 13 ++ apps/node-dist-vis-wc/src/main.ts | 1 + apps/node-dist-vis-wc/src/styles.scss | 1 + apps/node-dist-vis-wc/src/test-setup.ts | 8 + apps/node-dist-vis-wc/tsconfig.app.json | 10 + apps/node-dist-vis-wc/tsconfig.editor.json | 6 + apps/node-dist-vis-wc/tsconfig.json | 33 +++ apps/node-dist-vis-wc/tsconfig.spec.json | 11 + libs/node-dist-vis/.eslintrc.json | 40 ++++ libs/node-dist-vis/README.md | 7 + libs/node-dist-vis/jest.config.ts | 22 ++ libs/node-dist-vis/ng-package.json | 7 + libs/node-dist-vis/package.json | 12 + libs/node-dist-vis/project.json | 36 +++ libs/node-dist-vis/src/index.ts | 9 + .../node-dist-vis.component.html | 1 + .../node-dist-vis.component.scss | 3 + .../node-dist-vis.component.spec.ts | 21 ++ .../node-dist-vis/node-dist-vis.component.ts | 12 + libs/node-dist-vis/src/test-setup.ts | 8 + libs/node-dist-vis/tsconfig.json | 28 +++ libs/node-dist-vis/tsconfig.lib.json | 12 + libs/node-dist-vis/tsconfig.lib.prod.json | 9 + libs/node-dist-vis/tsconfig.spec.json | 11 + package-lock.json | 220 +++++++----------- package.json | 3 + tsconfig.base.json | 3 + 42 files changed, 694 insertions(+), 136 deletions(-) create mode 100644 apps/node-dist-vis-wc-e2e/.eslintrc.json create mode 100644 apps/node-dist-vis-wc-e2e/cypress.config.ts create mode 100644 apps/node-dist-vis-wc-e2e/project.json create mode 100644 apps/node-dist-vis-wc-e2e/src/e2e/app.cy.ts create mode 100644 apps/node-dist-vis-wc-e2e/src/fixtures/example.json create mode 100644 apps/node-dist-vis-wc-e2e/src/support/app.po.ts create mode 100644 apps/node-dist-vis-wc-e2e/src/support/commands.ts create mode 100644 apps/node-dist-vis-wc-e2e/src/support/e2e.ts create mode 100644 apps/node-dist-vis-wc-e2e/tsconfig.json create mode 100644 apps/node-dist-vis-wc/.eslintrc.json create mode 100644 apps/node-dist-vis-wc/jest.config.ts create mode 100644 apps/node-dist-vis-wc/project.json create mode 100644 apps/node-dist-vis-wc/public/favicon.ico create mode 100644 apps/node-dist-vis-wc/src/app/.gitkeep create mode 100644 apps/node-dist-vis-wc/src/index.html create mode 100644 apps/node-dist-vis-wc/src/main.ts create mode 100644 apps/node-dist-vis-wc/src/styles.scss create mode 100644 apps/node-dist-vis-wc/src/test-setup.ts create mode 100644 apps/node-dist-vis-wc/tsconfig.app.json create mode 100644 apps/node-dist-vis-wc/tsconfig.editor.json create mode 100644 apps/node-dist-vis-wc/tsconfig.json create mode 100644 apps/node-dist-vis-wc/tsconfig.spec.json create mode 100644 libs/node-dist-vis/.eslintrc.json create mode 100644 libs/node-dist-vis/README.md create mode 100644 libs/node-dist-vis/jest.config.ts create mode 100644 libs/node-dist-vis/ng-package.json create mode 100644 libs/node-dist-vis/package.json create mode 100644 libs/node-dist-vis/project.json create mode 100644 libs/node-dist-vis/src/index.ts create mode 100644 libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.html create mode 100644 libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.scss create mode 100644 libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.spec.ts create mode 100644 libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts create mode 100644 libs/node-dist-vis/src/test-setup.ts create mode 100644 libs/node-dist-vis/tsconfig.json create mode 100644 libs/node-dist-vis/tsconfig.lib.json create mode 100644 libs/node-dist-vis/tsconfig.lib.prod.json create mode 100644 libs/node-dist-vis/tsconfig.spec.json diff --git a/.eslintrc.json b/.eslintrc.json index 12f3dcb04..840341043 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -63,6 +63,10 @@ { "sourceTag": "project:design-system", "onlyDependOnLibsWithTags": ["*"] + }, + { + "sourceTag": "project:node-dist-vis", + "onlyDependOnLibsWithTags": ["*"] } ] } diff --git a/apps/node-dist-vis-wc-e2e/.eslintrc.json b/apps/node-dist-vis-wc-e2e/.eslintrc.json new file mode 100644 index 000000000..696cb8b12 --- /dev/null +++ b/apps/node-dist-vis-wc-e2e/.eslintrc.json @@ -0,0 +1,10 @@ +{ + "extends": ["plugin:cypress/recommended", "../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/apps/node-dist-vis-wc-e2e/cypress.config.ts b/apps/node-dist-vis-wc-e2e/cypress.config.ts new file mode 100644 index 000000000..7df58bdcf --- /dev/null +++ b/apps/node-dist-vis-wc-e2e/cypress.config.ts @@ -0,0 +1,7 @@ +import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { ...nxE2EPreset(__filename, { cypressDir: 'src' }), baseUrl: 'http://localhost:4200' }, +}); diff --git a/apps/node-dist-vis-wc-e2e/project.json b/apps/node-dist-vis-wc-e2e/project.json new file mode 100644 index 000000000..1142bc404 --- /dev/null +++ b/apps/node-dist-vis-wc-e2e/project.json @@ -0,0 +1,29 @@ +{ + "name": "node-dist-vis-wc-e2e", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "sourceRoot": "apps/node-dist-vis-wc-e2e/src", + "tags": [], + "implicitDependencies": ["node-dist-vis-wc"], + "targets": { + "e2e": { + "executor": "@nx/cypress:cypress", + "options": { + "cypressConfig": "apps/node-dist-vis-wc-e2e/cypress.config.ts", + "testingType": "e2e", + "devServerTarget": "node-dist-vis-wc:serve:development" + }, + "configurations": { + "production": { + "devServerTarget": "node-dist-vis-wc:serve:production" + }, + "ci": { + "devServerTarget": "node-dist-vis-wc:serve-static" + } + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/apps/node-dist-vis-wc-e2e/src/e2e/app.cy.ts b/apps/node-dist-vis-wc-e2e/src/e2e/app.cy.ts new file mode 100644 index 000000000..50275a28d --- /dev/null +++ b/apps/node-dist-vis-wc-e2e/src/e2e/app.cy.ts @@ -0,0 +1,13 @@ +import { getGreeting } from '../support/app.po'; + +describe('node-dist-vis-wc-e2e', () => { + beforeEach(() => cy.visit('/')); + + it('should display welcome message', () => { + // Custom command example, see `../support/commands.ts` file + cy.login('my-email@something.com', 'myPassword'); + + // Function helper example, see `../support/app.po.ts` file + getGreeting().contains(/Welcome/); + }); +}); diff --git a/apps/node-dist-vis-wc-e2e/src/fixtures/example.json b/apps/node-dist-vis-wc-e2e/src/fixtures/example.json new file mode 100644 index 000000000..02e425437 --- /dev/null +++ b/apps/node-dist-vis-wc-e2e/src/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/apps/node-dist-vis-wc-e2e/src/support/app.po.ts b/apps/node-dist-vis-wc-e2e/src/support/app.po.ts new file mode 100644 index 000000000..329342469 --- /dev/null +++ b/apps/node-dist-vis-wc-e2e/src/support/app.po.ts @@ -0,0 +1 @@ +export const getGreeting = () => cy.get('h1'); diff --git a/apps/node-dist-vis-wc-e2e/src/support/commands.ts b/apps/node-dist-vis-wc-e2e/src/support/commands.ts new file mode 100644 index 000000000..c421a3c47 --- /dev/null +++ b/apps/node-dist-vis-wc-e2e/src/support/commands.ts @@ -0,0 +1,35 @@ +/// + +// *********************************************** +// This example commands.ts shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** + +// eslint-disable-next-line @typescript-eslint/no-namespace +declare namespace Cypress { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface Chainable { + login(email: string, password: string): void; + } +} + +// -- This is a parent command -- +Cypress.Commands.add('login', (email, password) => { + console.log('Custom command example: Login', email, password); +}); +// +// -- This is a child command -- +// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) diff --git a/apps/node-dist-vis-wc-e2e/src/support/e2e.ts b/apps/node-dist-vis-wc-e2e/src/support/e2e.ts new file mode 100644 index 000000000..1c1a9e772 --- /dev/null +++ b/apps/node-dist-vis-wc-e2e/src/support/e2e.ts @@ -0,0 +1,17 @@ +// *********************************************************** +// This example support/e2e.ts is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.ts using ES2015 syntax: +import './commands'; diff --git a/apps/node-dist-vis-wc-e2e/tsconfig.json b/apps/node-dist-vis-wc-e2e/tsconfig.json new file mode 100644 index 000000000..e28de1d79 --- /dev/null +++ b/apps/node-dist-vis-wc-e2e/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "allowJs": true, + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["cypress", "node"], + "sourceMap": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["**/*.ts", "**/*.js", "cypress.config.ts", "**/*.cy.ts", "**/*.cy.js", "**/*.d.ts"] +} diff --git a/apps/node-dist-vis-wc/.eslintrc.json b/apps/node-dist-vis-wc/.eslintrc.json new file mode 100644 index 000000000..b01d28e2b --- /dev/null +++ b/apps/node-dist-vis-wc/.eslintrc.json @@ -0,0 +1,33 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "extends": ["plugin:@nx/angular", "plugin:@angular-eslint/template/process-inline-templates"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "hra", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "hra", + "style": "kebab-case" + } + ] + } + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/apps/node-dist-vis-wc/jest.config.ts b/apps/node-dist-vis-wc/jest.config.ts new file mode 100644 index 000000000..19a203a78 --- /dev/null +++ b/apps/node-dist-vis-wc/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'node-dist-vis-wc', + preset: '../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../coverage/apps/node-dist-vis-wc', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/apps/node-dist-vis-wc/project.json b/apps/node-dist-vis-wc/project.json new file mode 100644 index 000000000..9f6df8882 --- /dev/null +++ b/apps/node-dist-vis-wc/project.json @@ -0,0 +1,90 @@ +{ + "name": "node-dist-vis-wc", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "prefix": "hra", + "sourceRoot": "apps/node-dist-vis-wc/src", + "tags": ["type:app", "project:node-dist-vis"], + "targets": { + "build": { + "executor": "@angular-devkit/build-angular:application", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/apps/node-dist-vis-wc", + "index": "apps/node-dist-vis-wc/src/index.html", + "browser": "apps/node-dist-vis-wc/src/main.ts", + "polyfills": ["zone.js"], + "tsConfig": "apps/node-dist-vis-wc/tsconfig.app.json", + "inlineStyleLanguage": "scss", + "assets": [ + { + "glob": "**/*", + "input": "apps/node-dist-vis-wc/public" + } + ], + "styles": ["apps/node-dist-vis-wc/src/styles.scss"], + "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": { + "executor": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "buildTarget": "node-dist-vis-wc:build:production" + }, + "development": { + "buildTarget": "node-dist-vis-wc:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "executor": "@angular-devkit/build-angular:extract-i18n", + "options": { + "buildTarget": "node-dist-vis-wc:build" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "apps/node-dist-vis-wc/jest.config.ts" + } + }, + "serve-static": { + "executor": "@nx/web:file-server", + "options": { + "buildTarget": "node-dist-vis-wc:build", + "port": 4200, + "staticFilePath": "dist/apps/node-dist-vis-wc/browser", + "spa": true + } + } + } +} diff --git a/apps/node-dist-vis-wc/public/favicon.ico b/apps/node-dist-vis-wc/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..317ebcb2336e0833a22dddf0ab287849f26fda57 GIT binary patch literal 15086 zcmeI332;U^%p|z7g|#(P)qFEA@4f!_@qOK2 z_lJl}!lhL!VT_U|uN7%8B2iKH??xhDa;*`g{yjTFWHvXn;2s{4R7kH|pKGdy(7z!K zgftM+Ku7~24TLlh(!g)gz|foI94G^t2^IO$uvX$3(OR0<_5L2sB)lMAMy|+`xodJ{ z_Uh_1m)~h?a;2W{dmhM;u!YGo=)OdmId_B<%^V^{ovI@y`7^g1_V9G}*f# zNzAtvou}I!W1#{M^@ROc(BZ! z+F!!_aR&Px3_reO(EW+TwlW~tv*2zr?iP7(d~a~yA|@*a89IUke+c472NXM0wiX{- zl`UrZC^1XYyf%1u)-Y)jj9;MZ!SLfd2Hl?o|80Su%Z?To_=^g_Jt0oa#CT*tjx>BI z16wec&AOWNK<#i0Qd=1O$fymLRoUR*%;h@*@v7}wApDl^w*h}!sYq%kw+DKDY)@&A z@9$ULEB3qkR#85`lb8#WZw=@})#kQig9oqy^I$dj&k4jU&^2(M3q{n1AKeGUKPFbr z1^<)aH;VsG@J|B&l>UtU#Ejv3GIqERzYgL@UOAWtW<{p#zy`WyJgpCy8$c_e%wYJL zyGHRRx38)HyjU3y{-4z6)pzb>&Q1pR)B&u01F-|&Gx4EZWK$nkUkOI|(D4UHOXg_- zw{OBf!oWQUn)Pe(=f=nt=zkmdjpO^o8ZZ9o_|4tW1ni+Un9iCW47*-ut$KQOww!;u z`0q)$s6IZO!~9$e_P9X!hqLxu`fpcL|2f^I5d4*a@Dq28;@2271v_N+5HqYZ>x;&O z05*7JT)mUe&%S0@UD)@&8SmQrMtsDfZT;fkdA!r(S=}Oz>iP)w=W508=Rc#nNn7ym z1;42c|8($ALY8#a({%1#IXbWn9-Y|0eDY$_L&j{63?{?AH{);EzcqfydD$@-B`Y3<%IIj7S7rK_N}je^=dEk%JQ4c z!tBdTPE3Tse;oYF>cnrapWq*o)m47X1`~6@(!Y29#>-#8zm&LXrXa(3=7Z)ElaQqj z-#0JJy3Fi(C#Rx(`=VXtJ63E2_bZGCz+QRa{W0e2(m3sI?LOcUBx)~^YCqZ{XEPX)C>G>U4tfqeH8L(3|pQR*zbL1 zT9e~4Tb5p9_G}$y4t`i*4t_Mr9QYvL9C&Ah*}t`q*}S+VYh0M6GxTTSXI)hMpMpIq zD1ImYqJLzbj0}~EpE-aH#VCH_udYEW#`P2zYmi&xSPs_{n6tBj=MY|-XrA;SGA_>y zGtU$?HXm$gYj*!N)_nQ59%lQdXtQZS3*#PC-{iB_sm+ytD*7j`D*k(P&IH2GHT}Eh z5697eQECVIGQAUe#eU2I!yI&%0CP#>%6MWV z@zS!p@+Y1i1b^QuuEF*13CuB zu69dve5k7&Wgb+^s|UB08Dr3u`h@yM0NTj4h7MnHo-4@xmyr7(*4$rpPwsCDZ@2be zRz9V^GnV;;?^Lk%ynzq&K(Aix`mWmW`^152Hoy$CTYVehpD-S1-W^#k#{0^L`V6CN+E z!w+xte;2vu4AmVNEFUOBmrBL>6MK@!O2*N|2=d|Y;oN&A&qv=qKn73lDD zI(+oJAdgv>Yr}8(&@ZuAZE%XUXmX(U!N+Z_sjL<1vjy1R+1IeHt`79fnYdOL{$ci7 z%3f0A*;Zt@ED&Gjm|OFTYBDe%bbo*xXAQsFz+Q`fVBH!N2)kaxN8P$c>sp~QXnv>b zwq=W3&Mtmih7xkR$YA)1Yi?avHNR6C99!u6fh=cL|KQ&PwF!n@ud^n(HNIImHD!h87!i*t?G|p0o+eelJ?B@A64_9%SBhNaJ64EvKgD&%LjLCYnNfc; znj?%*p@*?dq#NqcQFmmX($wms@CSAr9#>hUR^=I+=0B)vvGX%T&#h$kmX*s=^M2E!@N9#m?LhMvz}YB+kd zG~mbP|D(;{s_#;hsKK9lbVK&Lo734x7SIFJ9V_}2$@q?zm^7?*XH94w5Qae{7zOMUF z^?%F%)c1Y)Q?Iy?I>knw*8gYW#ok|2gdS=YYZLiD=CW|Nj;n^x!=S#iJ#`~Ld79+xXpVmUK^B(xO_vO!btA9y7w3L3-0j-y4 z?M-V{%z;JI`bk7yFDcP}OcCd*{Q9S5$iGA7*E1@tfkyjAi!;wP^O71cZ^Ep)qrQ)N z#wqw0_HS;T7x3y|`P==i3hEwK%|>fZ)c&@kgKO1~5<5xBSk?iZV?KI6&i72H6S9A* z=U(*e)EqEs?Oc04)V-~K5AUmh|62H4*`UAtItO$O(q5?6jj+K^oD!04r=6#dsxp?~}{`?&sXn#q2 zGuY~7>O2=!u@@Kfu7q=W*4egu@qPMRM>(eyYyaIE<|j%d=iWNdGsx%c!902v#ngNg z@#U-O_4xN$s_9?(`{>{>7~-6FgWpBpqXb`Ydc3OFL#&I}Irse9F_8R@4zSS*Y*o*B zXL?6*Aw!AfkNCgcr#*yj&p3ZDe2y>v$>FUdKIy_2N~}6AbHc7gA3`6$g@1o|dE>vz z4pl(j9;kyMsjaw}lO?(?Xg%4k!5%^t#@5n=WVc&JRa+XT$~#@rldvN3S1rEpU$;XgxVny7mki3 z-Hh|jUCHrUXuLr!)`w>wgO0N%KTB-1di>cj(x3Bav`7v z3G7EIbU$z>`Nad7Rk_&OT-W{;qg)-GXV-aJT#(ozdmnA~Rq3GQ_3mby(>q6Ocb-RgTUhTN)))x>m&eD;$J5Bg zo&DhY36Yg=J=$Z>t}RJ>o|@hAcwWzN#r(WJ52^g$lh^!63@hh+dR$&_dEGu&^CR*< z!oFqSqO@>xZ*nC2oiOd0eS*F^IL~W-rsrO`J`ej{=ou_q^_(<$&-3f^J z&L^MSYWIe{&pYq&9eGaArA~*kA + + + + node-dist-vis-wc + + + + + + + + diff --git a/apps/node-dist-vis-wc/src/main.ts b/apps/node-dist-vis-wc/src/main.ts new file mode 100644 index 000000000..ead2e58ef --- /dev/null +++ b/apps/node-dist-vis-wc/src/main.ts @@ -0,0 +1 @@ +import '@hra-ui/node-dist-vis'; diff --git a/apps/node-dist-vis-wc/src/styles.scss b/apps/node-dist-vis-wc/src/styles.scss new file mode 100644 index 000000000..90d4ee007 --- /dev/null +++ b/apps/node-dist-vis-wc/src/styles.scss @@ -0,0 +1 @@ +/* You can add global styles to this file, and also import other style files */ diff --git a/apps/node-dist-vis-wc/src/test-setup.ts b/apps/node-dist-vis-wc/src/test-setup.ts new file mode 100644 index 000000000..ab1eeeb33 --- /dev/null +++ b/apps/node-dist-vis-wc/src/test-setup.ts @@ -0,0 +1,8 @@ +// @ts-expect-error https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment +globalThis.ngJest = { + testEnvironmentOptions: { + errorOnUnknownElements: true, + errorOnUnknownProperties: true, + }, +}; +import 'jest-preset-angular/setup-jest'; diff --git a/apps/node-dist-vis-wc/tsconfig.app.json b/apps/node-dist-vis-wc/tsconfig.app.json new file mode 100644 index 000000000..fff4a41d4 --- /dev/null +++ b/apps/node-dist-vis-wc/tsconfig.app.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts"], + "exclude": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts"] +} diff --git a/apps/node-dist-vis-wc/tsconfig.editor.json b/apps/node-dist-vis-wc/tsconfig.editor.json new file mode 100644 index 000000000..a8ac182c0 --- /dev/null +++ b/apps/node-dist-vis-wc/tsconfig.editor.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts"], + "compilerOptions": {}, + "exclude": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts"] +} diff --git a/apps/node-dist-vis-wc/tsconfig.json b/apps/node-dist-vis-wc/tsconfig.json new file mode 100644 index 000000000..a28fec9ac --- /dev/null +++ b/apps/node-dist-vis-wc/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.editor.json" + }, + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/apps/node-dist-vis-wc/tsconfig.spec.json b/apps/node-dist-vis-wc/tsconfig.spec.json new file mode 100644 index 000000000..7870b7c01 --- /dev/null +++ b/apps/node-dist-vis-wc/tsconfig.spec.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/libs/node-dist-vis/.eslintrc.json b/libs/node-dist-vis/.eslintrc.json new file mode 100644 index 000000000..fc700f9b7 --- /dev/null +++ b/libs/node-dist-vis/.eslintrc.json @@ -0,0 +1,40 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "extends": ["plugin:@nx/angular", "plugin:@angular-eslint/template/process-inline-templates"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "hra", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "hra", + "style": "kebab-case" + } + ] + } + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": "error" + } + } + ] +} diff --git a/libs/node-dist-vis/README.md b/libs/node-dist-vis/README.md new file mode 100644 index 000000000..4a0e4ed48 --- /dev/null +++ b/libs/node-dist-vis/README.md @@ -0,0 +1,7 @@ +# node-dist-vis + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test node-dist-vis` to execute the unit tests. diff --git a/libs/node-dist-vis/jest.config.ts b/libs/node-dist-vis/jest.config.ts new file mode 100644 index 000000000..118b6dae8 --- /dev/null +++ b/libs/node-dist-vis/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'node-dist-vis', + preset: '../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../coverage/libs/node-dist-vis', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/node-dist-vis/ng-package.json b/libs/node-dist-vis/ng-package.json new file mode 100644 index 000000000..c201290e7 --- /dev/null +++ b/libs/node-dist-vis/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/libs/node-dist-vis", + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/node-dist-vis/package.json b/libs/node-dist-vis/package.json new file mode 100644 index 000000000..f7c2295b3 --- /dev/null +++ b/libs/node-dist-vis/package.json @@ -0,0 +1,12 @@ +{ + "name": "@hra-ui/node-dist-vis", + "version": "0.0.1", + "peerDependencies": { + "@angular/common": "^18.2.0", + "@angular/core": "^18.2.0", + "@hra-ui/webcomponents": "0.0.1" + }, + "sideEffects": [ + "index.ts" + ] +} diff --git a/libs/node-dist-vis/project.json b/libs/node-dist-vis/project.json new file mode 100644 index 000000000..75ab26e40 --- /dev/null +++ b/libs/node-dist-vis/project.json @@ -0,0 +1,36 @@ +{ + "name": "node-dist-vis", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/node-dist-vis/src", + "prefix": "hra", + "projectType": "library", + "tags": ["type:lib", "project:node-dist-vis", "webcomponent"], + "targets": { + "build": { + "executor": "@nx/angular:package", + "outputs": ["{workspaceRoot}/dist/{projectRoot}"], + "options": { + "project": "libs/node-dist-vis/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "libs/node-dist-vis/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "libs/node-dist-vis/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/node-dist-vis/jest.config.ts" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libs/node-dist-vis/src/index.ts b/libs/node-dist-vis/src/index.ts new file mode 100644 index 000000000..51354d3db --- /dev/null +++ b/libs/node-dist-vis/src/index.ts @@ -0,0 +1,9 @@ +import { createCustomElement } from '@hra-ui/webcomponents'; +import { NodeDistVisComponent } from './lib/node-dist-vis/node-dist-vis.component'; + +export * from './lib/node-dist-vis/node-dist-vis.component'; + +/** Custom element definition for CdeVisualizationComponent */ +export const CdeVisualizationElement = createCustomElement('hra-node-dist-vis', NodeDistVisComponent, { + providers: [], +}); diff --git a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.html b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.html new file mode 100644 index 000000000..108540841 --- /dev/null +++ b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.html @@ -0,0 +1 @@ +

node-dist-vis works!

diff --git a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.scss b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.spec.ts b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.spec.ts new file mode 100644 index 000000000..de49ac38e --- /dev/null +++ b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NodeDistVisComponent } from './node-dist-vis.component'; + +describe('NodeDistVisComponent', () => { + let component: NodeDistVisComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NodeDistVisComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(NodeDistVisComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts new file mode 100644 index 000000000..1ad102fe7 --- /dev/null +++ b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts @@ -0,0 +1,12 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'hra-node-dist-vis', + standalone: true, + imports: [CommonModule], + templateUrl: './node-dist-vis.component.html', + styleUrl: './node-dist-vis.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NodeDistVisComponent {} diff --git a/libs/node-dist-vis/src/test-setup.ts b/libs/node-dist-vis/src/test-setup.ts new file mode 100644 index 000000000..ab1eeeb33 --- /dev/null +++ b/libs/node-dist-vis/src/test-setup.ts @@ -0,0 +1,8 @@ +// @ts-expect-error https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment +globalThis.ngJest = { + testEnvironmentOptions: { + errorOnUnknownElements: true, + errorOnUnknownProperties: true, + }, +}; +import 'jest-preset-angular/setup-jest'; diff --git a/libs/node-dist-vis/tsconfig.json b/libs/node-dist-vis/tsconfig.json new file mode 100644 index 000000000..56deb89f6 --- /dev/null +++ b/libs/node-dist-vis/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "es2022", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/node-dist-vis/tsconfig.lib.json b/libs/node-dist-vis/tsconfig.lib.json new file mode 100644 index 000000000..4cab05d46 --- /dev/null +++ b/libs/node-dist-vis/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": ["src/**/*.spec.ts", "src/test-setup.ts", "jest.config.ts", "src/**/*.test.ts"], + "include": ["src/**/*.ts"] +} diff --git a/libs/node-dist-vis/tsconfig.lib.prod.json b/libs/node-dist-vis/tsconfig.lib.prod.json new file mode 100644 index 000000000..2a2faa884 --- /dev/null +++ b/libs/node-dist-vis/tsconfig.lib.prod.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/libs/node-dist-vis/tsconfig.spec.json b/libs/node-dist-vis/tsconfig.spec.json new file mode 100644 index 000000000..7870b7c01 --- /dev/null +++ b/libs/node-dist-vis/tsconfig.spec.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/package-lock.json b/package-lock.json index 65dd2616d..f7da6361e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -166,6 +166,7 @@ "@typescript-eslint/eslint-plugin": "7.18.0", "@typescript-eslint/parser": "7.18.0", "@typescript-eslint/utils": "7.18.0", + "autoprefixer": "^10.4.0", "browserify": "^17.0.0", "commander": "^12.0.0", "cypress": "13.13.0", @@ -194,6 +195,8 @@ "ng-packagr": "18.2.1", "ngx-build-plus": "^18.0.0", "nx": "19.6.2", + "postcss": "^8.4.5", + "postcss-url": "~10.1.3", "prettier": "^3.3.2", "protractor": "~7.0.0", "shallow-render": "^18.0.0", @@ -3639,24 +3642,6 @@ "indefinitely-typed": "^1.1.0" } }, - "node_modules/@deck.gl/aggregation-layers": { - "version": "8.9.36", - "resolved": "https://registry.npmjs.org/@deck.gl/aggregation-layers/-/aggregation-layers-8.9.36.tgz", - "integrity": "sha512-EwUJ1bwhhAG6LF9hAdZDaIAwIFDUGC8XpQgHmitTLohciVrIp70p9zpgHNNU6oPy+iQvccmWctLcSC9TpgjsIg==", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.0.0", - "@luma.gl/constants": "^8.5.21", - "@luma.gl/shadertools": "^8.5.21", - "@math.gl/web-mercator": "^3.6.2", - "d3-hexbin": "^0.2.1" - }, - "peerDependencies": { - "@deck.gl/core": "^8.0.0", - "@deck.gl/layers": "^8.0.0", - "@luma.gl/core": "^8.0.0" - } - }, "node_modules/@deck.gl/carto": { "version": "8.8.27", "resolved": "https://registry.npmjs.org/@deck.gl/carto/-/carto-8.8.27.tgz", @@ -8771,16 +8756,6 @@ "node": ">=14" } }, - "node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, "node_modules/@preact/signals-core": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.8.0.tgz", @@ -11375,7 +11350,7 @@ "version": "1.13.3", "resolved": "https://registry.npmjs.org/@swc-node/core/-/core-1.13.3.tgz", "integrity": "sha512-OGsvXIid2Go21kiNqeTIn79jcaX4l0G93X2rAnas4LFoDyA9wAwVK7xZdm+QsKoMn5Mus2yFLCc4OtX2dD/PWA==", - "devOptional": true, + "dev": true, "engines": { "node": ">= 10" }, @@ -11392,7 +11367,7 @@ "version": "1.9.2", "resolved": "https://registry.npmjs.org/@swc-node/register/-/register-1.9.2.tgz", "integrity": "sha512-BBjg0QNuEEmJSoU/++JOXhrjWdu3PTyYeJWsvchsI0Aqtj8ICkz/DqlwtXbmZVZ5vuDPpTfFlwDBZe81zgShMA==", - "devOptional": true, + "dev": true, "dependencies": { "@swc-node/core": "^1.13.1", "@swc-node/sourcemap-support": "^0.5.0", @@ -11414,7 +11389,7 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/@swc-node/sourcemap-support/-/sourcemap-support-0.5.1.tgz", "integrity": "sha512-JxIvIo/Hrpv0JCHSyRpetAdQ6lB27oFYhv0PKCNf1g2gUXOjpeR1exrXccRxLMuAV5WAmGFBwRnNOJqN38+qtg==", - "devOptional": true, + "dev": true, "dependencies": { "source-map-support": "^0.5.21", "tslib": "^2.6.3" @@ -11424,7 +11399,7 @@ "version": "1.5.7", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.5.7.tgz", "integrity": "sha512-U4qJRBefIJNJDRCCiVtkfa/hpiZ7w0R6kASea+/KLp+vkus3zcLSB8Ub8SvKgTIxjWpwsKcZlPf5nrv4ls46SQ==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "dependencies": { "@swc/counter": "^0.1.2", @@ -11622,7 +11597,7 @@ "version": "0.1.7", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.7.tgz", "integrity": "sha512-scHWahbHF0eyj3JsxG9CFJgFdFNaVQCNAimBlT6PzS3n/HptxqREjsm4OH6AN3lYcffZYSPxXW8ua2BEHp0lJQ==", - "devOptional": true, + "dev": true, "dependencies": { "@swc/counter": "^0.1.3" } @@ -11631,7 +11606,7 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "devOptional": true + "dev": true }, "node_modules/@swc/helpers": { "version": "0.5.11", @@ -11663,7 +11638,7 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.12.tgz", "integrity": "sha512-wBJA+SdtkbFhHjTMYH+dEH1y4VpfGdAc2Kw/LK09i9bXd/K6j6PkDcFCEzb6iVfZMkPRrl/q0e3toqTAJdkIVA==", - "devOptional": true, + "dev": true, "dependencies": { "@swc/counter": "^0.1.3" } @@ -11926,33 +11901,6 @@ "node": ">=10.13.0" } }, - "node_modules/@ts-morph/common": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.22.0.tgz", - "integrity": "sha512-HqNBuV/oIlMKdkLshXd1zKBqNQCsuPEsgQOkfFQ/eUKjRlwndXW1AjN9LVkBEIukm00gGXSRmfkl0Wv5VXLnlw==", - "peer": true, - "dependencies": { - "fast-glob": "^3.3.2", - "minimatch": "^9.0.3", - "mkdirp": "^3.0.1", - "path-browserify": "^1.0.1" - } - }, - "node_modules/@ts-morph/common/node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "peer": true, - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -15807,7 +15755,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "devOptional": true + "dev": true }, "node_modules/buffer-xor": { "version": "1.0.3", @@ -16532,12 +16480,6 @@ "node": ">= 0.12.0" } }, - "node_modules/code-block-writer": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-12.0.0.tgz", - "integrity": "sha512-q4dMFMlXtKR3XNBHyMHt/3pwYNA69EDk00lloMOaaUMKPUXBw6lpXtbu3MMVG6/uOihGnRDOlkyqsONEUj60+w==", - "peer": true - }, "node_modules/codepage": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", @@ -16592,7 +16534,7 @@ "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "devOptional": true + "dev": true }, "node_modules/colors": { "version": "1.4.0", @@ -17772,6 +17714,12 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "dev": true }, + "node_modules/cuint": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/cuint/-/cuint-0.2.2.tgz", + "integrity": "sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==", + "dev": true + }, "node_modules/custom-event": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", @@ -18476,12 +18424,6 @@ "node": ">= 10" } }, - "node_modules/d3-hexbin": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/d3-hexbin/-/d3-hexbin-0.2.2.tgz", - "integrity": "sha512-KS3fUT2ReD4RlGCjvCEm1RgMtp2NFZumdMu4DBzQK8AZv3fXRM6Xm8I4fSU07UXvH4xxg03NwWKWdvxfS/yc4w==", - "peer": true - }, "node_modules/d3-hierarchy": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", @@ -19934,6 +19876,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, "optional": true, "dependencies": { "iconv-lite": "^0.6.2" @@ -25057,23 +25000,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-circus/node_modules/babel-plugin-macros": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", - "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@babel/runtime": "^7.12.5", - "cosmiconfig": "^7.0.0", - "resolve": "^1.19.0" - }, - "engines": { - "node": ">=10", - "npm": ">=6" - } - }, "node_modules/jest-circus/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -25090,24 +25016,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-circus/node_modules/cosmiconfig": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", - "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jest-circus/node_modules/dedent": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", @@ -25175,17 +25083,6 @@ "node": ">=8" } }, - "node_modules/jest-circus/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/jest-cli": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", @@ -32824,7 +32721,7 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "devOptional": true, + "dev": true, "engines": { "node": ">= 6" } @@ -33589,6 +33486,58 @@ "postcss": "^8.4.31" } }, + "node_modules/postcss-url": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/postcss-url/-/postcss-url-10.1.3.tgz", + "integrity": "sha512-FUzyxfI5l2tKmXdYc6VTu3TWZsInayEKPbiyW+P6vmmIrrb4I6CGX0BFoewgYHLK+oIL5FECEK02REYRpBvUCw==", + "dev": true, + "dependencies": { + "make-dir": "~3.1.0", + "mime": "~2.5.2", + "minimatch": "~3.0.4", + "xxhashjs": "~0.2.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-url/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/postcss-url/node_modules/mime": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.5.2.tgz", + "integrity": "sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/postcss-url/node_modules/minimatch": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", + "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", @@ -36233,7 +36182,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "devOptional": true, + "dev": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -36243,7 +36192,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "devOptional": true, + "dev": true, "engines": { "node": ">=0.10.0" } @@ -38408,16 +38357,6 @@ "node": ">=8" } }, - "node_modules/ts-morph": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-21.0.1.tgz", - "integrity": "sha512-dbDtVdEAncKctzrVZ+Nr7kHpHkv+0JDJb2MjjpBaj8bFeCkePU9rHfMklmhuLFnpeq/EJZk2IhStY6NzqgjOkg==", - "peer": true, - "dependencies": { - "@ts-morph/common": "~0.22.0", - "code-block-writer": "^12.0.0" - } - }, "node_modules/ts-node": { "version": "10.9.1", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", @@ -38802,7 +38741,7 @@ "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", - "devOptional": true, + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -41407,6 +41346,15 @@ "node": ">=0.4" } }, + "node_modules/xxhashjs": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/xxhashjs/-/xxhashjs-0.2.2.tgz", + "integrity": "sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw==", + "dev": true, + "dependencies": { + "cuint": "^0.2.2" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index e9b78bcf0..3a4af77a4 100644 --- a/package.json +++ b/package.json @@ -164,6 +164,7 @@ "@typescript-eslint/eslint-plugin": "7.18.0", "@typescript-eslint/parser": "7.18.0", "@typescript-eslint/utils": "7.18.0", + "autoprefixer": "^10.4.0", "browserify": "^17.0.0", "commander": "^12.0.0", "cypress": "13.13.0", @@ -192,6 +193,8 @@ "ng-packagr": "18.2.1", "ngx-build-plus": "^18.0.0", "nx": "19.6.2", + "postcss": "^8.4.5", + "postcss-url": "~10.1.3", "prettier": "^3.3.2", "protractor": "~7.0.0", "shallow-render": "^18.0.0", diff --git a/tsconfig.base.json b/tsconfig.base.json index 4740d4b44..26610d0b5 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -128,6 +128,9 @@ "@hra-ui/design-system/tree": [ "libs/design-system/tree/src/index.ts" ], + "@hra-ui/node-dist-vis": [ + "libs/node-dist-vis/src/index.ts" + ], "@hra-ui/services": [ "libs/services/src/index.ts" ], From ebb73e4da3450f367925badd708529aed84fb0db Mon Sep 17 00:00:00 2001 From: Bhushan Khope Date: Tue, 17 Sep 2024 10:20:42 -0400 Subject: [PATCH 02/23] create services for nodes and edges --- libs/node-dist-vis/package.json | 4 +- libs/node-dist-vis/src/lib/models/edges.ts | 9 + libs/node-dist-vis/src/lib/models/nodes.ts | 15 + .../node-dist-vis/node-dist-vis.component.ts | 301 +++++++++++++++++- .../lib/node-dist-vis/node-dist.vis.worker.ts | 27 ++ .../src/lib/services/edge-data.service.ts | 56 ++++ .../src/lib/services/node-data.service.ts | 77 +++++ .../src/lib/utils/distance-edges.service.ts | 32 ++ .../src/lib/utils/distance-edges.ts | 80 +++++ libs/node-dist-vis/src/lib/utils/helper.ts | 15 + 10 files changed, 602 insertions(+), 14 deletions(-) create mode 100644 libs/node-dist-vis/src/lib/models/edges.ts create mode 100644 libs/node-dist-vis/src/lib/models/nodes.ts create mode 100644 libs/node-dist-vis/src/lib/node-dist-vis/node-dist.vis.worker.ts create mode 100644 libs/node-dist-vis/src/lib/services/edge-data.service.ts create mode 100644 libs/node-dist-vis/src/lib/services/node-data.service.ts create mode 100644 libs/node-dist-vis/src/lib/utils/distance-edges.service.ts create mode 100644 libs/node-dist-vis/src/lib/utils/distance-edges.ts create mode 100644 libs/node-dist-vis/src/lib/utils/helper.ts diff --git a/libs/node-dist-vis/package.json b/libs/node-dist-vis/package.json index f7c2295b3..30e6f827c 100644 --- a/libs/node-dist-vis/package.json +++ b/libs/node-dist-vis/package.json @@ -2,9 +2,9 @@ "name": "@hra-ui/node-dist-vis", "version": "0.0.1", "peerDependencies": { - "@angular/common": "^18.2.0", "@angular/core": "^18.2.0", - "@hra-ui/webcomponents": "0.0.1" + "@hra-ui/webcomponents": "0.0.1", + "papaparse": "^5.4.1" }, "sideEffects": [ "index.ts" diff --git a/libs/node-dist-vis/src/lib/models/edges.ts b/libs/node-dist-vis/src/lib/models/edges.ts new file mode 100644 index 000000000..4c99ae6e9 --- /dev/null +++ b/libs/node-dist-vis/src/lib/models/edges.ts @@ -0,0 +1,9 @@ +export type EdgeEntry = [ + sourceNodeIndex: number, + x0: number, + y0: number, + z0: number, + x1: number, + y1: number, + z1: number, +]; diff --git a/libs/node-dist-vis/src/lib/models/nodes.ts b/libs/node-dist-vis/src/lib/models/nodes.ts new file mode 100644 index 000000000..6144efe5c --- /dev/null +++ b/libs/node-dist-vis/src/lib/models/nodes.ts @@ -0,0 +1,15 @@ +declare const BRAND: unique symbol; +export type Brand = { [BRAND]: { [P in T]: true } }; +export type NodeTargetKey = string & Brand<'NodeTargetKey'>; + +export interface NodeEntry { + /** X-coordinate of the node */ + x: number; + /** Y-coordinate of the node */ + y: number; + /** Optional Z-coordinate of the node */ + z?: number; + position?: [number, number, number]; + /** Dynamic property for node target values */ + [target: NodeTargetKey]: string; +} diff --git a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts index 1ad102fe7..77f059abb 100644 --- a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts +++ b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts @@ -1,12 +1,289 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -@Component({ - selector: 'hra-node-dist-vis', - standalone: true, - imports: [CommonModule], - templateUrl: './node-dist-vis.component.html', - styleUrl: './node-dist-vis.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class NodeDistVisComponent {} +// import { CommonModule } from '@angular/common'; +// import { +// ChangeDetectionStrategy, +// Component, +// effect, +// EffectRef, +// ElementRef, +// inject, +// input, +// Input, +// OnChanges, +// OnDestroy, +// OnInit, +// output, +// untracked, +// ViewChild, +// } from '@angular/core'; +// import { Deck, OrbitView } from '@deck.gl/core'; +// import Papa from 'papaparse'; +// import { EdgeEntry } from '../models/edges'; +// import { NodeEntry } from '../models/nodes'; +// import { EdgeDataService } from '../services/edge-data.service'; +// import { NodeDataService } from '../services/node-data.service'; + +// @Component({ +// selector: 'hra-node-dist-vis', +// standalone: true, +// imports: [CommonModule], +// providers: [NodeDataService, EdgeDataService], +// templateUrl: './node-dist-vis.component.html', +// styleUrl: './node-dist-vis.component.scss', +// changeDetection: ChangeDetectionStrategy.OnPush, +// }) +// export class NodeDistVisComponent implements OnInit, OnDestroy, OnChanges { +// readonly nodes = input(); +// readonly edges = input(); + +// nodesUrl = input(); +// nodesData = input(); +// edgesUrl = input(); +// edgesData = input(); +// colorMapUrl = input(); +// colorMapKey = input('cell_type'); +// colorMapValue = input('cell_color'); +// nodeTargetKey = input(); +// nodeTargetValue = input(); +// maxEdgeDistance = input(); +// dispatchEvent = output(); +// @Input() selection?: any[]; +// @ViewChild('visCanvas', { static: true }) visCanvas!: ElementRef; + +// toDispose: EffectRef[] = []; +// initialized = false; +// edgesVersion = 0; + +// private readonly nodeDataService = inject(NodeDataService); +// private readonly edgeDataService = inject(EdgeDataService); + +// constructor() { +// effect(() => { +// this.nodeDataService.nodesInput.next(this.nodes()); +// this.edgeDataService.edgesInput.next(untracked(this.edges)); +// }); + +// effect(() => { +// this.edgeDataService.edgesInput.next(this.edges()); +// }); +// } + +// private deck!: Deck; +// // private nodes: NodeEntry[] | undefined = []; +// private colorCoding: any; + +// static readonly observedAttributes = [ +// 'nodes', +// 'edges', +// 'color-map', +// 'color-map-key', +// 'color-map-value', +// 'node-target-key', +// 'node-target-value', +// 'max-edge-distance', +// 'selection', +// ]; + +// private async loadData() { +// if (this.nodesData) { +// this.nodes = this.nodesData(); +// } else { +// this.nodes = await this.fetchCsv(this.nodesUrl() ?? ''); +// } + +// if (this.edgesData) { +// this.edges = this.edgesData(); +// } else { +// this.edges = await this.fetchCsv(this.edgesUrl() ?? ''); +// } +// this.colorCoding = await this.loadColorCoding(); +// this.updateLayers(); +// } + +// private changedCallback(name: string, newValue: string | number) { +// if (this.initialized) { +// if (name === 'max-edge-distance' && typeof newValue === 'string') { +// newValue = parseFloat(newValue); +// } else if (name === 'selection' && typeof newValue === 'string') { +// newValue = this.parseSelectionValue(newValue); +// } +// this.attributesLookup[name].value = newValue; +// } +// } + +// ngOnInit() { +// this.loadData(); +// this.initializeDeck(); +// } + +// ngOnChanges(): void { +// this.changedCallback(); +// } +// ngOnDestroy() { +// this.toDispose.forEach((dispose) => dispose()); +// this.toDispose = []; +// this.deck.finalize(); +// } + +// private initializeDeck() { +// let isHovering = false; +// let hoveredObject = undefined; +// this.deck = new Deck({ +// canvas: this.visCanvas.nativeElement, +// controller: true, +// views: [new OrbitView({ id: 'orbit', orbitAxis: 'Y' })], +// initialViewState: this.getInitialViewState(), +// onClick: (e: Event) => (e.picked ? this.dispatch('nodeClicked', e.object) : undefined), +// onViewStateChange: ({ viewState }) => (this.viewState.value = viewState), +// onLoad: () => (this.viewState.value = this.deck.viewState), +// onHover: (e) => { +// isHovering = e.picked; +// if (isHovering) { +// if (hoveredObject !== e.object) { +// this.dispatch('nodeHovering', e.object); +// hoveredObject = e.object; +// } +// } else { +// if (hoveredObject) { +// this.dispatch('nodeHovering', undefined); +// hoveredObject = undefined; +// } +// } +// }, +// getCursor: (e) => (isHovering ? 'pointer' : e.isDragging ? 'grabbing' : 'grab'), +// layers: [], +// }); + +// this.trackDisposal( +// effect(() => { +// const layers = [this.nodesLayer.value, this.edgesLayer.value, this.scaleBarLayer.value].filter((l) => !!l); +// this.deck.setProps({ layers }); +// }), +// ); + +// this.trackDisposal( +// effect(async () => { +// this.nodes.value = []; +// this.nodes.value = await this.nodes$.value; +// this.dispatch('nodes', this.nodes.value); +// }), +// ); + +// this.trackDisposal( +// effect(async () => { +// this.edges.value = []; +// const edges = await this.edges$.value; +// if (edges) { +// this.edges.value = edges; +// this.dispatch('edges', this.edges.value); +// } +// }), +// ); + +// this.trackDisposal( +// effect(async () => { +// const colorCoding = await this.colorCoding$.value; +// if (colorCoding) { +// this.colorCoding.value = colorCoding; +// } +// }), +// ); + +// batch(() => { +// this.nodesUrl = this.visCanvas.nativeElement.getAttribute('nodes'); +// this.edgesUrl.value = this.visCanvas.nativeElement.getAttribute('edges'); +// this.colorMapUrl.value = this.visCanvas.nativeElement.getAttribute('color-map'); +// this.colorMapKey.value = this.visCanvas.nativeElement.getAttribute('color-map-key') || 'cell_type'; +// this.colorMapValue.value = this.visCanvas.nativeElement.getAttribute('color-map-value') || 'cell_color'; +// this.nodeTargetKey.value = this.visCanvas.nativeElement.getAttribute('node-target-key'); +// this.nodeTargetValue.value = this.visCanvas.nativeElement.getAttribute('node-target-value'); +// this.maxEdgeDistance.value = parseFloat(this.getAttribute('max-edge-distance')); +// this.selection.value = this.parseSelectionValue(this.getAttribute('selection')); +// this.initialized = true; +// }); +// } + +// trackDisposal(disposable: EffectRef) { +// this.toDispose.push(disposable); +// } + +// private parseSelectionValue(value: string) { +// if (value === '') { +// return undefined; +// } +// return typeof value === 'string' ? JSON.parse(value) : value; +// } + +// attributesLookup = { +// nodes: this.nodesUrl, +// edges: this.edgesUrl, +// 'color-map': this.colorMapUrl, +// 'color-map-key': this.colorMapKey, +// 'color-map-value': this.colorMapValue, +// 'node-target-key': this.nodeTargetKey, +// 'node-target-value': this.nodeTargetValue, +// 'max-edge-distance': this.maxEdgeDistance, +// selection: this.selection, +// }; + +// viewStateVersionCounter = 0; +// private getInitialViewState() { +// return { +// // ... initial view state configuration +// version: this.viewStateVersionCounter++, +// orbitAxis: 'Y', +// camera: 'orbit', +// zoom: 9, +// minRotationX: -90, +// maxRotationX: 90, +// rotationX: 0, +// rotationOrbit: 0, +// dragMode: 'rotate', +// target: [0.5, 0.5], +// }; +// } + +// private async fetchCsv(url: string): Promise { +// return new Promise((resolve) => { +// Papa.parse(url, { +// header: true, +// skipEmptyLines: true, +// dynamicTyping: true, +// complete: (results) => { +// resolve(results.data); +// }, +// }); +// }); +// } + +// private async loadColorCoding() { +// // Implement color coding logic +// } + +// private updateLayers() { +// const layers = [this.createNodesLayer(), this.createEdgesLayer(), this.createScaleBarLayer()].filter((l) => !!l); + +// this.deck.setProps({ layers }); +// } + +// private createNodesLayer() { +// // Implement PointCloudLayer creation +// } + +// private createEdgesLayer() { +// // Implement LineLayer creation +// } + +// private createScaleBarLayer() { +// // Implement ScaleBarLayer creation +// } + +// private dispatch(eventName: string, payload = undefined) { +// let event; +// if (payload) { +// event = new CustomEvent(eventName, { detail: payload }); +// } else { +// event = new Event(eventName); +// } +// this.dispatchEvent.emit(event); +// } +// } diff --git a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist.vis.worker.ts b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist.vis.worker.ts new file mode 100644 index 000000000..aac06aada --- /dev/null +++ b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist.vis.worker.ts @@ -0,0 +1,27 @@ +// import { NodeEntry } from '../models/nodes'; +// import { distanceEdges } from '../utils/distance-edges'; + +// interface WorkerMessage { +// nodes: NodeEntry[]; +// type_field: string; +// target_type: string; +// maxDist: number; +// } + +// addEventListener('message', (event: MessageEvent) => { +// const { nodes, type_field, target_type, maxDist } = event.data; +// const edges: any[] = new Array(nodes.length); +// let index = 0; +// const reportStep = Math.floor(nodes.length / 10); + +// for (const edge of distanceEdges(nodes, type_field, target_type, maxDist)) { +// edges[index] = edge; +// if (index % reportStep === 0) { +// const percentage = Math.round((index / nodes.length) * 100); +// postMessage({ status: 'processing', percentage, node_index: edge[0] }); +// } +// index++; +// } + +// postMessage({ status: 'complete', edges: edges.slice(0, index) }); +// }); diff --git a/libs/node-dist-vis/src/lib/services/edge-data.service.ts b/libs/node-dist-vis/src/lib/services/edge-data.service.ts new file mode 100644 index 000000000..e4bc537b2 --- /dev/null +++ b/libs/node-dist-vis/src/lib/services/edge-data.service.ts @@ -0,0 +1,56 @@ +// import { computed, inject, Injectable } from '@angular/core'; +// import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +// import { combineLatest, distinctUntilChanged, ObservableInput, of, Subject, switchMap } from 'rxjs'; +// import { EdgeEntry } from '../models/edges'; +// import { NodeDataService, NodesData } from './node-data.service'; +// import { NodeEntry } from '../models/nodes'; +// import { distanceEdges } from '../utils/distance-edges'; +// import { fetchCsv } from '../utils/helper'; + +// export type EdgesInput = string | EdgeEntry[] | undefined; + +// export interface EdgesData { +// edges: EdgeEntry[] | undefined; +// maxEdgeDistance: number; +// } + +// @Injectable() +// export class EdgeDataService { +// readonly edgesInput = new Subject(); +// private readonly loadedEdges = this.edgesInput.pipe( +// distinctUntilChanged(), +// switchMap((data) => this.loadEdges(data)), +// ); + +// private readonly nodeDataService = inject(NodeDataService); +// readonly edges = toSignal( +// combineLatest([toObservable(this.nodeDataService.nodes), this.loadedEdges]).pipe( +// switchMap(([nodes, edges]) => this.computeEdges(nodes, edges)), +// ), +// { +// initialValue: undefined, +// }, +// ); + +// private loadEdges(data: EdgesInput): ObservableInput { +// if (Array.isArray(data)) { +// return of(data); +// } else if (typeof data === 'string') { +// const edgesData = fetchCsv(data, { header: false }); +// edgesData.then((res) => of(res)); +// } +// return of([]); +// } + +// private computeEdges(nodesData: NodesData, edgesData: EdgesData): ObservableInput { +// if (nodesData.nodes.length === 0) { +// return of({ edges: undefined, maxEdgeDistance: 0 }); +// } else if (edgesData.edges === undefined) { +// const { nodes, key, value } = nodesData; +// const distEdges = distanceEdges(nodes, key, value, edgesData.maxEdgeDistance); +// // +// } + +// return of(edgesData); +// } +// } diff --git a/libs/node-dist-vis/src/lib/services/node-data.service.ts b/libs/node-dist-vis/src/lib/services/node-data.service.ts new file mode 100644 index 000000000..80180ec77 --- /dev/null +++ b/libs/node-dist-vis/src/lib/services/node-data.service.ts @@ -0,0 +1,77 @@ +// import { computed, Injectable } from '@angular/core'; +// import { toSignal } from '@angular/core/rxjs-interop'; +// import { distinctUntilChanged, map, ObservableInput, of, Subject, switchMap, zip } from 'rxjs'; +// import { NodeEntry, NodeTargetKey } from '../models/nodes'; +// import { fetchCsv } from '../utils/helper'; + +// export type NodesInput = string | NodeEntry[] | undefined; + +// export interface NodesData { +// nodes: NodeEntry[]; +// key: NodeTargetKey; +// value: string; +// } + +// const EMPTY_DATA: NodesData = { +// nodes: [], +// key: '' as NodeTargetKey, +// value: '', +// }; + +// // function compareNodesInput(prev: NodesInput, curr: NodesInput): boolean { +// // return ( +// // (prev.input === curr.input || +// // (Array.isArray(prev.input) && prev.input.length === 0 && Array.isArray(curr.input) && curr.input.length === 0)) && +// // prev.key === curr.key && +// // prev.value === prev.value +// // ); +// // } + +// @Injectable() +// export class NodeDataService { +// private readonly nodesInput$ = new Subject(); +// private readonly nodesKey$ = new Subject(); +// private readonly nodesValue$ = new Subject(); +// private readonly nodes$ = this.nodesInput$.pipe( +// distinctUntilChanged(), +// switchMap((data) => this.loadNodes(data)), +// map((nodes) => this.setPositions(nodes)), +// ); + +// readonly nodesData = toSignal( +// zip(this.nodes$, this.nodesKey$, this.nodesValue$).pipe( +// map(([nodes, key, value]): NodesData => ({ nodes, key, value })), +// ), +// { initialValue: EMPTY_DATA }, +// ); +// readonly nodes = computed(() => this.nodesData().nodes); + +// setInput(input: NodesInput, key: NodeTargetKey, value: string): void { +// this.nodesInput$.next(input); +// this.nodesKey$.next(key); +// this.nodesValue$.next(value); +// } + +// private loadNodes(data: NodesInput): ObservableInput { +// if (Array.isArray(data)) { +// return of(data); +// } else if (typeof data === 'string') { +// const nodeData = this.loadNodesFromCsv(data); +// nodeData.then((response)=>of(response)) +// } +// return of([]) +// } + +// private async loadNodesFromCsv(url: string): Promise { +// const nodeData = await fetchCsv(url); +// return nodeData; +// } + +// private setPositions(nodes: NodeEntry[]): NodeEntry[] { +// for (const node of nodes) { +// node.position = [node.x ?? 0, node.y ?? 0, node.z ?? 0]; +// } + +// return nodes; +// } +// } diff --git a/libs/node-dist-vis/src/lib/utils/distance-edges.service.ts b/libs/node-dist-vis/src/lib/utils/distance-edges.service.ts new file mode 100644 index 000000000..1ffdcc094 --- /dev/null +++ b/libs/node-dist-vis/src/lib/utils/distance-edges.service.ts @@ -0,0 +1,32 @@ +// distances.service.ts +import { Injectable } from '@angular/core'; +import { readFileSync, createWriteStream } from 'fs'; +import Papa from 'papaparse'; +import { distanceEdges } from './distance-edges'; + +@Injectable({ + providedIn: 'root', +}) +export class DistancesService { + calculateDistances( + nodesFile: string, + targetKey: string, + targetValue: string, + maxDist: number, + outputFile: string, + ): void { + const nodes = Papa.parse(readFileSync(nodesFile).toString(), { + header: true, + dynamicTyping: true, + skipEmptyLines: true, + }).data; + + const out = createWriteStream(outputFile); + + for (const row of distanceEdges(nodes, targetKey, targetValue, maxDist)) { + out.write(row.join(',') + '\n'); + } + + out.end(); + } +} diff --git a/libs/node-dist-vis/src/lib/utils/distance-edges.ts b/libs/node-dist-vis/src/lib/utils/distance-edges.ts new file mode 100644 index 000000000..f7daebeab --- /dev/null +++ b/libs/node-dist-vis/src/lib/utils/distance-edges.ts @@ -0,0 +1,80 @@ +// function squaredDistance3D(a: number[], b: number[]): number { +// const dx = a[0] - b[0]; +// const dy = a[1] - b[1]; +// const dz = a[2] - b[2]; +// return dx * dx + dy * dy + dz * dz; +// } + +// const CELL_OFFSETS = [ +// [-1, -1], +// [-1, 0], +// [-1, 1], +// [1, -1], +// [1, 0], +// [1, 1], +// [0, -1], +// [0, 0], +// [0, 1], +// ]; + +// function* getClosest(sources: number[][], sourceIndexes: number[], targets: number[][], maxDistSquared: number) { +// for (const [index, source] of sources.entries()) { +// let minDist = maxDistSquared; +// let closest: number[] | undefined; +// for (const target of targets) { +// const distSquared = squaredDistance3D(source, target); +// if (distSquared < minDist) { +// minDist = distSquared; +// closest = target; +// } +// } +// if (closest) { +// yield [sourceIndexes[index], ...source, ...closest]; +// } +// } +// } + +// function addToCell(node: any, cells: any) { +// const cx = (cells[node.cell[0]] = cells[node.cell[0]] || {}); +// const cy = (cx[node.cell[1]] = cx[node.cell[1]] || { +// nodes: [], +// positions: [], +// }); +// cy.nodes.push(node.__index__); +// cy.positions.push(node.position); +// } + +// export function* distanceEdges(nodes: any[], type_field: string, target_type: string, maxDist: number) { +// console.log(nodes, type_field, target_type, maxDist) +// const source_cells: any = {}; +// const target_cells: any = {}; +// for (const [node_index, node] of nodes.entries()) { +// node.__index__ = node_index; +// node.position = [node.x ?? 0, node.y ?? 0, node.z ?? 0]; +// node.cell = [Math.floor(node.x / maxDist), Math.floor(node.y / maxDist)]; +// if (node[type_field] === target_type) { +// addToCell(node, target_cells); +// } else { +// addToCell(node, source_cells); +// } +// } + +// const maxDistSquared = maxDist * maxDist; +// for (const sourceCellX in source_cells) { +// for (const sourceCellY in source_cells[sourceCellX]) { +// const sources = source_cells[sourceCellX][sourceCellY]; +// let allTargets: number[][] = []; +// for (const [offsetX, offsetY] of CELL_OFFSETS) { +// const cellX = parseInt(sourceCellX) + offsetX; +// const cellY = parseInt(sourceCellY) + offsetY; +// const targets = target_cells[cellX]?.[cellY]; +// if (targets) { +// allTargets = allTargets.concat(targets.positions); +// } +// } +// if (allTargets.length > 0) { +// yield* getClosest(sources.positions, sources.nodes, allTargets, maxDistSquared); +// } +// } +// } +// } diff --git a/libs/node-dist-vis/src/lib/utils/helper.ts b/libs/node-dist-vis/src/lib/utils/helper.ts new file mode 100644 index 000000000..d3d1d3db9 --- /dev/null +++ b/libs/node-dist-vis/src/lib/utils/helper.ts @@ -0,0 +1,15 @@ +// import Papa from 'papaparse'; + +// export async function fetchCsv(url: string, papaOptions = {}): Promise { +// return new Promise((resolve) => { +// Papa.parse(url, { +// header: true, +// skipEmptyLines: true, +// dynamicTyping: true, +// ...papaOptions, +// complete: (results) => { +// resolve(results.data); +// }, +// }); +// }); +// } From 2cd7fcb3b224bfbf5d49d4bb3cde20675c7c3c4f Mon Sep 17 00:00:00 2001 From: Bhushan Khope Date: Tue, 17 Sep 2024 16:42:15 -0400 Subject: [PATCH 03/23] refactor node data service --- .../node-dist-vis/node-dist-vis.component.ts | 439 +++++++++--------- ....vis.worker.ts => node-dist-vis.worker.ts} | 1 + .../src/lib/services/edge-data.service.ts | 36 +- .../src/lib/services/node-data.service.ts | 152 +++--- .../src/lib/utils/distance-edges.ts | 2 +- libs/node-dist-vis/tsconfig.worker.json | 9 + libs/shared/utils/file-loaders/README.md | 3 + .../shared/utils/file-loaders/ng-package.json | 5 + libs/shared/utils/file-loaders/src/index.ts | 3 + .../src/lib/csv-file-loader.service.spec.ts | 160 +++++++ .../src/lib/csv-file-loader.service.ts | 123 +++++ .../utils/file-loaders/src/lib/file-loader.ts | 31 ++ .../src/lib/json-file-loader.service.spec.ts | 104 +++++ .../src/lib/json-file-loader.service.ts | 89 ++++ libs/shared/utils/tsconfig.lib.json | 2 +- tsconfig.base.json | 3 + 16 files changed, 864 insertions(+), 298 deletions(-) rename libs/node-dist-vis/src/lib/node-dist-vis/{node-dist.vis.worker.ts => node-dist-vis.worker.ts} (96%) create mode 100644 libs/node-dist-vis/tsconfig.worker.json create mode 100644 libs/shared/utils/file-loaders/README.md create mode 100644 libs/shared/utils/file-loaders/ng-package.json create mode 100644 libs/shared/utils/file-loaders/src/index.ts create mode 100644 libs/shared/utils/file-loaders/src/lib/csv-file-loader.service.spec.ts create mode 100644 libs/shared/utils/file-loaders/src/lib/csv-file-loader.service.ts create mode 100644 libs/shared/utils/file-loaders/src/lib/file-loader.ts create mode 100644 libs/shared/utils/file-loaders/src/lib/json-file-loader.service.spec.ts create mode 100644 libs/shared/utils/file-loaders/src/lib/json-file-loader.service.ts diff --git a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts index 77f059abb..4811596aa 100644 --- a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts +++ b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts @@ -2,21 +2,14 @@ // import { // ChangeDetectionStrategy, // Component, -// effect, // EffectRef, // ElementRef, // inject, // input, // Input, -// OnChanges, -// OnDestroy, -// OnInit, // output, -// untracked, -// ViewChild, +// ViewChild // } from '@angular/core'; -// import { Deck, OrbitView } from '@deck.gl/core'; -// import Papa from 'papaparse'; // import { EdgeEntry } from '../models/edges'; // import { NodeEntry } from '../models/nodes'; // import { EdgeDataService } from '../services/edge-data.service'; @@ -31,7 +24,7 @@ // styleUrl: './node-dist-vis.component.scss', // changeDetection: ChangeDetectionStrategy.OnPush, // }) -// export class NodeDistVisComponent implements OnInit, OnDestroy, OnChanges { +// export class NodeDistVisComponent { // readonly nodes = input(); // readonly edges = input(); @@ -57,233 +50,237 @@ // private readonly edgeDataService = inject(EdgeDataService); // constructor() { -// effect(() => { -// this.nodeDataService.nodesInput.next(this.nodes()); -// this.edgeDataService.edgesInput.next(untracked(this.edges)); -// }); - -// effect(() => { -// this.edgeDataService.edgesInput.next(this.edges()); -// }); +// console.log(this) // } -// private deck!: Deck; -// // private nodes: NodeEntry[] | undefined = []; -// private colorCoding: any; - -// static readonly observedAttributes = [ -// 'nodes', -// 'edges', -// 'color-map', -// 'color-map-key', -// 'color-map-value', -// 'node-target-key', -// 'node-target-value', -// 'max-edge-distance', -// 'selection', -// ]; - -// private async loadData() { -// if (this.nodesData) { -// this.nodes = this.nodesData(); -// } else { -// this.nodes = await this.fetchCsv(this.nodesUrl() ?? ''); -// } - -// if (this.edgesData) { -// this.edges = this.edgesData(); -// } else { -// this.edges = await this.fetchCsv(this.edgesUrl() ?? ''); -// } -// this.colorCoding = await this.loadColorCoding(); -// this.updateLayers(); -// } +// // constructor() { +// // effect(() => { +// // this.nodeDataService.nodesInput.next(this.nodes()); +// // this.edgeDataService.edgesInput.next(untracked(this.edges)); +// // }); -// private changedCallback(name: string, newValue: string | number) { -// if (this.initialized) { -// if (name === 'max-edge-distance' && typeof newValue === 'string') { -// newValue = parseFloat(newValue); -// } else if (name === 'selection' && typeof newValue === 'string') { -// newValue = this.parseSelectionValue(newValue); -// } -// this.attributesLookup[name].value = newValue; -// } -// } +// // effect(() => { +// // this.edgeDataService.edgesInput.next(this.edges()); +// // }); +// // } -// ngOnInit() { -// this.loadData(); -// this.initializeDeck(); -// } +// // private deck!: Deck; +// // // private nodes: NodeEntry[] | undefined = []; +// // private colorCoding: any; -// ngOnChanges(): void { -// this.changedCallback(); -// } -// ngOnDestroy() { -// this.toDispose.forEach((dispose) => dispose()); -// this.toDispose = []; -// this.deck.finalize(); -// } +// // static readonly observedAttributes = [ +// // 'nodes', +// // 'edges', +// // 'color-map', +// // 'color-map-key', +// // 'color-map-value', +// // 'node-target-key', +// // 'node-target-value', +// // 'max-edge-distance', +// // 'selection', +// // ]; -// private initializeDeck() { -// let isHovering = false; -// let hoveredObject = undefined; -// this.deck = new Deck({ -// canvas: this.visCanvas.nativeElement, -// controller: true, -// views: [new OrbitView({ id: 'orbit', orbitAxis: 'Y' })], -// initialViewState: this.getInitialViewState(), -// onClick: (e: Event) => (e.picked ? this.dispatch('nodeClicked', e.object) : undefined), -// onViewStateChange: ({ viewState }) => (this.viewState.value = viewState), -// onLoad: () => (this.viewState.value = this.deck.viewState), -// onHover: (e) => { -// isHovering = e.picked; -// if (isHovering) { -// if (hoveredObject !== e.object) { -// this.dispatch('nodeHovering', e.object); -// hoveredObject = e.object; -// } -// } else { -// if (hoveredObject) { -// this.dispatch('nodeHovering', undefined); -// hoveredObject = undefined; -// } -// } -// }, -// getCursor: (e) => (isHovering ? 'pointer' : e.isDragging ? 'grabbing' : 'grab'), -// layers: [], -// }); - -// this.trackDisposal( -// effect(() => { -// const layers = [this.nodesLayer.value, this.edgesLayer.value, this.scaleBarLayer.value].filter((l) => !!l); -// this.deck.setProps({ layers }); -// }), -// ); - -// this.trackDisposal( -// effect(async () => { -// this.nodes.value = []; -// this.nodes.value = await this.nodes$.value; -// this.dispatch('nodes', this.nodes.value); -// }), -// ); - -// this.trackDisposal( -// effect(async () => { -// this.edges.value = []; -// const edges = await this.edges$.value; -// if (edges) { -// this.edges.value = edges; -// this.dispatch('edges', this.edges.value); -// } -// }), -// ); - -// this.trackDisposal( -// effect(async () => { -// const colorCoding = await this.colorCoding$.value; -// if (colorCoding) { -// this.colorCoding.value = colorCoding; -// } -// }), -// ); - -// batch(() => { -// this.nodesUrl = this.visCanvas.nativeElement.getAttribute('nodes'); -// this.edgesUrl.value = this.visCanvas.nativeElement.getAttribute('edges'); -// this.colorMapUrl.value = this.visCanvas.nativeElement.getAttribute('color-map'); -// this.colorMapKey.value = this.visCanvas.nativeElement.getAttribute('color-map-key') || 'cell_type'; -// this.colorMapValue.value = this.visCanvas.nativeElement.getAttribute('color-map-value') || 'cell_color'; -// this.nodeTargetKey.value = this.visCanvas.nativeElement.getAttribute('node-target-key'); -// this.nodeTargetValue.value = this.visCanvas.nativeElement.getAttribute('node-target-value'); -// this.maxEdgeDistance.value = parseFloat(this.getAttribute('max-edge-distance')); -// this.selection.value = this.parseSelectionValue(this.getAttribute('selection')); -// this.initialized = true; -// }); -// } +// // private async loadData() { +// // if (this.nodesData) { +// // this.nodes = this.nodesData(); +// // } else { +// // this.nodes = await this.fetchCsv(this.nodesUrl() ?? ''); +// // } -// trackDisposal(disposable: EffectRef) { -// this.toDispose.push(disposable); -// } +// // if (this.edgesData) { +// // this.edges = this.edgesData(); +// // } else { +// // this.edges = await this.fetchCsv(this.edgesUrl() ?? ''); +// // } +// // this.colorCoding = await this.loadColorCoding(); +// // this.updateLayers(); +// // } -// private parseSelectionValue(value: string) { -// if (value === '') { -// return undefined; -// } -// return typeof value === 'string' ? JSON.parse(value) : value; -// } +// // // private changedCallback(name: string, newValue: string | number) { +// // // if (this.initialized) { +// // // if (name === 'max-edge-distance' && typeof newValue === 'string') { +// // // newValue = parseFloat(newValue); +// // // } else if (name === 'selection' && typeof newValue === 'string') { +// // // newValue = this.parseSelectionValue(newValue); +// // // } +// // // this.attributesLookup[name].value = newValue; +// // // } +// // // } -// attributesLookup = { -// nodes: this.nodesUrl, -// edges: this.edgesUrl, -// 'color-map': this.colorMapUrl, -// 'color-map-key': this.colorMapKey, -// 'color-map-value': this.colorMapValue, -// 'node-target-key': this.nodeTargetKey, -// 'node-target-value': this.nodeTargetValue, -// 'max-edge-distance': this.maxEdgeDistance, -// selection: this.selection, -// }; - -// viewStateVersionCounter = 0; -// private getInitialViewState() { -// return { -// // ... initial view state configuration -// version: this.viewStateVersionCounter++, -// orbitAxis: 'Y', -// camera: 'orbit', -// zoom: 9, -// minRotationX: -90, -// maxRotationX: 90, -// rotationX: 0, -// rotationOrbit: 0, -// dragMode: 'rotate', -// target: [0.5, 0.5], -// }; -// } +// // ngOnInit() { +// // this.loadData(); +// // // this.initializeDeck(); +// // } -// private async fetchCsv(url: string): Promise { -// return new Promise((resolve) => { -// Papa.parse(url, { -// header: true, -// skipEmptyLines: true, -// dynamicTyping: true, -// complete: (results) => { -// resolve(results.data); -// }, -// }); -// }); -// } +// // ngOnChanges(): void { +// // // this.changedCallback(); +// // } +// // ngOnDestroy() { +// // // this.toDispose.forEach((dispose) => dispose()); +// // this.toDispose = []; +// // this.deck.finalize(); +// // } -// private async loadColorCoding() { -// // Implement color coding logic -// } +// // // private initializeDeck() { +// // // let isHovering = false; +// // // let hoveredObject = undefined; +// // // this.deck = new Deck({ +// // // canvas: this.visCanvas.nativeElement, +// // // controller: true, +// // // views: [new OrbitView({ id: 'orbit', orbitAxis: 'Y' })], +// // // initialViewState: this.getInitialViewState(), +// // // onClick: (e: Event) => (e.picked ? this.dispatch('nodeClicked', e.object) : undefined), +// // // onViewStateChange: ({ viewState }) => (this.viewState.value = viewState), +// // // onLoad: () => (this.viewState.value = this.deck.viewState), +// // // onHover: (e) => { +// // // isHovering = e.picked; +// // // if (isHovering) { +// // // if (hoveredObject !== e.object) { +// // // this.dispatch('nodeHovering', e.object); +// // // hoveredObject = e.object; +// // // } +// // // } else { +// // // if (hoveredObject) { +// // // this.dispatch('nodeHovering', undefined); +// // // hoveredObject = undefined; +// // // } +// // // } +// // // }, +// // // getCursor: (e) => (isHovering ? 'pointer' : e.isDragging ? 'grabbing' : 'grab'), +// // // layers: [], +// // // }); -// private updateLayers() { -// const layers = [this.createNodesLayer(), this.createEdgesLayer(), this.createScaleBarLayer()].filter((l) => !!l); +// // // this.trackDisposal( +// // // effect(() => { +// // // const layers = [this.nodesLayer.value, this.edgesLayer.value, this.scaleBarLayer.value].filter((l) => !!l); +// // // this.deck.setProps({ layers }); +// // // }), +// // // ); -// this.deck.setProps({ layers }); -// } +// // // this.trackDisposal( +// // // effect(async () => { +// // // this.nodes.value = []; +// // // this.nodes.value = await this.nodes$.value; +// // // this.dispatch('nodes', this.nodes.value); +// // // }), +// // // ); -// private createNodesLayer() { -// // Implement PointCloudLayer creation -// } +// // // this.trackDisposal( +// // // effect(async () => { +// // // this.edges.value = []; +// // // const edges = await this.edges$.value; +// // // if (edges) { +// // // this.edges.value = edges; +// // // this.dispatch('edges', this.edges.value); +// // // } +// // // }), +// // // ); -// private createEdgesLayer() { -// // Implement LineLayer creation -// } +// // // this.trackDisposal( +// // // effect(async () => { +// // // const colorCoding = await this.colorCoding$.value; +// // // if (colorCoding) { +// // // this.colorCoding.value = colorCoding; +// // // } +// // // }), +// // // ); -// private createScaleBarLayer() { -// // Implement ScaleBarLayer creation -// } +// // // batch(() => { +// // // this.nodesUrl = this.visCanvas.nativeElement.getAttribute('nodes'); +// // // this.edgesUrl.value = this.visCanvas.nativeElement.getAttribute('edges'); +// // // this.colorMapUrl.value = this.visCanvas.nativeElement.getAttribute('color-map'); +// // // this.colorMapKey.value = this.visCanvas.nativeElement.getAttribute('color-map-key') || 'cell_type'; +// // // this.colorMapValue.value = this.visCanvas.nativeElement.getAttribute('color-map-value') || 'cell_color'; +// // // this.nodeTargetKey.value = this.visCanvas.nativeElement.getAttribute('node-target-key'); +// // // this.nodeTargetValue.value = this.visCanvas.nativeElement.getAttribute('node-target-value'); +// // // this.maxEdgeDistance.value = parseFloat(this.getAttribute('max-edge-distance')); +// // // this.selection.value = this.parseSelectionValue(this.getAttribute('selection')); +// // // this.initialized = true; +// // // }); +// // } -// private dispatch(eventName: string, payload = undefined) { -// let event; -// if (payload) { -// event = new CustomEvent(eventName, { detail: payload }); -// } else { -// event = new Event(eventName); -// } -// this.dispatchEvent.emit(event); -// } +// // trackDisposal(disposable: EffectRef) { +// // this.toDispose.push(disposable); +// // } + +// // private parseSelectionValue(value: string) { +// // if (value === '') { +// // return undefined; +// // } +// // return typeof value === 'string' ? JSON.parse(value) : value; +// // } + +// // attributesLookup = { +// // nodes: this.nodesUrl, +// // edges: this.edgesUrl, +// // 'color-map': this.colorMapUrl, +// // 'color-map-key': this.colorMapKey, +// // 'color-map-value': this.colorMapValue, +// // 'node-target-key': this.nodeTargetKey, +// // 'node-target-value': this.nodeTargetValue, +// // 'max-edge-distance': this.maxEdgeDistance, +// // selection: this.selection, +// // }; + +// // viewStateVersionCounter = 0; +// // private getInitialViewState() { +// // return { +// // // ... initial view state configuration +// // version: this.viewStateVersionCounter++, +// // orbitAxis: 'Y', +// // camera: 'orbit', +// // zoom: 9, +// // minRotationX: -90, +// // maxRotationX: 90, +// // rotationX: 0, +// // rotationOrbit: 0, +// // dragMode: 'rotate', +// // target: [0.5, 0.5], +// // }; +// // } + +// // private async fetchCsv(url: string): Promise { +// // return new Promise((resolve) => { +// // Papa.parse(url, { +// // header: true, +// // skipEmptyLines: true, +// // dynamicTyping: true, +// // complete: (results) => { +// // resolve(results.data); +// // }, +// // }); +// // }); +// // } + +// // private async loadColorCoding() { +// // // Implement color coding logic +// // } + +// // private updateLayers() { +// // const layers = [this.createNodesLayer(), this.createEdgesLayer(), this.createScaleBarLayer()].filter((l) => !!l); + +// // this.deck.setProps({ layers }); +// // } + +// // private createNodesLayer() { +// // // Implement PointCloudLayer creation +// // } + +// // private createEdgesLayer() { +// // // Implement LineLayer creation +// // } + +// // private createScaleBarLayer() { +// // // Implement ScaleBarLayer creation +// // } + +// // private dispatch(eventName: string, payload = undefined) { +// // let event; +// // if (payload) { +// // event = new CustomEvent(eventName, { detail: payload }); +// // } else { +// // event = new Event(eventName); +// // } +// // this.dispatchEvent.emit(event); +// // } // } diff --git a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist.vis.worker.ts b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.worker.ts similarity index 96% rename from libs/node-dist-vis/src/lib/node-dist-vis/node-dist.vis.worker.ts rename to libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.worker.ts index aac06aada..3e273cfd6 100644 --- a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist.vis.worker.ts +++ b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.worker.ts @@ -1,3 +1,4 @@ +// /// // import { NodeEntry } from '../models/nodes'; // import { distanceEdges } from '../utils/distance-edges'; diff --git a/libs/node-dist-vis/src/lib/services/edge-data.service.ts b/libs/node-dist-vis/src/lib/services/edge-data.service.ts index e4bc537b2..5a20acb9b 100644 --- a/libs/node-dist-vis/src/lib/services/edge-data.service.ts +++ b/libs/node-dist-vis/src/lib/services/edge-data.service.ts @@ -1,11 +1,10 @@ -// import { computed, inject, Injectable } from '@angular/core'; +// import { inject, Injectable } from '@angular/core'; // import { toObservable, toSignal } from '@angular/core/rxjs-interop'; // import { combineLatest, distinctUntilChanged, ObservableInput, of, Subject, switchMap } from 'rxjs'; // import { EdgeEntry } from '../models/edges'; -// import { NodeDataService, NodesData } from './node-data.service'; -// import { NodeEntry } from '../models/nodes'; -// import { distanceEdges } from '../utils/distance-edges'; +// import { NodeEntry, NodeTargetKey } from '../models/nodes'; // import { fetchCsv } from '../utils/helper'; +// import { NodeDataService, NodesData } from './node-data.service'; // export type EdgesInput = string | EdgeEntry[] | undefined; @@ -24,8 +23,8 @@ // private readonly nodeDataService = inject(NodeDataService); // readonly edges = toSignal( -// combineLatest([toObservable(this.nodeDataService.nodes), this.loadedEdges]).pipe( -// switchMap(([nodes, edges]) => this.computeEdges(nodes, edges)), +// combineLatest([toObservable(this.nodeDataService.nodesData), this.loadedEdges]).pipe( +// switchMap(([nodes, edges]) => this.computeEdges(nodes, { edges, maxEdgeDistance: 0 })), // ), // { // initialValue: undefined, @@ -42,13 +41,34 @@ // return of([]); // } +// private async customDistanceEdges(nodes: NodeEntry[], key: NodeTargetKey, value: string, maxEdgeDist: number) { +// if (typeof Worker !== 'undefined') { +// const worker = new Worker(new URL('../node-dist-vis/node-dist-vis.worker', import.meta.url)); +// return new Promise((resolve) => { +// worker.onmessage = (e) => { +// if (e.data.status === 'processing') { +// console.log(`Computing edges; ${e.data.percentage}% complete.`); +// } else if (e.data.status === 'complete') { +// resolve(e.data.edges); +// worker.terminate(); +// } +// }; +// worker.postMessage({ nodes, key, value, maxEdgeDist }); +// }); +// } else { +// return; +// // Web workers are not supported in this environment. +// // You should add a fallback so that your program still executes correctly. +// } +// } + // private computeEdges(nodesData: NodesData, edgesData: EdgesData): ObservableInput { // if (nodesData.nodes.length === 0) { // return of({ edges: undefined, maxEdgeDistance: 0 }); // } else if (edgesData.edges === undefined) { // const { nodes, key, value } = nodesData; -// const distEdges = distanceEdges(nodes, key, value, edgesData.maxEdgeDistance); -// // +// const distEdges = this.customDistanceEdges(nodes, key, value, edgesData.maxEdgeDistance); +// distEdges.then(res=>of(res)) // } // return of(edgesData); diff --git a/libs/node-dist-vis/src/lib/services/node-data.service.ts b/libs/node-dist-vis/src/lib/services/node-data.service.ts index 80180ec77..10014c582 100644 --- a/libs/node-dist-vis/src/lib/services/node-data.service.ts +++ b/libs/node-dist-vis/src/lib/services/node-data.service.ts @@ -1,77 +1,95 @@ -// import { computed, Injectable } from '@angular/core'; -// import { toSignal } from '@angular/core/rxjs-interop'; -// import { distinctUntilChanged, map, ObservableInput, of, Subject, switchMap, zip } from 'rxjs'; -// import { NodeEntry, NodeTargetKey } from '../models/nodes'; -// import { fetchCsv } from '../utils/helper'; +import { computed, DestroyRef, inject, Injectable, signal } from '@angular/core'; +import { CsvFileLoaderService } from '@hra-ui/utils/file-loaders'; +import { Subscription } from 'rxjs'; +import { NodeEntry, NodeTargetKey } from '../models/nodes'; -// export type NodesInput = string | NodeEntry[] | undefined; +export type NodesInput = string | NodeEntry[] | undefined; -// export interface NodesData { -// nodes: NodeEntry[]; -// key: NodeTargetKey; -// value: string; -// } +export interface NodesData { + nodes: NodeEntry[]; + key: NodeTargetKey; + value: string; +} -// const EMPTY_DATA: NodesData = { -// nodes: [], -// key: '' as NodeTargetKey, -// value: '', -// }; +const EMPTY_DATA: NodesData = { + nodes: [], + key: '' as NodeTargetKey, + value: '', +}; -// // function compareNodesInput(prev: NodesInput, curr: NodesInput): boolean { -// // return ( -// // (prev.input === curr.input || -// // (Array.isArray(prev.input) && prev.input.length === 0 && Array.isArray(curr.input) && curr.input.length === 0)) && -// // prev.key === curr.key && -// // prev.value === prev.value -// // ); -// // } +@Injectable() +export class NodeDataService { + private url?: string; + private data?: NodeEntry[]; + private key = EMPTY_DATA.key; + private value = EMPTY_DATA.value; + private subscription?: Subscription; + private readonly csvFileLoader = inject(CsvFileLoaderService); + private readonly nodeDataMut = signal(EMPTY_DATA); + readonly nodeData = this.nodeDataMut.asReadonly(); + readonly nodes = computed(() => this.nodeData().nodes); -// @Injectable() -// export class NodeDataService { -// private readonly nodesInput$ = new Subject(); -// private readonly nodesKey$ = new Subject(); -// private readonly nodesValue$ = new Subject(); -// private readonly nodes$ = this.nodesInput$.pipe( -// distinctUntilChanged(), -// switchMap((data) => this.loadNodes(data)), -// map((nodes) => this.setPositions(nodes)), -// ); + constructor() { + inject(DestroyRef).onDestroy(() => this.clear()); + } -// readonly nodesData = toSignal( -// zip(this.nodes$, this.nodesKey$, this.nodesValue$).pipe( -// map(([nodes, key, value]): NodesData => ({ nodes, key, value })), -// ), -// { initialValue: EMPTY_DATA }, -// ); -// readonly nodes = computed(() => this.nodesData().nodes); + setInput(input: NodesInput, key: NodeTargetKey, value: string): void { + if (input === undefined || Array.isArray(input)) { + input ??= []; + this.clear(); + this.setPositions(input); + this.emit(input, key, value); + } else if (input !== this.url) { + this.clear(); + this.url = input; + this.key = key; + this.value = value; + this.load(input); + } else if (this.data) { + this.emit(this.data, key, value); + } else { + this.key = key; + this.value = value; + } + } -// setInput(input: NodesInput, key: NodeTargetKey, value: string): void { -// this.nodesInput$.next(input); -// this.nodesKey$.next(key); -// this.nodesValue$.next(value); -// } + private emit(nodes: NodeEntry[], key: NodeTargetKey, value: string): void { + this.nodeDataMut.set({ nodes, key, value }); + } -// private loadNodes(data: NodesInput): ObservableInput { -// if (Array.isArray(data)) { -// return of(data); -// } else if (typeof data === 'string') { -// const nodeData = this.loadNodesFromCsv(data); -// nodeData.then((response)=>of(response)) -// } -// return of([]) -// } + private load(url: string): void { + this.subscription = this.csvFileLoader + .load(url, { + papaparse: { + header: true, + dynamicTyping: { + x: true, + y: true, + z: true, + }, + }, + }) + .subscribe((event) => { + if (event.type === 'data') { + this.data = event.data; + this.setPositions(this.data); + this.emit(this.data, this.key, this.value); + } + }); + } -// private async loadNodesFromCsv(url: string): Promise { -// const nodeData = await fetchCsv(url); -// return nodeData; -// } + private setPositions(nodes: NodeEntry[]): void { + for (const node of nodes) { + node.position = [node.x ?? 0, node.y ?? 0, node.z ?? 0]; + } + } -// private setPositions(nodes: NodeEntry[]): NodeEntry[] { -// for (const node of nodes) { -// node.position = [node.x ?? 0, node.y ?? 0, node.z ?? 0]; -// } - -// return nodes; -// } -// } + private clear(): void { + this.subscription?.unsubscribe(); + this.url = undefined; + this.data = undefined; + this.key = EMPTY_DATA.key; + this.value = EMPTY_DATA.value; + this.subscription = undefined; + } +} diff --git a/libs/node-dist-vis/src/lib/utils/distance-edges.ts b/libs/node-dist-vis/src/lib/utils/distance-edges.ts index f7daebeab..64957aa27 100644 --- a/libs/node-dist-vis/src/lib/utils/distance-edges.ts +++ b/libs/node-dist-vis/src/lib/utils/distance-edges.ts @@ -45,7 +45,7 @@ // } // export function* distanceEdges(nodes: any[], type_field: string, target_type: string, maxDist: number) { -// console.log(nodes, type_field, target_type, maxDist) +// console.log(nodes, type_field, target_type, maxDist); // const source_cells: any = {}; // const target_cells: any = {}; // for (const [node_index, node] of nodes.entries()) { diff --git a/libs/node-dist-vis/tsconfig.worker.json b/libs/node-dist-vis/tsconfig.worker.json new file mode 100644 index 000000000..d6c930080 --- /dev/null +++ b/libs/node-dist-vis/tsconfig.worker.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../..//out-tsc/worker", + "lib": ["es2018", "webworker"], + "types": [] + }, + "include": ["src/**/*.worker.ts"] +} diff --git a/libs/shared/utils/file-loaders/README.md b/libs/shared/utils/file-loaders/README.md new file mode 100644 index 000000000..7fc84e18d --- /dev/null +++ b/libs/shared/utils/file-loaders/README.md @@ -0,0 +1,3 @@ +# @hra-ui/utils/file-loaders + +Secondary entry point of `@hra-ui/utils`. It can be used by importing from `@hra-ui/utils/file-loaders`. diff --git a/libs/shared/utils/file-loaders/ng-package.json b/libs/shared/utils/file-loaders/ng-package.json new file mode 100644 index 000000000..c781f0df4 --- /dev/null +++ b/libs/shared/utils/file-loaders/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/shared/utils/file-loaders/src/index.ts b/libs/shared/utils/file-loaders/src/index.ts new file mode 100644 index 000000000..1be66a56c --- /dev/null +++ b/libs/shared/utils/file-loaders/src/index.ts @@ -0,0 +1,3 @@ +export * from './lib/csv-file-loader.service'; +export * from './lib/file-loader'; +export * from './lib/json-file-loader.service'; diff --git a/libs/shared/utils/file-loaders/src/lib/csv-file-loader.service.spec.ts b/libs/shared/utils/file-loaders/src/lib/csv-file-loader.service.spec.ts new file mode 100644 index 000000000..e79227080 --- /dev/null +++ b/libs/shared/utils/file-loaders/src/lib/csv-file-loader.service.spec.ts @@ -0,0 +1,160 @@ +import { TestBed } from '@angular/core/testing'; +import { mock } from 'jest-mock-extended'; +import { ParseError, ParseLocalConfig, ParseMeta, ParseResult, Parser, parse } from 'papaparse'; +import { Observable, firstValueFrom, toArray } from 'rxjs'; +import { CsvFileLoaderService } from './csv-file-loader.service'; +import { FileLoaderEvent } from './file-loader'; + +jest.mock('papaparse', () => ({ + parse: jest.fn(), + LocalChunkSize: 100, + RemoteChunkSize: 200, +})); + +describe('CsvFileLoaderService', () => { + const url = 'https://example.com'; + const data = [ + { a: 1, b: 2 }, + { a: 2, b: 3 }, + ]; + const meta: ParseMeta = { + aborted: false, + cursor: 0, + delimiter: ',', + linebreak: '\n', + truncated: false, + }; + const chunkResult: ParseResult = { + data, + meta, + errors: [], + }; + const completeResult: ParseResult = { + data: [], + errors: [], + meta, + }; + const dataEvent: FileLoaderEvent = { + type: 'data', + data: data, + }; + const parser = mock(); + let service: CsvFileLoaderService; + + async function getEvents(source: Observable>): Promise[]> { + return firstValueFrom(source.pipe(toArray())); + } + + function getConfig(): ParseLocalConfig { + return jest.mocked(parse).mock.calls[0][1] as ParseLocalConfig; + } + + beforeEach(() => { + service = TestBed.inject(CsvFileLoaderService); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('loads a local file', async () => { + const file = { size: 20 } as File; + const result$ = service.load(file, {}); + const eventsPromise = getEvents(result$); + expect(parse).toHaveBeenCalledWith(file, expect.anything()); + + const config = getConfig(); + config.chunk?.(chunkResult, parser); + config.complete?.(completeResult, undefined); + + expect(await eventsPromise).toEqual([ + { + type: 'progress', + loaded: 20, + total: 20, + }, + dataEvent, + ]); + }); + + it('loads a remote file', async () => { + const result$ = service.load(url, {}); + const eventsPromise = getEvents(result$); + expect(parse).toHaveBeenCalledWith(url, expect.anything()); + + const config = getConfig(); + config.chunk?.(chunkResult, parser); + config.complete?.(completeResult, undefined); + + expect(await eventsPromise).toEqual([ + { + type: 'progress', + loaded: 200, + }, + dataEvent, + ]); + }); + + it('emits multiple data events when not in collect mode', async () => { + const result$ = service.load(url, { collect: false }); + const eventsPromise = getEvents(result$); + expect(parse).toHaveBeenCalledWith(url, expect.anything()); + + const config = getConfig(); + config.chunk?.(chunkResult, parser); + config.chunk?.(chunkResult, parser); + config.complete?.(completeResult, undefined); + + expect(await eventsPromise).toEqual([ + { + type: 'progress', + loaded: 200, + }, + dataEvent, + { + type: 'progress', + loaded: 400, + }, + dataEvent, + ]); + }); + + it('aborts the loading when there are no observers', async () => { + const result$ = service.load(url, {}); + result$.subscribe().unsubscribe(); + + const config = getConfig(); + config.chunk?.(chunkResult, parser); + expect(parser.abort).toHaveBeenCalled(); + }); + + it('aborts the loading when there are too many errors', async () => { + const errors: ParseError[] = [{ code: 'TooFewFields', message: '', type: 'FieldMismatch' }]; + const result$ = service.load(url, { errorTolerance: 0 }); + const eventsPromise = getEvents(result$); + + const config = getConfig(); + config.chunk?.( + { + data, + errors, + meta, + }, + parser, + ); + + expect(parser.abort).toHaveBeenCalled(); + expect(eventsPromise).rejects.toEqual(errors); + }); + + it('forwards other errors to the subscriber', async () => { + const error = new Error('some other error'); + const result$ = service.load(url, { errorTolerance: 0 }); + const eventsPromise = getEvents(result$); + + const config = getConfig(); + config.error?.(error, undefined); + + expect(eventsPromise).rejects.toEqual(error); + }); +}); diff --git a/libs/shared/utils/file-loaders/src/lib/csv-file-loader.service.ts b/libs/shared/utils/file-loaders/src/lib/csv-file-loader.service.ts new file mode 100644 index 000000000..09a924db8 --- /dev/null +++ b/libs/shared/utils/file-loaders/src/lib/csv-file-loader.service.ts @@ -0,0 +1,123 @@ +import { Injectable } from '@angular/core'; +import { LocalChunkSize, ParseError, ParseLocalConfig, ParseRemoteConfig, RemoteChunkSize, parse } from 'papaparse'; +import { Observable, Subject, defer } from 'rxjs'; +import { FileLoader, FileLoaderEvent } from './file-loader'; + +/** Any function type */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyFunction = (...args: any[]) => any; + +/** Configuration keys that are either overridden or functions that can't be sent to a worker */ +type ReservedPapaparseConfigKeys = + | 'transformHeader' + | 'transform' + | 'dynamicTyping' + | 'worker' + | 'download' + | 'beforeFirstChunk' + | 'step' + | 'chunk' + | 'complete' + | 'error'; + +/** Properties picked from remote configuration */ +type RemoteRequestKeys = 'downloadRequestHeaders' | 'downloadRequestBody' | 'withCredentials'; + +/** Dynamic typing option but without the ability to pass a function */ +type DynamicTyping = { dynamicTyping?: Exclude }; + +/** Additional options for loading from URLs */ +type RemoteRequest = Pick; + +/** Accepted papaparse configuration subset */ +export type PapaparseConfig = Omit & DynamicTyping & RemoteRequest; + +/** Csv file loader options */ +export interface CsvFileLoaderOptions { + /** Whether to collect the results into a single data event or emit multiple events */ + collect?: boolean; + /** Number of parsing errors that can happen before the load aborts */ + errorTolerance?: false | number; + /** Additional papaparse configuration */ + papaparse?: PapaparseConfig; +} + +/** Appends items to an array */ +function arrayAppend(array: T[], items: T[]): void { + for (const item of items) { + array.push(item); + } +} + +/** Service for loading CSV files */ +@Injectable({ + providedIn: 'root', +}) +export class CsvFileLoaderService implements FileLoader { + /** Loads a CSV file and returns an observable of the loader events */ + load(file: string | File, options: CsvFileLoaderOptions): Observable> { + return defer(() => this.loadImpl(file, options)); + } + + /** Implementation of the CSV file loading logic */ + private loadImpl(file: string | File, options: CsvFileLoaderOptions): Observable> { + const isLocalFile = typeof file === 'object'; + const fileSize = isLocalFile ? file.size : undefined; + const defaultChunkSize = isLocalFile ? LocalChunkSize : RemoteChunkSize; + const { collect = true, errorTolerance = false, papaparse = {} } = options; + const { chunkSize = defaultChunkSize } = papaparse; + const data: DataT[] = []; + const errors: ParseError[] = []; + const subject = new Subject>(); + let chunkProcessed = 0; + + parse( + file as never, + { + skipEmptyLines: 'greedy', + ...papaparse, + worker: true, + download: !isLocalFile, + chunk(results, parser) { + if (!subject.observed) { + parser.abort(); + return; + } + + if (errorTolerance !== false) { + arrayAppend(errors, results.errors); + if (errors.length > errorTolerance) { + subject.error(errors); + parser.abort(); + return; + } + } + + chunkProcessed += 1; + subject.next({ + type: 'progress', + loaded: Math.min(chunkProcessed * chunkSize, fileSize ?? Infinity), + total: fileSize, + }); + + if (collect) { + arrayAppend(data, results.data); + } else { + subject.next({ type: 'data', data: results.data }); + } + }, + complete() { + if (collect) { + subject.next({ type: 'data', data }); + } + subject.complete(); + }, + error(error) { + subject.error(error); + }, + } as ParseLocalConfig, + ); + + return subject; + } +} diff --git a/libs/shared/utils/file-loaders/src/lib/file-loader.ts b/libs/shared/utils/file-loaders/src/lib/file-loader.ts new file mode 100644 index 000000000..2b96c51c6 --- /dev/null +++ b/libs/shared/utils/file-loaders/src/lib/file-loader.ts @@ -0,0 +1,31 @@ +import { Observable } from 'rxjs'; + +/** Event type for data loading, containing the loaded data */ +export interface FileLoaderDataEvent { + /** Indicates this is a data event */ + type: 'data'; + /** The loaded data of type DataT */ + data: DataT; +} + +/** Event type for progress updates during file loading */ +export interface FileLoaderProgressEvent { + /** Indicates this is a progress event */ + type: 'progress'; + /** Number of bytes loaded */ + loaded: number; + /** Total number of bytes */ + total?: number; +} + +/** Union type for file loader events, can be either data or progress */ +export type FileLoaderEvent = FileLoaderDataEvent | FileLoaderProgressEvent; + +/** Extracts options type from a FileLoader based on the provided LoaderT */ +export type FileLoaderOptions = LoaderT extends FileLoader ? OptionsT : never; + +/** Interface for file loader that defines the load method */ +export interface FileLoader { + /** Loads a file and returns an observable of file loader events */ + load(file: string | File, options: OptionsT): Observable>; +} diff --git a/libs/shared/utils/file-loaders/src/lib/json-file-loader.service.spec.ts b/libs/shared/utils/file-loaders/src/lib/json-file-loader.service.spec.ts new file mode 100644 index 000000000..c0201ad0e --- /dev/null +++ b/libs/shared/utils/file-loaders/src/lib/json-file-loader.service.spec.ts @@ -0,0 +1,104 @@ +import { HttpEventType, provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { mock } from 'jest-mock-extended'; +import { Observable, firstValueFrom, toArray } from 'rxjs'; +import { FileLoaderEvent } from './file-loader'; +import { JsonFileLoaderService } from './json-file-loader.service'; + +describe('JsonFileLoaderService', () => { + const url = 'https://example.com'; + const data = { a: 1, b: 2 }; + const serializedData = JSON.stringify(data); + const size = serializedData.length; + + async function getEvents(source: Observable>): Promise[]> { + return firstValueFrom(source.pipe(toArray())); + } + + it('loads a local file', async () => { + const file = mock({ + size: serializedData.length, + text: () => Promise.resolve(serializedData), + }); + const service = TestBed.inject(JsonFileLoaderService); + const result$ = service.load(file, {}); + const events = await getEvents(result$); + + expect(events).toEqual([ + { + type: 'progress', + loaded: 0, + total: file.size, + }, + { + type: 'progress', + loaded: file.size, + total: file.size, + }, + { + type: 'data', + data: data, + }, + ]); + }); + + it('loads from an url', async () => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + + const service = TestBed.inject(JsonFileLoaderService); + const result$ = service.load(url, {}); + const eventsPromise = getEvents(result$); + const http = TestBed.inject(HttpTestingController); + const request = http.expectOne(url); + + request.event({ + type: HttpEventType.DownloadProgress, + loaded: size, + total: size, + }); + request.event({ + type: HttpEventType.User, + }); + request.flush(data); + + expect(await eventsPromise).toEqual([ + { + type: 'progress', + loaded: 0, + }, + { + type: 'progress', + loaded: size, + total: size, + }, + { + type: 'data', + data: data, + }, + ]); + }); + + it('throws an error if HttpClient in not available', async () => { + const service = TestBed.inject(JsonFileLoaderService); + const result$ = service.load(url, {}); + expect(getEvents(result$)).rejects.toMatch(/HttpClient/); + }); + + it("throws if the response can't be parsed", async () => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + + const service = TestBed.inject(JsonFileLoaderService); + const result$ = service.load(url, {}); + const eventsPromise = getEvents(result$); + const http = TestBed.inject(HttpTestingController); + const request = http.expectOne(url); + + request.flush(null); + expect(eventsPromise).rejects.toMatch(/parse/); + }); +}); diff --git a/libs/shared/utils/file-loaders/src/lib/json-file-loader.service.ts b/libs/shared/utils/file-loaders/src/lib/json-file-loader.service.ts new file mode 100644 index 000000000..63472d3e8 --- /dev/null +++ b/libs/shared/utils/file-loaders/src/lib/json-file-loader.service.ts @@ -0,0 +1,89 @@ +import { HttpClient, HttpEvent, HttpEventType } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable, concatMap, defer, filter, from, map, of, startWith } from 'rxjs'; +import { FileLoader, FileLoaderEvent, FileLoaderProgressEvent } from './file-loader'; + +/** Options for loading JSON files */ +export type JsonFileLoaderOptions = Record; + +/** Service for loading JSON files, either locally or remotely */ +@Injectable({ + providedIn: 'root', +}) +export class JsonFileLoaderService implements FileLoader { + /** Reference to the HTTP client */ + private readonly http = inject(HttpClient, { optional: true }); + + /** Loads a JSON file and returns an observable of file loader events */ + load(file: string | File, options: JsonFileLoaderOptions): Observable> { + return defer(() => this.loadImpl(file, options)); + } + + /** Implementation of the load method, handling local and remote files */ + private loadImpl(file: string | File, _options: JsonFileLoaderOptions): Observable> { + if (typeof file === 'object') { + return this.loadLocalFile(file); + } else { + return this.loadRemoteFile(file); + } + } + + /** Loads a local JSON file and emits progress and data events */ + private loadLocalFile(file: File): Observable> { + const fileSize = file.size; + const progressStart: FileLoaderProgressEvent = { + type: 'progress', + loaded: 0, + total: fileSize, + }; + const progressEnd: FileLoaderProgressEvent = { + type: 'progress', + loaded: fileSize, + total: fileSize, + }; + + return from(file.text()).pipe( + map((text): FileLoaderEvent => ({ type: 'data', data: JSON.parse(text) })), + concatMap((dataEvent) => of(progressEnd, dataEvent)), + startWith(progressStart), + ); + } + + /** Loads a remote JSON file and emits progress and data events */ + private loadRemoteFile(file: string): Observable> { + const { http } = this; + if (!http) { + throw new Error('HttpClient is required to load remote json files'); + } + + const event$ = http.get(file, { + responseType: 'json', + observe: 'events', + }); + + return event$.pipe( + map((event) => this.httpEventToFileLoaderEvent(event)), + filter((event): event is FileLoaderEvent => event !== undefined), + ); + } + + /** Converts HTTP events to file loader events */ + private httpEventToFileLoaderEvent(event: HttpEvent): FileLoaderEvent | undefined { + switch (event.type) { + case HttpEventType.Sent: + return { type: 'progress', loaded: 0 }; + + case HttpEventType.DownloadProgress: + return { type: 'progress', loaded: event.loaded, total: event.total }; + + case HttpEventType.Response: + if (!event.body) { + throw new Error('Could not parse response as json'); + } + return { type: 'data', data: event.body }; + + default: + return undefined; + } + } +} diff --git a/libs/shared/utils/tsconfig.lib.json b/libs/shared/utils/tsconfig.lib.json index 814ca7c78..5657fbf45 100644 --- a/libs/shared/utils/tsconfig.lib.json +++ b/libs/shared/utils/tsconfig.lib.json @@ -7,6 +7,6 @@ "inlineSources": true, "types": [] }, - "exclude": ["src/test-setup.ts", "**/*.spec.ts", "jest.config.ts", "**/*.test.ts"], + "exclude": ["test-setup.ts", "**/*.spec.ts", "jest.config.ts", "**/*.test.ts"], "include": ["**/*.ts"] } diff --git a/tsconfig.base.json b/tsconfig.base.json index 26610d0b5..a767c0420 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -143,6 +143,9 @@ "@hra-ui/utils": [ "libs/shared/utils/src/index.ts" ], + "@hra-ui/utils/file-loaders": [ + "libs/shared/utils/file-loaders/src/index.ts" + ], "@hra-ui/utils/testing": [ "libs/shared/testing/src/index.ts" ], From 7b15f352c79bfef8e2ebadaff9dd5c6c6a0e2bef Mon Sep 17 00:00:00 2001 From: Bhushan Khope Date: Tue, 17 Sep 2024 16:43:21 -0400 Subject: [PATCH 04/23] update peer dependencies --- libs/node-dist-vis/package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/node-dist-vis/package.json b/libs/node-dist-vis/package.json index 30e6f827c..1900fa599 100644 --- a/libs/node-dist-vis/package.json +++ b/libs/node-dist-vis/package.json @@ -4,7 +4,9 @@ "peerDependencies": { "@angular/core": "^18.2.0", "@hra-ui/webcomponents": "0.0.1", - "papaparse": "^5.4.1" + "papaparse": "^5.4.1", + "@hra-ui/utils": "0.0.1", + "rxjs": "7.8.0" }, "sideEffects": [ "index.ts" From 7664a92a549631fe61dfd2cf943b80c72c29aa41 Mon Sep 17 00:00:00 2001 From: Bhushan Khope Date: Thu, 19 Sep 2024 16:31:12 -0400 Subject: [PATCH 05/23] create deck gl visualization component --- libs/node-dist-vis/src/index.ts | 2 + .../deck-gl-visualization.component.html | 1 + .../deck-gl-visualization.component.scss | 3 + .../deck-gl-visualization.component.spec.ts | 21 ++ .../deck-gl-visualization.component.ts | 240 ++++++++++++++ libs/node-dist-vis/src/lib/models/edges.ts | 22 ++ .../node-dist-vis.component.html | 7 + .../node-dist-vis/node-dist-vis.component.ts | 94 ++++-- .../src/lib/utils/distance-edges.ts | 150 ++++----- libs/node-dist-vis/src/lib/utils/helper.ts | 28 +- package-lock.json | 294 ++++++++++++++---- package.json | 1 + 12 files changed, 673 insertions(+), 190 deletions(-) create mode 100644 libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.html create mode 100644 libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.scss create mode 100644 libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.spec.ts create mode 100644 libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.ts diff --git a/libs/node-dist-vis/src/index.ts b/libs/node-dist-vis/src/index.ts index 51354d3db..0050f7e7f 100644 --- a/libs/node-dist-vis/src/index.ts +++ b/libs/node-dist-vis/src/index.ts @@ -7,3 +7,5 @@ export * from './lib/node-dist-vis/node-dist-vis.component'; export const CdeVisualizationElement = createCustomElement('hra-node-dist-vis', NodeDistVisComponent, { providers: [], }); + +export * from './lib/deck-gl-visualization/deck-gl-visualization.component'; diff --git a/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.html b/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.html new file mode 100644 index 000000000..c2e2ad0b6 --- /dev/null +++ b/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.html @@ -0,0 +1 @@ + diff --git a/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.scss b/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.spec.ts b/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.spec.ts new file mode 100644 index 000000000..009c40230 --- /dev/null +++ b/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { DeckGlVisualizationComponent } from './deck-gl-visualization.component'; + +describe('DeckGlVisualizationComponent', () => { + let component: DeckGlVisualizationComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DeckGlVisualizationComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(DeckGlVisualizationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.ts b/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.ts new file mode 100644 index 000000000..33521820d --- /dev/null +++ b/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.ts @@ -0,0 +1,240 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + ElementRef, + input, + output, + signal, + untracked, + viewChild, +} from '@angular/core'; +import { colorCategories } from '@deck.gl/carto/typed'; +import { AccessorFunction, Color, COORDINATE_SYSTEM, Deck, DeckProps, OrbitView, Position } from '@deck.gl/core/typed'; +import { DataFilterExtension, DataFilterExtensionProps } from '@deck.gl/extensions/typed'; +import { LineLayer, PointCloudLayer } from '@deck.gl/layers/typed'; +import { ScaleBarLayer } from '@vivjs/layers'; +import { EdgeEntry, EdgeIndex } from '../models/edges'; +import { NodeEntry, NodeTargetKey } from '../models/nodes'; + +type DeckCallbackParameters = Parameters>; + +const NO_HOVER = Symbol(); +const ORIGIN: Position = [0, 0, 0]; +const COLOR_WHITE: Color = [255, 255, 255]; +const SELECTION_RANGE: [number, number] = [0, 10]; +const SELECTION_VALUE_INSIDE_RANGE = 5; +const SELECTION_VALUE_OUT_OF_RANGE = 100; + +@Component({ + selector: 'hra-deck-gl-visualization', + standalone: true, + imports: [CommonModule], + templateUrl: './deck-gl-visualization.component.html', + styleUrl: './deck-gl-visualization.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DeckGlVisualizationComponent { + readonly nodes = input.required(); + readonly edges = input.required(); + readonly selection = input.required(); + readonly colorMap = input.required<{ domain: string[]; range: Color[] }>(); + readonly nodeTargetKey = input.required(); + + readonly nodeClick = output(); + readonly nodeHover = output(); + + private readonly canvas = viewChild.required>('canvas'); + private readonly viewState = signal(this.getInitialViewState()); + private readonly activeHover = signal(NO_HOVER); + + private viewStateVersionCounter = 0; + + private readonly dimensions = computed(() => { + let minDimSize = Number.MAX_VALUE; + let maxDimSize = Number.MIN_VALUE; + for (const node of this.nodes() ?? []) { + maxDimSize = Math.max(maxDimSize, ...(node.position ?? [])); + minDimSize = Math.min(minDimSize, ...(node.position ?? [])); + } + + return [minDimSize, maxDimSize] as const; + }); + + private readonly scaleFn = computed(() => { + const [min, max] = this.dimensions(); + const diff = max - min; + return ([x, y, z]: Position): Position => [(x - min) / diff, 1 - (y - min) / diff, (z - min) / diff]; + }); + + private readonly nodesLayer = computed(() => { + type ExtraProps = DataFilterExtensionProps; + + const targetKey = this.nodeTargetKey(); + return new PointCloudLayer({ + id: 'nodes', + data: this.nodes(), + getPosition: this.createScaler((node) => node.position ?? ORIGIN), + getColor: this.createColorCoding((node) => node[targetKey]), + pickable: true, + coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, + pointSize: 1.5, + getFilterValue: this.createFilter((d) => d[this.nodeTargetKey()]), + filterRange: SELECTION_RANGE, + filterEnabled: this.selection() !== undefined, + extensions: [new DataFilterExtension()], + updateTriggers: { + getColor: this.colorMap()?.range, + getFilterValue: this.selection(), + }, + }); + }); + + private readonly edgesLayer = computed(() => { + const nodes = this.nodes(); + const targetKey = this.nodeTargetKey(); + const sourceNodeAccessor = (edge: EdgeEntry) => nodes[edge[EdgeIndex.SourceNode]][targetKey]; + const sourcePositionAccessor = (edge: EdgeEntry) => + [edge[EdgeIndex.x0], edge[EdgeIndex.y0], edge[EdgeIndex.z0]] satisfies Position; + const targetPositionAccessor = (edge: EdgeEntry) => + [edge[EdgeIndex.x0], edge[EdgeIndex.y0], edge[EdgeIndex.z0]] satisfies Position; + + return new LineLayer({ + id: 'edges', + data: this.edges(), + getSourcePosition: this.createScaler(sourcePositionAccessor), + getTargetPosition: this.createScaler(targetPositionAccessor), + getColor: this.createColorCoding(sourceNodeAccessor), + pickable: false, + coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, + getWidth: 1, + getFilterValue: this.createFilter(sourceNodeAccessor), + filterRange: SELECTION_RANGE, + filterEnabled: this.selection() !== undefined, + extensions: [new DataFilterExtension()], + updateTriggers: { + getColor: this.colorMap()?.range, + getFilterValue: this.selection(), + }, + }); + }); + + private readonly scaleBarLayer = computed(() => { + type Props = ConstructorParameters[0]; + const { width, height } = this.canvas().nativeElement; + // Scale 1µm the same way positions are scaled + const scale = this.scaleFn(); + const size = 1 / scale([1, 1, 1])[0]; + + return new ScaleBarLayer({ + id: 'scalebar', + unit: 'µm', + size: size, + position: 'top-right', + viewState: { ...this.viewState(), width: width - 136, height: height - 32 }, + length: 0.1, + snap: true, + } as Props); + }); + + private readonly deck = computed( + () => + new Deck({ + canvas: this.canvas().nativeElement, + controller: true, + views: [new OrbitView({ orbitAxis: 'Y' })], + initialViewState: untracked(this.viewState), + layers: [], + getCursor: this.getCursor.bind(this), + onViewStateChange: this.onViewStateChange.bind(this), + onClick: this.onClick.bind(this), + onHover: this.onHover.bind(this), + }), + ); + + constructor() { + effect(() => { + const deck = this.deck(); + deck.setProps({ layers: [this.nodesLayer(), this.edgesLayer(), this.scaleBarLayer()] }); + }); + + effect(() => { + const activeHover = this.activeHover(); + if (activeHover !== NO_HOVER) { + this.nodeHover.emit(activeHover); + } + }); + } + + resetView() { + this.deck().setProps({ initialViewState: this.getInitialViewState() }); + } + + private createScaler(accessor: (value: T) => Position): AccessorFunction { + const scale = this.scaleFn(); + return (value) => scale(accessor(value)); + } + + private createFilter(accessor: (value: T) => string): AccessorFunction { + const selection = this.selection(); + const selectionSet = new Set(selection); + if (selection === undefined) { + return () => SELECTION_VALUE_INSIDE_RANGE; + } + + return (value) => (selectionSet.has(accessor(value)) ? SELECTION_VALUE_INSIDE_RANGE : SELECTION_VALUE_OUT_OF_RANGE); + } + + private createColorCoding(accessor: (value: T) => number | string): AccessorFunction { + type Color2 = Exclude[0]['colors'], string>[number]; + const { domain, range } = this.colorMap(); + return colorCategories({ + attr: accessor, + domain: domain, + colors: range as Color2[], + othersColor: COLOR_WHITE as Color2, + nullColor: COLOR_WHITE as Color2, + }) as AccessorFunction; + } + + private getInitialViewState() { + return { + version: this.viewStateVersionCounter++, + orbitAxis: 'Y', + camera: 'orbit', + zoom: 9, + minRotationX: -90, + maxRotationX: 90, + rotationX: 0, + rotationOrbit: 0, + dragMode: 'rotate', + target: [0.5, 0.5], + }; + } + + private getCursor(...[state]: DeckCallbackParameters<'getCursor'>): string { + if (this.activeHover()) { + return 'pointer'; + } else if (state.isDragging) { + return 'grabbing'; + } else { + return 'grab'; + } + } + + private onViewStateChange(...[params]: DeckCallbackParameters<'onViewStateChange'>): void { + this.viewState.set(params.viewState); + } + + private onClick(...[info]: DeckCallbackParameters<'onClick'>): void { + if (info.picked) { + this.nodeClick.emit(info.object); + } + } + + private onHover(...[info]: DeckCallbackParameters<'onHover'>): void { + this.activeHover.set(info.picked ? info.object : undefined); + } +} diff --git a/libs/node-dist-vis/src/lib/models/edges.ts b/libs/node-dist-vis/src/lib/models/edges.ts index 4c99ae6e9..9d3bfc3c6 100644 --- a/libs/node-dist-vis/src/lib/models/edges.ts +++ b/libs/node-dist-vis/src/lib/models/edges.ts @@ -7,3 +7,25 @@ export type EdgeEntry = [ y1: number, z1: number, ]; + +/** Enum representing the indices of the elements in an EdgeEntry */ +export enum EdgeIndex { + SourceNode = 0, + x0, + y0, + z0, + x1, + y1, + z1, +} + +/** Default maximum distance for edges */ +export const DEFAULT_MAX_EDGE_DISTANCE = 1000; + +/** Calculates the Euclidean distance between the two points defined by an EdgeEntry */ +export function edgeDistance(edge: EdgeEntry): number { + const xDiff = edge[EdgeIndex.x0] - edge[EdgeIndex.x1]; + const yDiff = edge[EdgeIndex.y0] - edge[EdgeIndex.y1]; + const zDiff = edge[EdgeIndex.z0] - edge[EdgeIndex.z1]; + return Math.hypot(xDiff, yDiff, zDiff); +} diff --git a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.html b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.html index 108540841..49c336961 100644 --- a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.html +++ b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.html @@ -1 +1,8 @@

node-dist-vis works!

+ diff --git a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts index 4811596aa..9468ab253 100644 --- a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts +++ b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts @@ -8,50 +8,80 @@ // input, // Input, // output, -// ViewChild +// ViewChild, // } from '@angular/core'; // import { EdgeEntry } from '../models/edges'; -// import { NodeEntry } from '../models/nodes'; -// import { EdgeDataService } from '../services/edge-data.service'; +// import { NodeEntry, NodeTargetKey } from '../models/nodes'; +// // import { EdgeDataService } from '../services/edge-data.service'; // import { NodeDataService } from '../services/node-data.service'; +// import { DeckGlVisualizationComponent } from '../deck-gl-visualization/deck-gl-visualization.component'; // @Component({ // selector: 'hra-node-dist-vis', // standalone: true, -// imports: [CommonModule], -// providers: [NodeDataService, EdgeDataService], +// imports: [CommonModule, DeckGlVisualizationComponent], +// providers: [NodeDataService], // templateUrl: './node-dist-vis.component.html', // styleUrl: './node-dist-vis.component.scss', // changeDetection: ChangeDetectionStrategy.OnPush, // }) // export class NodeDistVisComponent { -// readonly nodes = input(); -// readonly edges = input(); - -// nodesUrl = input(); -// nodesData = input(); -// edgesUrl = input(); -// edgesData = input(); -// colorMapUrl = input(); -// colorMapKey = input('cell_type'); -// colorMapValue = input('cell_color'); -// nodeTargetKey = input(); -// nodeTargetValue = input(); -// maxEdgeDistance = input(); -// dispatchEvent = output(); -// @Input() selection?: any[]; -// @ViewChild('visCanvas', { static: true }) visCanvas!: ElementRef; - -// toDispose: EffectRef[] = []; -// initialized = false; -// edgesVersion = 0; - -// private readonly nodeDataService = inject(NodeDataService); -// private readonly edgeDataService = inject(EdgeDataService); - -// constructor() { -// console.log(this) -// } +// readonly nodeTargetKey: NodeTargetKey = 'Cell Type' as NodeTargetKey; + +// readonly nodes: NodeEntry[] = [ +// { x: 659, y: 72, position: [659, 72, 0], [this.nodeTargetKey]: 'T-Helper' }, +// { x: 178, y: 73, position: [178, 73, 0], [this.nodeTargetKey]: 'T-Helper' }, +// { x: 170, y: 74, position: [170, 74, 0], [this.nodeTargetKey]: 'T-Helper' }, +// { x: 173, y: 75, position: [173, 75, 0], [this.nodeTargetKey]: 'T-Helper' }, +// { x: 174, y: 76, position: [174, 76, 0], [this.nodeTargetKey]: 'T-Helper' }, +// ] as NodeEntry[]; + +// readonly edges: EdgeEntry[] = [ +// [0, 659, 72, 0, 630, 105, 5], +// [1, 178, 73, 0, 177, 71, 2], +// [2, 170, 74, 0, 166, 79, 2], +// [3, 173, 74, 0, 177, 71, 2], +// [4, 174, 75, 0, 177, 71, 2], +// ]; + +// readonly selection: string[] = ['T-Helper']; +// readonly colorMap: { domain: string[]; range: [[number, number, number]] } = { +// domain: ['T-Helper'], +// range: [[112, 165, 168]], +// }; + +// // log(label: string, value: T): T { +// // console.log(label, value); +// // return value; +// // } + +// // readonly nodes = input(); +// // readonly edges = input(); + +// // nodesUrl = input(); +// // nodesData = input(); +// // edgesUrl = input(); +// // edgesData = input(); +// // colorMapUrl = input(); +// // colorMapKey = input('cell_type'); +// // colorMapValue = input('cell_color'); +// // nodeTargetKey = input(); +// // nodeTargetValue = input(); +// // maxEdgeDistance = input(); +// // dispatchEvent = output(); +// // @Input() selection?: any[]; +// // @ViewChild('visCanvas', { static: true }) visCanvas!: ElementRef; + +// // toDispose: EffectRef[] = []; +// // initialized = false; +// // edgesVersion = 0; + +// // private readonly nodeDataService = inject(NodeDataService); +// // private readonly edgeDataService = inject(EdgeDataService); + +// // constructor() { +// // console.log(this) +// // } // // constructor() { // // effect(() => { diff --git a/libs/node-dist-vis/src/lib/utils/distance-edges.ts b/libs/node-dist-vis/src/lib/utils/distance-edges.ts index 64957aa27..d43f3962f 100644 --- a/libs/node-dist-vis/src/lib/utils/distance-edges.ts +++ b/libs/node-dist-vis/src/lib/utils/distance-edges.ts @@ -1,80 +1,80 @@ -// function squaredDistance3D(a: number[], b: number[]): number { -// const dx = a[0] - b[0]; -// const dy = a[1] - b[1]; -// const dz = a[2] - b[2]; -// return dx * dx + dy * dy + dz * dz; -// } +function squaredDistance3D(a: number[], b: number[]): number { + const dx = a[0] - b[0]; + const dy = a[1] - b[1]; + const dz = a[2] - b[2]; + return dx * dx + dy * dy + dz * dz; +} -// const CELL_OFFSETS = [ -// [-1, -1], -// [-1, 0], -// [-1, 1], -// [1, -1], -// [1, 0], -// [1, 1], -// [0, -1], -// [0, 0], -// [0, 1], -// ]; +const CELL_OFFSETS = [ + [-1, -1], + [-1, 0], + [-1, 1], + [1, -1], + [1, 0], + [1, 1], + [0, -1], + [0, 0], + [0, 1], +]; -// function* getClosest(sources: number[][], sourceIndexes: number[], targets: number[][], maxDistSquared: number) { -// for (const [index, source] of sources.entries()) { -// let minDist = maxDistSquared; -// let closest: number[] | undefined; -// for (const target of targets) { -// const distSquared = squaredDistance3D(source, target); -// if (distSquared < minDist) { -// minDist = distSquared; -// closest = target; -// } -// } -// if (closest) { -// yield [sourceIndexes[index], ...source, ...closest]; -// } -// } -// } +function* getClosest(sources: number[][], sourceIndexes: number[], targets: number[][], maxDistSquared: number) { + for (const [index, source] of sources.entries()) { + let minDist = maxDistSquared; + let closest: number[] | undefined; + for (const target of targets) { + const distSquared = squaredDistance3D(source, target); + if (distSquared < minDist) { + minDist = distSquared; + closest = target; + } + } + if (closest) { + yield [sourceIndexes[index], ...source, ...closest]; + } + } +} -// function addToCell(node: any, cells: any) { -// const cx = (cells[node.cell[0]] = cells[node.cell[0]] || {}); -// const cy = (cx[node.cell[1]] = cx[node.cell[1]] || { -// nodes: [], -// positions: [], -// }); -// cy.nodes.push(node.__index__); -// cy.positions.push(node.position); -// } +function addToCell(node: any, cells: any) { + const cx = (cells[node.cell[0]] = cells[node.cell[0]] || {}); + const cy = (cx[node.cell[1]] = cx[node.cell[1]] || { + nodes: [], + positions: [], + }); + cy.nodes.push(node.__index__); + cy.positions.push(node.position); +} -// export function* distanceEdges(nodes: any[], type_field: string, target_type: string, maxDist: number) { -// console.log(nodes, type_field, target_type, maxDist); -// const source_cells: any = {}; -// const target_cells: any = {}; -// for (const [node_index, node] of nodes.entries()) { -// node.__index__ = node_index; -// node.position = [node.x ?? 0, node.y ?? 0, node.z ?? 0]; -// node.cell = [Math.floor(node.x / maxDist), Math.floor(node.y / maxDist)]; -// if (node[type_field] === target_type) { -// addToCell(node, target_cells); -// } else { -// addToCell(node, source_cells); -// } -// } +export function* distanceEdges(nodes: any[], type_field: string, target_type: string, maxDist: number) { + console.log(nodes, type_field, target_type, maxDist); + const source_cells: any = {}; + const target_cells: any = {}; + for (const [node_index, node] of nodes.entries()) { + node.__index__ = node_index; + node.position = [node.x ?? 0, node.y ?? 0, node.z ?? 0]; + node.cell = [Math.floor(node.x / maxDist), Math.floor(node.y / maxDist)]; + if (node[type_field] === target_type) { + addToCell(node, target_cells); + } else { + addToCell(node, source_cells); + } + } -// const maxDistSquared = maxDist * maxDist; -// for (const sourceCellX in source_cells) { -// for (const sourceCellY in source_cells[sourceCellX]) { -// const sources = source_cells[sourceCellX][sourceCellY]; -// let allTargets: number[][] = []; -// for (const [offsetX, offsetY] of CELL_OFFSETS) { -// const cellX = parseInt(sourceCellX) + offsetX; -// const cellY = parseInt(sourceCellY) + offsetY; -// const targets = target_cells[cellX]?.[cellY]; -// if (targets) { -// allTargets = allTargets.concat(targets.positions); -// } -// } -// if (allTargets.length > 0) { -// yield* getClosest(sources.positions, sources.nodes, allTargets, maxDistSquared); -// } -// } -// } -// } + const maxDistSquared = maxDist * maxDist; + for (const sourceCellX in source_cells) { + for (const sourceCellY in source_cells[sourceCellX]) { + const sources = source_cells[sourceCellX][sourceCellY]; + let allTargets: number[][] = []; + for (const [offsetX, offsetY] of CELL_OFFSETS) { + const cellX = parseInt(sourceCellX) + offsetX; + const cellY = parseInt(sourceCellY) + offsetY; + const targets = target_cells[cellX]?.[cellY]; + if (targets) { + allTargets = allTargets.concat(targets.positions); + } + } + if (allTargets.length > 0) { + yield* getClosest(sources.positions, sources.nodes, allTargets, maxDistSquared); + } + } + } +} diff --git a/libs/node-dist-vis/src/lib/utils/helper.ts b/libs/node-dist-vis/src/lib/utils/helper.ts index d3d1d3db9..a981d451e 100644 --- a/libs/node-dist-vis/src/lib/utils/helper.ts +++ b/libs/node-dist-vis/src/lib/utils/helper.ts @@ -1,15 +1,15 @@ -// import Papa from 'papaparse'; +import Papa from 'papaparse'; -// export async function fetchCsv(url: string, papaOptions = {}): Promise { -// return new Promise((resolve) => { -// Papa.parse(url, { -// header: true, -// skipEmptyLines: true, -// dynamicTyping: true, -// ...papaOptions, -// complete: (results) => { -// resolve(results.data); -// }, -// }); -// }); -// } +export async function fetchCsv(url: string, papaOptions = {}): Promise { + return new Promise((resolve) => { + Papa.parse(url, { + header: true, + skipEmptyLines: true, + dynamicTyping: true, + ...papaOptions, + complete: (results) => { + resolve(results.data); + }, + }); + }); +} diff --git a/package-lock.json b/package-lock.json index f7da6361e..751ac56f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@angular/youtube-player": "^18.0.6", "@deck.gl/carto": "~8.8.20", "@deck.gl/core": "~8.8.20", + "@deck.gl/extensions": "~8.8.20", "@deck.gl/geo-layers": "~8.8.20", "@deck.gl/layers": "~8.8.20", "@deck.gl/mesh-layers": "~8.8.20", @@ -3642,6 +3643,24 @@ "indefinitely-typed": "^1.1.0" } }, + "node_modules/@deck.gl/aggregation-layers": { + "version": "8.9.36", + "resolved": "https://registry.npmjs.org/@deck.gl/aggregation-layers/-/aggregation-layers-8.9.36.tgz", + "integrity": "sha512-EwUJ1bwhhAG6LF9hAdZDaIAwIFDUGC8XpQgHmitTLohciVrIp70p9zpgHNNU6oPy+iQvccmWctLcSC9TpgjsIg==", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.0.0", + "@luma.gl/constants": "^8.5.21", + "@luma.gl/shadertools": "^8.5.21", + "@math.gl/web-mercator": "^3.6.2", + "d3-hexbin": "^0.2.1" + }, + "peerDependencies": { + "@deck.gl/core": "^8.0.0", + "@deck.gl/layers": "^8.0.0", + "@luma.gl/core": "^8.0.0" + } + }, "node_modules/@deck.gl/carto": { "version": "8.8.27", "resolved": "https://registry.npmjs.org/@deck.gl/carto/-/carto-8.8.27.tgz", @@ -3693,19 +3712,16 @@ } }, "node_modules/@deck.gl/extensions": { - "version": "8.9.36", - "resolved": "https://registry.npmjs.org/@deck.gl/extensions/-/extensions-8.9.36.tgz", - "integrity": "sha512-BoHjJOK9Ue/zH+YkXiFli7ebS+I21fyL4YeCUzw2a6OOo36SZV/4S0gZSSkaaltO72aZsDsvduWPAbmXY2slqA==", + "version": "8.8.27", + "resolved": "https://registry.npmjs.org/@deck.gl/extensions/-/extensions-8.8.27.tgz", + "integrity": "sha512-gjLHHuwoBt9dK8/iOBEgBzAtfqVZ4l7nh0aFcft/jemlusb7iNSuCz2UUYx2lUDl9IBFzkEeo1aayJCkSiEkfw==", "dependencies": { - "@babel/runtime": "^7.0.0", - "@luma.gl/shadertools": "^8.5.21" + "@luma.gl/shadertools": "^8.5.16" }, "peerDependencies": { "@deck.gl/core": "^8.0.0", "@luma.gl/constants": "^8.0.0", "@luma.gl/core": "^8.0.0", - "@math.gl/core": "^3.6.2", - "@math.gl/web-mercator": "^3.6.2", "gl-matrix": "^3.0.0" } }, @@ -8756,6 +8772,16 @@ "node": ">=14" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@preact/signals-core": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.8.0.tgz", @@ -11350,7 +11376,7 @@ "version": "1.13.3", "resolved": "https://registry.npmjs.org/@swc-node/core/-/core-1.13.3.tgz", "integrity": "sha512-OGsvXIid2Go21kiNqeTIn79jcaX4l0G93X2rAnas4LFoDyA9wAwVK7xZdm+QsKoMn5Mus2yFLCc4OtX2dD/PWA==", - "dev": true, + "devOptional": true, "engines": { "node": ">= 10" }, @@ -11367,7 +11393,7 @@ "version": "1.9.2", "resolved": "https://registry.npmjs.org/@swc-node/register/-/register-1.9.2.tgz", "integrity": "sha512-BBjg0QNuEEmJSoU/++JOXhrjWdu3PTyYeJWsvchsI0Aqtj8ICkz/DqlwtXbmZVZ5vuDPpTfFlwDBZe81zgShMA==", - "dev": true, + "devOptional": true, "dependencies": { "@swc-node/core": "^1.13.1", "@swc-node/sourcemap-support": "^0.5.0", @@ -11389,7 +11415,7 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/@swc-node/sourcemap-support/-/sourcemap-support-0.5.1.tgz", "integrity": "sha512-JxIvIo/Hrpv0JCHSyRpetAdQ6lB27oFYhv0PKCNf1g2gUXOjpeR1exrXccRxLMuAV5WAmGFBwRnNOJqN38+qtg==", - "dev": true, + "devOptional": true, "dependencies": { "source-map-support": "^0.5.21", "tslib": "^2.6.3" @@ -11399,7 +11425,7 @@ "version": "1.5.7", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.5.7.tgz", "integrity": "sha512-U4qJRBefIJNJDRCCiVtkfa/hpiZ7w0R6kASea+/KLp+vkus3zcLSB8Ub8SvKgTIxjWpwsKcZlPf5nrv4ls46SQ==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "dependencies": { "@swc/counter": "^0.1.2", @@ -11597,7 +11623,7 @@ "version": "0.1.7", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.7.tgz", "integrity": "sha512-scHWahbHF0eyj3JsxG9CFJgFdFNaVQCNAimBlT6PzS3n/HptxqREjsm4OH6AN3lYcffZYSPxXW8ua2BEHp0lJQ==", - "dev": true, + "devOptional": true, "dependencies": { "@swc/counter": "^0.1.3" } @@ -11606,7 +11632,7 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true + "devOptional": true }, "node_modules/@swc/helpers": { "version": "0.5.11", @@ -11638,7 +11664,7 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.12.tgz", "integrity": "sha512-wBJA+SdtkbFhHjTMYH+dEH1y4VpfGdAc2Kw/LK09i9bXd/K6j6PkDcFCEzb6iVfZMkPRrl/q0e3toqTAJdkIVA==", - "dev": true, + "devOptional": true, "dependencies": { "@swc/counter": "^0.1.3" } @@ -11901,6 +11927,33 @@ "node": ">=10.13.0" } }, + "node_modules/@ts-morph/common": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.22.0.tgz", + "integrity": "sha512-HqNBuV/oIlMKdkLshXd1zKBqNQCsuPEsgQOkfFQ/eUKjRlwndXW1AjN9LVkBEIukm00gGXSRmfkl0Wv5VXLnlw==", + "peer": true, + "dependencies": { + "fast-glob": "^3.3.2", + "minimatch": "^9.0.3", + "mkdirp": "^3.0.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@ts-morph/common/node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "peer": true, + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -15190,9 +15243,9 @@ "dev": true }, "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -15202,7 +15255,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -15237,11 +15290,11 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/body-parser/node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -15755,7 +15808,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true + "devOptional": true }, "node_modules/buffer-xor": { "version": "1.0.3", @@ -16480,6 +16533,12 @@ "node": ">= 0.12.0" } }, + "node_modules/code-block-writer": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-12.0.0.tgz", + "integrity": "sha512-q4dMFMlXtKR3XNBHyMHt/3pwYNA69EDk00lloMOaaUMKPUXBw6lpXtbu3MMVG6/uOihGnRDOlkyqsONEUj60+w==", + "peer": true + }, "node_modules/codepage": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", @@ -16534,7 +16593,7 @@ "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true + "devOptional": true }, "node_modules/colors": { "version": "1.4.0", @@ -18424,6 +18483,12 @@ "node": ">= 10" } }, + "node_modules/d3-hexbin": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/d3-hexbin/-/d3-hexbin-0.2.2.tgz", + "integrity": "sha512-KS3fUT2ReD4RlGCjvCEm1RgMtp2NFZumdMu4DBzQK8AZv3fXRM6Xm8I4fSU07UXvH4xxg03NwWKWdvxfS/yc4w==", + "peer": true + }, "node_modules/d3-hierarchy": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", @@ -19876,7 +19941,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, "optional": true, "dependencies": { "iconv-lite": "^0.6.2" @@ -21041,36 +21105,36 @@ "dev": true }, "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", + "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -21100,13 +21164,21 @@ "ms": "2.0.0" } }, + "node_modules/express/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/express/node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -21123,11 +21195,11 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/express/node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -22966,6 +23038,23 @@ "mjolnir.js": "^2.7.0" } }, + "node_modules/hra-node-dist-vis/node_modules/@deck.gl/extensions": { + "version": "8.9.36", + "resolved": "https://registry.npmjs.org/@deck.gl/extensions/-/extensions-8.9.36.tgz", + "integrity": "sha512-BoHjJOK9Ue/zH+YkXiFli7ebS+I21fyL4YeCUzw2a6OOo36SZV/4S0gZSSkaaltO72aZsDsvduWPAbmXY2slqA==", + "dependencies": { + "@babel/runtime": "^7.0.0", + "@luma.gl/shadertools": "^8.5.21" + }, + "peerDependencies": { + "@deck.gl/core": "^8.0.0", + "@luma.gl/constants": "^8.0.0", + "@luma.gl/core": "^8.0.0", + "@math.gl/core": "^3.6.2", + "@math.gl/web-mercator": "^3.6.2", + "gl-matrix": "^3.0.0" + } + }, "node_modules/hra-node-dist-vis/node_modules/@deck.gl/layers": { "version": "8.9.36", "resolved": "https://registry.npmjs.org/@deck.gl/layers/-/layers-8.9.36.tgz", @@ -25000,6 +25089,23 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/jest-circus/node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/jest-circus/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -25016,6 +25122,24 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/jest-circus/node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jest-circus/node_modules/dedent": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", @@ -25083,6 +25207,17 @@ "node": ">=8" } }, + "node_modules/jest-circus/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/jest-cli": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", @@ -27435,12 +27570,12 @@ } }, "node_modules/jspreadsheet-ce": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/jspreadsheet-ce/-/jspreadsheet-ce-4.2.1.tgz", - "integrity": "sha512-kQ+bUwH0MEeyr3Ojdd+tB0BJik3NsnQlhC1E52uwrURvqBu3GnrDtRZZS3cAwtL0FzHBZDsJJB9afTdReBm6Hg==", + "version": "4.13.4", + "resolved": "https://registry.npmjs.org/jspreadsheet-ce/-/jspreadsheet-ce-4.13.4.tgz", + "integrity": "sha512-Rv1xbR5AKme7Nd+vCRsHS05+3h0CtcDYcGseXPOEOWV9Mq7k3z57comq+kjLXJZyEf3CR9kCzIPQsd6tN7Yn6w==", "dependencies": { "@jspreadsheet/formula": "^2.0.2", - "jsuites": "^5.3.0" + "jsuites": "^5.0.25" } }, "node_modules/jsprim": { @@ -29343,9 +29478,12 @@ "integrity": "sha512-OcjA+jzjOYzKmKS6IQVALHLVz+rNTMPoJvCztFaZxwG14wtAW7VRZjwTQu06vKCYOxh4jVnik7ya0SXTB0W+xA==" }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/merge-stream": { "version": "2.0.0", @@ -29853,9 +29991,9 @@ "optional": true }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -32565,9 +32703,9 @@ "dev": true }, "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" }, "node_modules/path-type": { "version": "4.0.0", @@ -32721,7 +32859,7 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "dev": true, + "devOptional": true, "engines": { "node": ">= 6" } @@ -35651,9 +35789,9 @@ } }, "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -35779,19 +35917,27 @@ } }, "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "dependencies": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" }, "engines": { "node": ">= 0.8.0" } }, + "node_modules/serve-static/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -36182,7 +36328,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, + "devOptional": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -36192,7 +36338,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -38357,6 +38503,16 @@ "node": ">=8" } }, + "node_modules/ts-morph": { + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-21.0.1.tgz", + "integrity": "sha512-dbDtVdEAncKctzrVZ+Nr7kHpHkv+0JDJb2MjjpBaj8bFeCkePU9rHfMklmhuLFnpeq/EJZk2IhStY6NzqgjOkg==", + "peer": true, + "dependencies": { + "@ts-morph/common": "~0.22.0", + "code-block-writer": "^12.0.0" + } + }, "node_modules/ts-node": { "version": "10.9.1", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", @@ -38741,7 +38897,7 @@ "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 3a4af77a4..25397ffa6 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@angular/youtube-player": "^18.0.6", "@deck.gl/carto": "~8.8.20", "@deck.gl/core": "~8.8.20", + "@deck.gl/extensions": "~8.8.20", "@deck.gl/geo-layers": "~8.8.20", "@deck.gl/layers": "~8.8.20", "@deck.gl/mesh-layers": "~8.8.20", From 2c275f943383f607d127845d399d828335c74bb5 Mon Sep 17 00:00:00 2001 From: Bhushan Khope Date: Thu, 19 Sep 2024 16:32:03 -0400 Subject: [PATCH 06/23] update peer deps --- libs/node-dist-vis/package.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/libs/node-dist-vis/package.json b/libs/node-dist-vis/package.json index 1900fa599..f6dcc7eee 100644 --- a/libs/node-dist-vis/package.json +++ b/libs/node-dist-vis/package.json @@ -6,7 +6,13 @@ "@hra-ui/webcomponents": "0.0.1", "papaparse": "^5.4.1", "@hra-ui/utils": "0.0.1", - "rxjs": "7.8.0" + "rxjs": "7.8.0", + "@angular/common": "18.2.1", + "@deck.gl/carto": "~8.8.20", + "@deck.gl/core": "~8.8.20", + "@deck.gl/extensions": "~8.8.20", + "@deck.gl/layers": "~8.8.20", + "@vivjs/layers": "0.16.1" }, "sideEffects": [ "index.ts" From e2310d78c713ea078521e3cd82a3f5d6d0020fdd Mon Sep 17 00:00:00 2001 From: Bhushan Khope Date: Mon, 23 Sep 2024 11:20:00 -0400 Subject: [PATCH 07/23] improve doc coverage --- .../deck-gl-visualization.component.ts | 25 ++++ .../lib/node-dist-vis/node-dist-vis.worker.ts | 48 +++---- .../src/lib/services/edge-data.service.ts | 136 +++++++++--------- .../src/lib/utils/distance-edges.ts | 2 + 4 files changed, 119 insertions(+), 92 deletions(-) diff --git a/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.ts b/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.ts index 33521820d..8afafb9b8 100644 --- a/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.ts +++ b/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.ts @@ -28,6 +28,7 @@ const SELECTION_RANGE: [number, number] = [0, 10]; const SELECTION_VALUE_INSIDE_RANGE = 5; const SELECTION_VALUE_OUT_OF_RANGE = 100; +/** DeckGl Visualization Component */ @Component({ selector: 'hra-deck-gl-visualization', standalone: true, @@ -37,21 +38,33 @@ const SELECTION_VALUE_OUT_OF_RANGE = 100; changeDetection: ChangeDetectionStrategy.OnPush, }) export class DeckGlVisualizationComponent { + /** Nodes for the visualization */ readonly nodes = input.required(); + /** Edges for the visualization */ readonly edges = input.required(); + /** Selection for the visualization */ readonly selection = input.required(); + /** Colormap for the visualization */ readonly colorMap = input.required<{ domain: string[]; range: Color[] }>(); + /** Node target key for the visualization */ readonly nodeTargetKey = input.required(); + /** Event for click on the node */ readonly nodeClick = output(); + /** Event for hover on the node */ readonly nodeHover = output(); + /** Reference to the canvas element */ private readonly canvas = viewChild.required>('canvas'); + /** Configuration of the view state of the deck gl */ private readonly viewState = signal(this.getInitialViewState()); + /** */ private readonly activeHover = signal(NO_HOVER); + /** Counter for view state version */ private viewStateVersionCounter = 0; + /** Computed dimensions based on current node position */ private readonly dimensions = computed(() => { let minDimSize = Number.MAX_VALUE; let maxDimSize = Number.MIN_VALUE; @@ -63,12 +76,14 @@ export class DeckGlVisualizationComponent { return [minDimSize, maxDimSize] as const; }); + /** */ private readonly scaleFn = computed(() => { const [min, max] = this.dimensions(); const diff = max - min; return ([x, y, z]: Position): Position => [(x - min) / diff, 1 - (y - min) / diff, (z - min) / diff]; }); + /** Computed nodes layer required to render deckgl */ private readonly nodesLayer = computed(() => { type ExtraProps = DataFilterExtensionProps; @@ -92,6 +107,7 @@ export class DeckGlVisualizationComponent { }); }); + /** Computed edges layer required to render deckgl */ private readonly edgesLayer = computed(() => { const nodes = this.nodes(); const targetKey = this.nodeTargetKey(); @@ -121,6 +137,7 @@ export class DeckGlVisualizationComponent { }); }); + /** Computed scale bar layer required to render deckgl */ private readonly scaleBarLayer = computed(() => { type Props = ConstructorParameters[0]; const { width, height } = this.canvas().nativeElement; @@ -139,6 +156,7 @@ export class DeckGlVisualizationComponent { } as Props); }); + /** Deck initialization */ private readonly deck = computed( () => new Deck({ @@ -154,6 +172,7 @@ export class DeckGlVisualizationComponent { }), ); + /** Set properties to the deck */ constructor() { effect(() => { const deck = this.deck(); @@ -168,6 +187,7 @@ export class DeckGlVisualizationComponent { }); } + /** Reset the deck view */ resetView() { this.deck().setProps({ initialViewState: this.getInitialViewState() }); } @@ -199,6 +219,7 @@ export class DeckGlVisualizationComponent { }) as AccessorFunction; } + /** Initial view state config */ private getInitialViewState() { return { version: this.viewStateVersionCounter++, @@ -214,6 +235,7 @@ export class DeckGlVisualizationComponent { }; } + /** Gets the current cursor state */ private getCursor(...[state]: DeckCallbackParameters<'getCursor'>): string { if (this.activeHover()) { return 'pointer'; @@ -224,16 +246,19 @@ export class DeckGlVisualizationComponent { } } + /** Updates the deck gl view state with current view state */ private onViewStateChange(...[params]: DeckCallbackParameters<'onViewStateChange'>): void { this.viewState.set(params.viewState); } + /** Emits node information when clicked on the node */ private onClick(...[info]: DeckCallbackParameters<'onClick'>): void { if (info.picked) { this.nodeClick.emit(info.object); } } + /** Emits hover information when hovered on the node */ private onHover(...[info]: DeckCallbackParameters<'onHover'>): void { this.activeHover.set(info.picked ? info.object : undefined); } diff --git a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.worker.ts b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.worker.ts index 3e273cfd6..1dff0c272 100644 --- a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.worker.ts +++ b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.worker.ts @@ -1,28 +1,28 @@ -// /// -// import { NodeEntry } from '../models/nodes'; -// import { distanceEdges } from '../utils/distance-edges'; +/// +import { NodeEntry } from '../models/nodes'; +import { distanceEdges } from '../utils/distance-edges'; -// interface WorkerMessage { -// nodes: NodeEntry[]; -// type_field: string; -// target_type: string; -// maxDist: number; -// } +interface WorkerMessage { + nodes: NodeEntry[]; + type_field: string; + target_type: string; + maxDist: number; +} -// addEventListener('message', (event: MessageEvent) => { -// const { nodes, type_field, target_type, maxDist } = event.data; -// const edges: any[] = new Array(nodes.length); -// let index = 0; -// const reportStep = Math.floor(nodes.length / 10); +addEventListener('message', (event: MessageEvent) => { + const { nodes, type_field, target_type, maxDist } = event.data; + const edges: any[] = new Array(nodes.length); + let index = 0; + const reportStep = Math.floor(nodes.length / 10); -// for (const edge of distanceEdges(nodes, type_field, target_type, maxDist)) { -// edges[index] = edge; -// if (index % reportStep === 0) { -// const percentage = Math.round((index / nodes.length) * 100); -// postMessage({ status: 'processing', percentage, node_index: edge[0] }); -// } -// index++; -// } + for (const edge of distanceEdges(nodes, type_field, target_type, maxDist)) { + edges[index] = edge; + if (index % reportStep === 0) { + const percentage = Math.round((index / nodes.length) * 100); + postMessage({ status: 'processing', percentage, node_index: edge[0] }); + } + index++; + } -// postMessage({ status: 'complete', edges: edges.slice(0, index) }); -// }); + postMessage({ status: 'complete', edges: edges.slice(0, index) }); +}); diff --git a/libs/node-dist-vis/src/lib/services/edge-data.service.ts b/libs/node-dist-vis/src/lib/services/edge-data.service.ts index 5a20acb9b..322d7d0fd 100644 --- a/libs/node-dist-vis/src/lib/services/edge-data.service.ts +++ b/libs/node-dist-vis/src/lib/services/edge-data.service.ts @@ -1,76 +1,76 @@ -// import { inject, Injectable } from '@angular/core'; -// import { toObservable, toSignal } from '@angular/core/rxjs-interop'; -// import { combineLatest, distinctUntilChanged, ObservableInput, of, Subject, switchMap } from 'rxjs'; -// import { EdgeEntry } from '../models/edges'; -// import { NodeEntry, NodeTargetKey } from '../models/nodes'; -// import { fetchCsv } from '../utils/helper'; -// import { NodeDataService, NodesData } from './node-data.service'; +import { inject, Injectable } from '@angular/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { combineLatest, distinctUntilChanged, ObservableInput, of, Subject, switchMap } from 'rxjs'; +import { EdgeEntry } from '../models/edges'; +import { NodeEntry, NodeTargetKey } from '../models/nodes'; +import { fetchCsv } from '../utils/helper'; +import { NodeDataService, NodesData } from './node-data.service'; -// export type EdgesInput = string | EdgeEntry[] | undefined; +export type EdgesInput = string | EdgeEntry[] | undefined; -// export interface EdgesData { -// edges: EdgeEntry[] | undefined; -// maxEdgeDistance: number; -// } +export interface EdgesData { + edges: EdgeEntry[] | undefined; + maxEdgeDistance: number; +} -// @Injectable() -// export class EdgeDataService { -// readonly edgesInput = new Subject(); -// private readonly loadedEdges = this.edgesInput.pipe( -// distinctUntilChanged(), -// switchMap((data) => this.loadEdges(data)), -// ); +@Injectable() +export class EdgeDataService { + readonly edgesInput = new Subject(); + private readonly loadedEdges = this.edgesInput.pipe( + distinctUntilChanged(), + switchMap((data) => this.loadEdges(data)), + ); -// private readonly nodeDataService = inject(NodeDataService); -// readonly edges = toSignal( -// combineLatest([toObservable(this.nodeDataService.nodesData), this.loadedEdges]).pipe( -// switchMap(([nodes, edges]) => this.computeEdges(nodes, { edges, maxEdgeDistance: 0 })), -// ), -// { -// initialValue: undefined, -// }, -// ); + private readonly nodeDataService = inject(NodeDataService); + readonly edges = toSignal( + combineLatest([toObservable(this.nodeDataService.nodesData), this.loadedEdges]).pipe( + switchMap(([nodes, edges]) => this.computeEdges(nodes, { edges, maxEdgeDistance: 0 })), + ), + { + initialValue: undefined, + }, + ); -// private loadEdges(data: EdgesInput): ObservableInput { -// if (Array.isArray(data)) { -// return of(data); -// } else if (typeof data === 'string') { -// const edgesData = fetchCsv(data, { header: false }); -// edgesData.then((res) => of(res)); -// } -// return of([]); -// } + private loadEdges(data: EdgesInput): ObservableInput { + if (Array.isArray(data)) { + return of(data); + } else if (typeof data === 'string') { + const edgesData = fetchCsv(data, { header: false }); + edgesData.then((res) => of(res)); + } + return of([]); + } -// private async customDistanceEdges(nodes: NodeEntry[], key: NodeTargetKey, value: string, maxEdgeDist: number) { -// if (typeof Worker !== 'undefined') { -// const worker = new Worker(new URL('../node-dist-vis/node-dist-vis.worker', import.meta.url)); -// return new Promise((resolve) => { -// worker.onmessage = (e) => { -// if (e.data.status === 'processing') { -// console.log(`Computing edges; ${e.data.percentage}% complete.`); -// } else if (e.data.status === 'complete') { -// resolve(e.data.edges); -// worker.terminate(); -// } -// }; -// worker.postMessage({ nodes, key, value, maxEdgeDist }); -// }); -// } else { -// return; -// // Web workers are not supported in this environment. -// // You should add a fallback so that your program still executes correctly. -// } -// } + private async customDistanceEdges(nodes: NodeEntry[], key: NodeTargetKey, value: string, maxEdgeDist: number) { + if (typeof Worker !== 'undefined') { + const worker = new Worker(new URL('../node-dist-vis/node-dist-vis.worker', import.meta.url)); + return new Promise((resolve) => { + worker.onmessage = (e) => { + if (e.data.status === 'processing') { + console.log(`Computing edges; ${e.data.percentage}% complete.`); + } else if (e.data.status === 'complete') { + resolve(e.data.edges); + worker.terminate(); + } + }; + worker.postMessage({ nodes, key, value, maxEdgeDist }); + }); + } else { + return; + // Web workers are not supported in this environment. + // You should add a fallback so that your program still executes correctly. + } + } -// private computeEdges(nodesData: NodesData, edgesData: EdgesData): ObservableInput { -// if (nodesData.nodes.length === 0) { -// return of({ edges: undefined, maxEdgeDistance: 0 }); -// } else if (edgesData.edges === undefined) { -// const { nodes, key, value } = nodesData; -// const distEdges = this.customDistanceEdges(nodes, key, value, edgesData.maxEdgeDistance); -// distEdges.then(res=>of(res)) -// } + private computeEdges(nodesData: NodesData, edgesData: EdgesData): ObservableInput { + if (nodesData.nodes.length === 0) { + return of({ edges: undefined, maxEdgeDistance: 0 }); + } else if (edgesData.edges === undefined) { + const { nodes, key, value } = nodesData; + const distEdges = this.customDistanceEdges(nodes, key, value, edgesData.maxEdgeDistance); + distEdges.then((res) => of(res)); + } -// return of(edgesData); -// } -// } + return of(edgesData); + } +} diff --git a/libs/node-dist-vis/src/lib/utils/distance-edges.ts b/libs/node-dist-vis/src/lib/utils/distance-edges.ts index d43f3962f..5e124601a 100644 --- a/libs/node-dist-vis/src/lib/utils/distance-edges.ts +++ b/libs/node-dist-vis/src/lib/utils/distance-edges.ts @@ -35,6 +35,8 @@ function* getClosest(sources: number[][], sourceIndexes: number[], targets: numb } function addToCell(node: any, cells: any) { + console.log(node, 'node'); + console.log(cells, 'cells'); const cx = (cells[node.cell[0]] = cells[node.cell[0]] || {}); const cy = (cx[node.cell[1]] = cx[node.cell[1]] || { nodes: [], From f2f446b7939497d1ecc3018d7626ac2256882d9b Mon Sep 17 00:00:00 2001 From: Daniel Bolin Date: Thu, 17 Oct 2024 13:43:54 -0400 Subject: [PATCH 08/23] refactor: Node dist vis data format update (WIP) --- .../deck-gl-visualization.component.html | 1 - .../deck-gl-visualization.component.scss | 3 - .../deck-gl-visualization.component.spec.ts | 21 -- .../deck-gl-visualization.component.ts | 265 ------------------ libs/node-dist-vis/src/lib/deckgl/deck.ts | 15 + .../src/lib/deckgl/layers/edges.ts | 64 +++++ .../src/lib/deckgl/layers/nodes.ts | 53 ++++ .../src/lib/deckgl/layers/scale-bar.ts | 38 +++ .../lib/deckgl/layers/utils/color-coding.ts | 27 ++ .../deckgl/layers/utils/position-scaling.ts | 15 + .../deckgl/layers/utils/selection-filter.ts | 20 ++ .../node-dist-vis/src/lib/models/color-map.ts | 38 +++ .../node-dist-vis/src/lib/models/data-view.ts | 83 ++++++ libs/node-dist-vis/src/lib/models/edges.ts | 51 ++-- libs/node-dist-vis/src/lib/models/nodes.ts | 50 +++- .../node-dist-vis/node-dist-vis.component.ts | 142 ++++++++++ 16 files changed, 558 insertions(+), 328 deletions(-) delete mode 100644 libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.html delete mode 100644 libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.scss delete mode 100644 libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.spec.ts delete mode 100644 libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.ts create mode 100644 libs/node-dist-vis/src/lib/deckgl/deck.ts create mode 100644 libs/node-dist-vis/src/lib/deckgl/layers/edges.ts create mode 100644 libs/node-dist-vis/src/lib/deckgl/layers/nodes.ts create mode 100644 libs/node-dist-vis/src/lib/deckgl/layers/scale-bar.ts create mode 100644 libs/node-dist-vis/src/lib/deckgl/layers/utils/color-coding.ts create mode 100644 libs/node-dist-vis/src/lib/deckgl/layers/utils/position-scaling.ts create mode 100644 libs/node-dist-vis/src/lib/deckgl/layers/utils/selection-filter.ts create mode 100644 libs/node-dist-vis/src/lib/models/color-map.ts create mode 100644 libs/node-dist-vis/src/lib/models/data-view.ts diff --git a/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.html b/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.html deleted file mode 100644 index c2e2ad0b6..000000000 --- a/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.scss b/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.scss deleted file mode 100644 index 5d4e87f30..000000000 --- a/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -:host { - display: block; -} diff --git a/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.spec.ts b/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.spec.ts deleted file mode 100644 index 009c40230..000000000 --- a/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { DeckGlVisualizationComponent } from './deck-gl-visualization.component'; - -describe('DeckGlVisualizationComponent', () => { - let component: DeckGlVisualizationComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [DeckGlVisualizationComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(DeckGlVisualizationComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.ts b/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.ts deleted file mode 100644 index 8afafb9b8..000000000 --- a/libs/node-dist-vis/src/lib/deck-gl-visualization/deck-gl-visualization.component.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { - ChangeDetectionStrategy, - Component, - computed, - effect, - ElementRef, - input, - output, - signal, - untracked, - viewChild, -} from '@angular/core'; -import { colorCategories } from '@deck.gl/carto/typed'; -import { AccessorFunction, Color, COORDINATE_SYSTEM, Deck, DeckProps, OrbitView, Position } from '@deck.gl/core/typed'; -import { DataFilterExtension, DataFilterExtensionProps } from '@deck.gl/extensions/typed'; -import { LineLayer, PointCloudLayer } from '@deck.gl/layers/typed'; -import { ScaleBarLayer } from '@vivjs/layers'; -import { EdgeEntry, EdgeIndex } from '../models/edges'; -import { NodeEntry, NodeTargetKey } from '../models/nodes'; - -type DeckCallbackParameters = Parameters>; - -const NO_HOVER = Symbol(); -const ORIGIN: Position = [0, 0, 0]; -const COLOR_WHITE: Color = [255, 255, 255]; -const SELECTION_RANGE: [number, number] = [0, 10]; -const SELECTION_VALUE_INSIDE_RANGE = 5; -const SELECTION_VALUE_OUT_OF_RANGE = 100; - -/** DeckGl Visualization Component */ -@Component({ - selector: 'hra-deck-gl-visualization', - standalone: true, - imports: [CommonModule], - templateUrl: './deck-gl-visualization.component.html', - styleUrl: './deck-gl-visualization.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class DeckGlVisualizationComponent { - /** Nodes for the visualization */ - readonly nodes = input.required(); - /** Edges for the visualization */ - readonly edges = input.required(); - /** Selection for the visualization */ - readonly selection = input.required(); - /** Colormap for the visualization */ - readonly colorMap = input.required<{ domain: string[]; range: Color[] }>(); - /** Node target key for the visualization */ - readonly nodeTargetKey = input.required(); - - /** Event for click on the node */ - readonly nodeClick = output(); - /** Event for hover on the node */ - readonly nodeHover = output(); - - /** Reference to the canvas element */ - private readonly canvas = viewChild.required>('canvas'); - /** Configuration of the view state of the deck gl */ - private readonly viewState = signal(this.getInitialViewState()); - /** */ - private readonly activeHover = signal(NO_HOVER); - - /** Counter for view state version */ - private viewStateVersionCounter = 0; - - /** Computed dimensions based on current node position */ - private readonly dimensions = computed(() => { - let minDimSize = Number.MAX_VALUE; - let maxDimSize = Number.MIN_VALUE; - for (const node of this.nodes() ?? []) { - maxDimSize = Math.max(maxDimSize, ...(node.position ?? [])); - minDimSize = Math.min(minDimSize, ...(node.position ?? [])); - } - - return [minDimSize, maxDimSize] as const; - }); - - /** */ - private readonly scaleFn = computed(() => { - const [min, max] = this.dimensions(); - const diff = max - min; - return ([x, y, z]: Position): Position => [(x - min) / diff, 1 - (y - min) / diff, (z - min) / diff]; - }); - - /** Computed nodes layer required to render deckgl */ - private readonly nodesLayer = computed(() => { - type ExtraProps = DataFilterExtensionProps; - - const targetKey = this.nodeTargetKey(); - return new PointCloudLayer({ - id: 'nodes', - data: this.nodes(), - getPosition: this.createScaler((node) => node.position ?? ORIGIN), - getColor: this.createColorCoding((node) => node[targetKey]), - pickable: true, - coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, - pointSize: 1.5, - getFilterValue: this.createFilter((d) => d[this.nodeTargetKey()]), - filterRange: SELECTION_RANGE, - filterEnabled: this.selection() !== undefined, - extensions: [new DataFilterExtension()], - updateTriggers: { - getColor: this.colorMap()?.range, - getFilterValue: this.selection(), - }, - }); - }); - - /** Computed edges layer required to render deckgl */ - private readonly edgesLayer = computed(() => { - const nodes = this.nodes(); - const targetKey = this.nodeTargetKey(); - const sourceNodeAccessor = (edge: EdgeEntry) => nodes[edge[EdgeIndex.SourceNode]][targetKey]; - const sourcePositionAccessor = (edge: EdgeEntry) => - [edge[EdgeIndex.x0], edge[EdgeIndex.y0], edge[EdgeIndex.z0]] satisfies Position; - const targetPositionAccessor = (edge: EdgeEntry) => - [edge[EdgeIndex.x0], edge[EdgeIndex.y0], edge[EdgeIndex.z0]] satisfies Position; - - return new LineLayer({ - id: 'edges', - data: this.edges(), - getSourcePosition: this.createScaler(sourcePositionAccessor), - getTargetPosition: this.createScaler(targetPositionAccessor), - getColor: this.createColorCoding(sourceNodeAccessor), - pickable: false, - coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, - getWidth: 1, - getFilterValue: this.createFilter(sourceNodeAccessor), - filterRange: SELECTION_RANGE, - filterEnabled: this.selection() !== undefined, - extensions: [new DataFilterExtension()], - updateTriggers: { - getColor: this.colorMap()?.range, - getFilterValue: this.selection(), - }, - }); - }); - - /** Computed scale bar layer required to render deckgl */ - private readonly scaleBarLayer = computed(() => { - type Props = ConstructorParameters[0]; - const { width, height } = this.canvas().nativeElement; - // Scale 1µm the same way positions are scaled - const scale = this.scaleFn(); - const size = 1 / scale([1, 1, 1])[0]; - - return new ScaleBarLayer({ - id: 'scalebar', - unit: 'µm', - size: size, - position: 'top-right', - viewState: { ...this.viewState(), width: width - 136, height: height - 32 }, - length: 0.1, - snap: true, - } as Props); - }); - - /** Deck initialization */ - private readonly deck = computed( - () => - new Deck({ - canvas: this.canvas().nativeElement, - controller: true, - views: [new OrbitView({ orbitAxis: 'Y' })], - initialViewState: untracked(this.viewState), - layers: [], - getCursor: this.getCursor.bind(this), - onViewStateChange: this.onViewStateChange.bind(this), - onClick: this.onClick.bind(this), - onHover: this.onHover.bind(this), - }), - ); - - /** Set properties to the deck */ - constructor() { - effect(() => { - const deck = this.deck(); - deck.setProps({ layers: [this.nodesLayer(), this.edgesLayer(), this.scaleBarLayer()] }); - }); - - effect(() => { - const activeHover = this.activeHover(); - if (activeHover !== NO_HOVER) { - this.nodeHover.emit(activeHover); - } - }); - } - - /** Reset the deck view */ - resetView() { - this.deck().setProps({ initialViewState: this.getInitialViewState() }); - } - - private createScaler(accessor: (value: T) => Position): AccessorFunction { - const scale = this.scaleFn(); - return (value) => scale(accessor(value)); - } - - private createFilter(accessor: (value: T) => string): AccessorFunction { - const selection = this.selection(); - const selectionSet = new Set(selection); - if (selection === undefined) { - return () => SELECTION_VALUE_INSIDE_RANGE; - } - - return (value) => (selectionSet.has(accessor(value)) ? SELECTION_VALUE_INSIDE_RANGE : SELECTION_VALUE_OUT_OF_RANGE); - } - - private createColorCoding(accessor: (value: T) => number | string): AccessorFunction { - type Color2 = Exclude[0]['colors'], string>[number]; - const { domain, range } = this.colorMap(); - return colorCategories({ - attr: accessor, - domain: domain, - colors: range as Color2[], - othersColor: COLOR_WHITE as Color2, - nullColor: COLOR_WHITE as Color2, - }) as AccessorFunction; - } - - /** Initial view state config */ - private getInitialViewState() { - return { - version: this.viewStateVersionCounter++, - orbitAxis: 'Y', - camera: 'orbit', - zoom: 9, - minRotationX: -90, - maxRotationX: 90, - rotationX: 0, - rotationOrbit: 0, - dragMode: 'rotate', - target: [0.5, 0.5], - }; - } - - /** Gets the current cursor state */ - private getCursor(...[state]: DeckCallbackParameters<'getCursor'>): string { - if (this.activeHover()) { - return 'pointer'; - } else if (state.isDragging) { - return 'grabbing'; - } else { - return 'grab'; - } - } - - /** Updates the deck gl view state with current view state */ - private onViewStateChange(...[params]: DeckCallbackParameters<'onViewStateChange'>): void { - this.viewState.set(params.viewState); - } - - /** Emits node information when clicked on the node */ - private onClick(...[info]: DeckCallbackParameters<'onClick'>): void { - if (info.picked) { - this.nodeClick.emit(info.object); - } - } - - /** Emits hover information when hovered on the node */ - private onHover(...[info]: DeckCallbackParameters<'onHover'>): void { - this.activeHover.set(info.picked ? info.object : undefined); - } -} diff --git a/libs/node-dist-vis/src/lib/deckgl/deck.ts b/libs/node-dist-vis/src/lib/deckgl/deck.ts new file mode 100644 index 000000000..ca6d2b5bc --- /dev/null +++ b/libs/node-dist-vis/src/lib/deckgl/deck.ts @@ -0,0 +1,15 @@ +import { Signal } from '@angular/core'; +import { Deck, DeckProps } from '@deck.gl/core/typed'; +import { derivedAsync } from 'ngxtension/derived-async'; + +export function createDeck(props: Signal): Signal { + return derivedAsync((previous) => { + previous?.finalize(); + return new Promise((resolve) => { + const deck = new Deck({ + ...props(), + onLoad: () => resolve(deck), + }); + }); + }); +} diff --git a/libs/node-dist-vis/src/lib/deckgl/layers/edges.ts b/libs/node-dist-vis/src/lib/deckgl/layers/edges.ts new file mode 100644 index 000000000..d0d24d0ee --- /dev/null +++ b/libs/node-dist-vis/src/lib/deckgl/layers/edges.ts @@ -0,0 +1,64 @@ +import { computed, Signal } from '@angular/core'; +import { COORDINATE_SYSTEM } from '@deck.gl/core/typed'; +import { DataFilterExtension, DataFilterExtensionProps } from '@deck.gl/extensions/typed'; +import { LineLayer } from '@deck.gl/layers/typed'; +import { ColorMapView } from '../../models/color-map'; +import { AnyData, AnyDataEntry } from '../../models/data-view'; +import { EdgesView } from '../../models/edges'; +import { NodesView } from '../../models/nodes'; +import { createColorAccessor } from './utils/color-coding'; +import { createScaledPositionAccessor } from './utils/position-scaling'; +import { createSelectionFilterAccessor, FILTER_RANGE } from './utils/selection-filter'; + +export type EdgesLayer = LineLayer>; + +export function createEdgesLayer( + nodes: Signal, + edges: Signal, + selection: Signal, + colorMap: Signal, +): Signal { + const sourcePositionAccessor = computed(() => { + const accessor = edges().getSourcePositionFor; + const dimensions = nodes().getDimensions(); + return createScaledPositionAccessor(accessor, dimensions); + }); + const targetPositionAccessor = computed(() => { + const accessor = edges().getTargetPositionFor; + const dimensions = nodes().getDimensions(); + return createScaledPositionAccessor(accessor, dimensions); + }); + const cellTypeAccessor = computed(() => { + const nodeIndex = edges().getCellIDFor; + const nodeCellType = nodes().getCellTypeAt; + return (obj: AnyDataEntry) => nodeCellType(nodeIndex(obj)); + }); + const colorAccessor = computed(() => { + const map = colorMap().getColorMap(); + return createColorAccessor(cellTypeAccessor(), map); + }); + const filterValueAccessor = computed(() => { + return createSelectionFilterAccessor(cellTypeAccessor(), selection()); + }); + + return computed(() => { + return new LineLayer({ + id: 'edges', + data: edges().data, + getSourcePosition: sourcePositionAccessor(), + getTargetPosition: targetPositionAccessor(), + getColor: colorAccessor(), + pickable: false, + coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, + getWidth: 1, + getFilterValue: filterValueAccessor(), + filterRange: FILTER_RANGE, + filterEnabled: selection() !== undefined, + extensions: [new DataFilterExtension()], + updateTriggers: { + getColor: colorMap().getRange(), + getFilterValue: selection(), + }, + }); + }); +} diff --git a/libs/node-dist-vis/src/lib/deckgl/layers/nodes.ts b/libs/node-dist-vis/src/lib/deckgl/layers/nodes.ts new file mode 100644 index 000000000..91c0e0e93 --- /dev/null +++ b/libs/node-dist-vis/src/lib/deckgl/layers/nodes.ts @@ -0,0 +1,53 @@ +import { computed, Signal } from '@angular/core'; +import { COORDINATE_SYSTEM } from '@deck.gl/core/typed'; +import { DataFilterExtension, DataFilterExtensionProps } from '@deck.gl/extensions/typed'; +import { PointCloudLayer } from '@deck.gl/layers/typed'; +import { ColorMapView } from '../../models/color-map'; +import { AnyData } from '../../models/data-view'; +import { NodesView } from '../../models/nodes'; +import { createColorAccessor } from './utils/color-coding'; +import { createScaledPositionAccessor } from './utils/position-scaling'; +import { createSelectionFilterAccessor, FILTER_RANGE } from './utils/selection-filter'; + +export type NodesLayer = PointCloudLayer>; + +export function createNodesLayer( + nodes: Signal, + selection: Signal, + colorMap: Signal, +): Signal { + const positionAccessor = computed(() => { + const accessor = nodes().getPositionFor; + const dimensions = nodes().getDimensions(); + return createScaledPositionAccessor(accessor, dimensions); + }); + const colorAccessor = computed(() => { + const accessor = nodes().getCellTypeFor; + const map = colorMap().getColorMap(); + return createColorAccessor(accessor, map); + }); + const filterValueAccessor = computed(() => { + const accessor = nodes().getCellTypeFor; + return createSelectionFilterAccessor(accessor, selection()); + }); + + return computed(() => { + return new PointCloudLayer({ + id: 'nodes', + data: nodes().data, + getPosition: positionAccessor(), + getColor: colorAccessor(), + pickable: true, + coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, + pointSize: 1.5, + getFilterValue: filterValueAccessor(), + filterRange: FILTER_RANGE, + filterEnabled: selection() !== undefined, + extensions: [new DataFilterExtension()], + updateTriggers: { + getColor: colorMap().getRange(), + getFilterValue: selection(), + }, + }); + }); +} diff --git a/libs/node-dist-vis/src/lib/deckgl/layers/scale-bar.ts b/libs/node-dist-vis/src/lib/deckgl/layers/scale-bar.ts new file mode 100644 index 000000000..392c8b11a --- /dev/null +++ b/libs/node-dist-vis/src/lib/deckgl/layers/scale-bar.ts @@ -0,0 +1,38 @@ +import { computed, Signal } from '@angular/core'; +import { Layer } from '@deck.gl/core/typed'; +import { ScaleBarLayer as ScaleBarLayerConstructor } from '@vivjs/layers'; +import { NodesView } from '../../models/nodes'; + +type ScaleBarLayerProps = ConstructorParameters[0]; +export type ScaleBarLayer = Layer; + +export function createScaleBarLayer( + nodes: Signal, + viewSize: Signal<[number, number]>, + viewState: Signal, +): Signal { + const size = computed(() => { + const [min, max] = nodes().getDimensions(); + return (max - min) / (1 - min); + }); + const state = computed(() => { + const [width, height] = viewSize(); + return { + ...viewState(), + width: width - 136, + height: height - 32, + }; + }); + + return computed(() => { + return new ScaleBarLayerConstructor({ + id: 'scalebar', + unit: 'µm', + position: 'top-right', + length: 0.1, + snap: true, + size: size(), + viewState: state(), + } as ScaleBarLayerProps); + }); +} diff --git a/libs/node-dist-vis/src/lib/deckgl/layers/utils/color-coding.ts b/libs/node-dist-vis/src/lib/deckgl/layers/utils/color-coding.ts new file mode 100644 index 000000000..56021c14e --- /dev/null +++ b/libs/node-dist-vis/src/lib/deckgl/layers/utils/color-coding.ts @@ -0,0 +1,27 @@ +import { AccessorContext, AccessorFunction, Color } from '@deck.gl/core/typed'; +import { ColorMap } from '../../../models/color-map'; +import { colorCategories } from '@deck.gl/carto/typed'; + +type Color2 = [r: number, g: number, b: number, a?: number]; + +const WHITE: Color2 = [255, 255, 255]; + +export function createColorAccessor( + accessor: AccessorFunction, + colorMap: ColorMap, + defaultColor?: Color, +): AccessorFunction { + let context: AccessorContext; + const coding = colorCategories({ + attr: (obj) => accessor(obj, context), + domain: colorMap.domain, + colors: colorMap.range as Color2[], + nullColor: (defaultColor ?? WHITE) as Color2, + othersColor: (defaultColor ?? WHITE) as Color2, + }); + + return (obj, info) => { + context = info; + return coding(obj, info) as Color; + }; +} diff --git a/libs/node-dist-vis/src/lib/deckgl/layers/utils/position-scaling.ts b/libs/node-dist-vis/src/lib/deckgl/layers/utils/position-scaling.ts new file mode 100644 index 000000000..526224a07 --- /dev/null +++ b/libs/node-dist-vis/src/lib/deckgl/layers/utils/position-scaling.ts @@ -0,0 +1,15 @@ +import { AccessorFunction, Position } from '@deck.gl/core/typed'; + +export function createScaledPositionAccessor( + accessor: AccessorFunction, + dimensions: [number, number], +): AccessorFunction { + const [min, max] = dimensions; + const diff = max - min; + const scale = (value: number) => (value - min) / diff; + + return (obj, info) => { + const [x, y, z] = accessor(obj, info); + return [scale(x), 1 - scale(y), scale(z)]; + }; +} diff --git a/libs/node-dist-vis/src/lib/deckgl/layers/utils/selection-filter.ts b/libs/node-dist-vis/src/lib/deckgl/layers/utils/selection-filter.ts new file mode 100644 index 000000000..9534fa3a8 --- /dev/null +++ b/libs/node-dist-vis/src/lib/deckgl/layers/utils/selection-filter.ts @@ -0,0 +1,20 @@ +import { AccessorFunction } from '@deck.gl/core/typed'; + +const FILTER_INCLUDE_VALUE = 1; +const FILTER_EXCLUDE_VALUE = 3; +export const FILTER_RANGE: [number, number] = [0, 2]; + +export function createSelectionFilterAccessor( + accessor: AccessorFunction, + selection: string[] | undefined, +): AccessorFunction { + if (selection === undefined) { + return () => FILTER_INCLUDE_VALUE; + } + + const selectionSet = new Set(selection); + return (obj, info) => { + const value = accessor(obj, info); + return selectionSet.has(value) ? FILTER_INCLUDE_VALUE : FILTER_EXCLUDE_VALUE; + }; +} diff --git a/libs/node-dist-vis/src/lib/models/color-map.ts b/libs/node-dist-vis/src/lib/models/color-map.ts new file mode 100644 index 000000000..8e9f039eb --- /dev/null +++ b/libs/node-dist-vis/src/lib/models/color-map.ts @@ -0,0 +1,38 @@ +import { Color } from '@deck.gl/core/typed'; +import { createDataViewClass } from './data-view'; + +export interface ColorMapEntry { + // TODO verify key names + 'Cell Type': string; + 'Cell Color': Color; +} + +export interface ColorMap { + domain: string[]; + range: Color[]; +} + +const COLOR_MAP_KEYS: (keyof ColorMapEntry)[] = ['Cell Type', 'Cell Color']; +const BaseColorMapView = createDataViewClass(COLOR_MAP_KEYS); + +export class ColorMapView extends BaseColorMapView { + readonly getColorMap = () => { + if (this._colorMap) { + return this._colorMap; + } + + const domain: string[] = []; + const range: Color[] = []; + for (const obj of this.data) { + domain.push(this.getCellTypeFor(obj)); + range.push(this.getCellColorFor(obj)); + } + + return (this._colorMap = { domain, range }); + }; + + readonly getDomain = () => this.getColorMap().domain; + readonly getRange = () => this.getColorMap().range; + + private _colorMap?: ColorMap = undefined; +} diff --git a/libs/node-dist-vis/src/lib/models/data-view.ts b/libs/node-dist-vis/src/lib/models/data-view.ts new file mode 100644 index 000000000..1d24c529b --- /dev/null +++ b/libs/node-dist-vis/src/lib/models/data-view.ts @@ -0,0 +1,83 @@ +type RemoveWhiteSpace = S extends `${infer Pre} ${infer Post}` + ? RemoveWhiteSpace<`${Pre}${Post}`> + : S; + +type AccessorPostfixes = 'At' | 'For'; +type AccessorName< + Entry, + P extends keyof Entry, + Postfix extends AccessorPostfixes, +> = `get${Capitalize>}${Postfix}`; +type Accessor = (arg: Arg) => Entry[P]; + +export type AnyDataEntry = unknown[] | object; +export type AnyData = unknown[][] | object[]; +export type KeyMapping = { [P in keyof Entry]: PropertyKey }; + +export type DataViewAccessors = { + [P in keyof Entry as AccessorName]-?: Accessor; +} & { + [P in keyof Entry as AccessorName]-?: Accessor; +}; + +export interface DataView { + readonly keys: (keyof Entry)[]; + readonly data: AnyData; + readonly keyMapping: KeyMapping; + + readonly getPropertyAt:

(index: number, property: P) => Entry[P]; + readonly getPropertyFor:

(obj: AnyDataEntry, property: P) => Entry[P]; +} + +export interface DataViewConstructor { + new (data: AnyData, keyMapping?: KeyMapping): DataView & DataViewAccessors; +} + +function createAccessorName(property: keyof Entry, postfix: AccessorPostfixes): string { + const trimmedProperty = String(property).replace(/\s+/g, ''); + const capitalizedProperty = trimmedProperty.slice(0, 1).toUpperCase() + trimmedProperty.slice(1); + return `get${capitalizedProperty}${postfix}`; +} + +function createAccessor(instance: DataView, property: keyof Entry, postfix: AccessorPostfixes) { + const method = `getProperty${postfix}` as const; + return (arg: unknown) => instance[method](arg as never, property); +} + +function attachAccessors(instance: DataView, keys: (keyof Entry)[]): void { + const postfixes: AccessorPostfixes[] = ['At', 'For']; + for (const key of keys) { + for (const postfix of postfixes) { + const name = createAccessorName(key, postfix); + const accessor = createAccessor(instance, key, postfix); + (instance as unknown as Record)[name] = accessor; + } + } +} + +export function createDataViewClass(keys: (keyof Entry)[]): DataViewConstructor { + class DataViewImpl implements DataView { + readonly keys = keys; + + readonly getPropertyAt =

(index: number, property: P): Entry[P] => { + return this.getPropertyFor(this.data[index], property); + }; + readonly getPropertyFor =

(obj: AnyDataEntry, property: P): Entry[P] => { + const key = this.keyMapping[property]; + if (key === undefined) { + return undefined as Entry[P]; + } + + return (obj as Record)[key]; + }; + + constructor( + readonly data: AnyData, + readonly keyMapping: KeyMapping, + ) { + attachAccessors(this, this.keys); + } + } + + return DataViewImpl as unknown as DataViewConstructor; +} diff --git a/libs/node-dist-vis/src/lib/models/edges.ts b/libs/node-dist-vis/src/lib/models/edges.ts index 9d3bfc3c6..cfe330272 100644 --- a/libs/node-dist-vis/src/lib/models/edges.ts +++ b/libs/node-dist-vis/src/lib/models/edges.ts @@ -1,31 +1,30 @@ -export type EdgeEntry = [ - sourceNodeIndex: number, - x0: number, - y0: number, - z0: number, - x1: number, - y1: number, - z1: number, -]; +import { AnyDataEntry, createDataViewClass } from './data-view'; -/** Enum representing the indices of the elements in an EdgeEntry */ -export enum EdgeIndex { - SourceNode = 0, - x0, - y0, - z0, - x1, - y1, - z1, +export interface EdgeEntry { + 'Cell ID': number; + X1: number; + Y1: number; + Z1: number; + X2: number; + Y2: number; + Z2: number; } -/** Default maximum distance for edges */ -export const DEFAULT_MAX_EDGE_DISTANCE = 1000; +const EDGE_KEYS: (keyof EdgeEntry)[] = ['Cell ID', 'X1', 'Y1', 'Z1', 'X2', 'Y2', 'Z2']; +const BaseEdgesView = createDataViewClass(EDGE_KEYS); -/** Calculates the Euclidean distance between the two points defined by an EdgeEntry */ -export function edgeDistance(edge: EdgeEntry): number { - const xDiff = edge[EdgeIndex.x0] - edge[EdgeIndex.x1]; - const yDiff = edge[EdgeIndex.y0] - edge[EdgeIndex.y1]; - const zDiff = edge[EdgeIndex.z0] - edge[EdgeIndex.z1]; - return Math.hypot(xDiff, yDiff, zDiff); +export class EdgesView extends BaseEdgesView { + readonly getSourcePositionAt = (index: number) => this.getSourcePositionFor(this.data[index]); + readonly getSourcePositionFor = (obj: AnyDataEntry): [number, number, number] => [ + this.getX1For(obj), + this.getY1For(obj), + this.getZ1For(obj), + ]; + + readonly getTargetPositionAt = (index: number) => this.getTargetPositionFor(this.data[index]); + readonly getTargetPositionFor = (obj: AnyDataEntry): [number, number, number] => [ + this.getX2For(obj), + this.getY2For(obj), + this.getZ2For(obj), + ]; } diff --git a/libs/node-dist-vis/src/lib/models/nodes.ts b/libs/node-dist-vis/src/lib/models/nodes.ts index 6144efe5c..552ecef05 100644 --- a/libs/node-dist-vis/src/lib/models/nodes.ts +++ b/libs/node-dist-vis/src/lib/models/nodes.ts @@ -1,15 +1,41 @@ -declare const BRAND: unique symbol; -export type Brand = { [BRAND]: { [P in T]: true } }; -export type NodeTargetKey = string & Brand<'NodeTargetKey'>; +import { AnyDataEntry, createDataViewClass } from './data-view'; export interface NodeEntry { - /** X-coordinate of the node */ - x: number; - /** Y-coordinate of the node */ - y: number; - /** Optional Z-coordinate of the node */ - z?: number; - position?: [number, number, number]; - /** Dynamic property for node target values */ - [target: NodeTargetKey]: string; + 'Cell Type': string; + 'Cell Ontology ID'?: string; + X: number; + Y: number; + Z?: number; +} + +const NODE_KEYS: (keyof NodeEntry)[] = ['Cell Type', 'Cell Ontology ID', 'X', 'Y', 'Z']; +const BaseNodesView = createDataViewClass(NODE_KEYS); + +export class NodesView extends BaseNodesView { + readonly getPositionAt = (index: number) => this.getPositionFor(this.data[index]); + readonly getPositionFor = (obj: AnyDataEntry): [number, number, number] => [ + this.getXFor(obj), + this.getYFor(obj), + this.getZFor(obj) ?? 0, + ]; + + readonly getDimensions = (): [number, number] => { + if (this._dimensions) { + return this._dimensions; + } + + let min = Number.MAX_VALUE; + let max = -Number.MAX_VALUE; + for (const obj of this.data) { + const x = this.getXFor(obj); + const y = this.getYFor(obj); + const z = this.getZFor(obj) ?? 0; + min = Math.min(min, x, y, z); + max = Math.max(max, x, y, z); + } + + return (this._dimensions = [min, max]); + }; + + private _dimensions?: [number, number] = undefined; } diff --git a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts index 9468ab253..842df9324 100644 --- a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts +++ b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts @@ -1,3 +1,145 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + ElementRef, + ErrorHandler, + inject, + output, + signal, + untracked, + viewChild, +} from '@angular/core'; +import { DeckProps, OrbitView, PickingInfo } from '@deck.gl/core/typed'; +import { createDeck } from '../deckgl/deck'; +import { createEdgesLayer } from '../deckgl/layers/edges'; +import { createNodesLayer } from '../deckgl/layers/nodes'; +import { createScaleBarLayer } from '../deckgl/layers/scale-bar'; +import { ColorMapView } from '../models/color-map'; +import { AnyDataEntry } from '../models/data-view'; +import { EdgesView } from '../models/edges'; +import { NodesView } from '../models/nodes'; + +@Component({ + selector: 'hra-node-dist-vis', + standalone: true, + template: '', + styles: ':host { display: block; width: 100%; height: 100%; }', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NodeDistVisComponent { + readonly nodeClick = output(); + readonly nodeHover = output(); + + private readonly nodesView = signal( + new NodesView( + [ + { x: 659, y: 72, position: [659, 72, 0], type: 'T-Helper' }, + { x: 178, y: 73, position: [178, 73, 0], type: 'T-Helper' }, + { x: 170, y: 74, position: [170, 74, 0], type: 'T-Helper' }, + { x: 173, y: 75, position: [173, 75, 0], type: 'T-Helper' }, + { x: 174, y: 76, position: [174, 76, 0], type: 'T-Helper' }, + ], + { 'Cell Type': 'type', X: 'x', Y: 'y' }, + ), + ); + private readonly edgesView = signal( + new EdgesView( + [ + [0, 659, 72, 0, 630, 105, 5], + [1, 178, 73, 0, 177, 71, 2], + [2, 170, 74, 0, 166, 79, 2], + [3, 173, 74, 0, 177, 71, 2], + [4, 174, 75, 0, 177, 71, 2], + ], + { 'Cell ID': 0, X1: 1, Y1: 2, Z1: 3, X2: 4, Y2: 5, Z2: 6 }, + ), + ); + private readonly colorMapView = signal( + new ColorMapView([['T-Helper', [112, 165, 168]]], { 'Cell Type': 0, 'Cell Color': 1 }), + ); + private readonly selection = signal(undefined); + + private readonly canvas = viewChild.required>('canvas'); + private readonly errorHandler = inject(ErrorHandler); + + private viewStateVersionCounter = 0; + private readonly viewState = signal(this.getInitialViewState()); + private readonly viewSize = computed((): [number, number] => { + const { width, height } = this.canvas().nativeElement; + return [width, height]; + }); + + private readonly deckProps = computed( + (): DeckProps => ({ + canvas: this.canvas().nativeElement, + controller: true, + views: [new OrbitView({ orbitAxis: 'Y' })], + initialViewState: untracked(this.viewState), + layers: [], + getCursor: ({ isDragging, isHovering }) => this.getCursor(isDragging, isHovering), + onClick: (info) => this.onClick(info), + onHover: (info) => this.onHover(info), + onViewStateChange: ({ viewState }) => this.viewState.set(viewState), + onError: (error) => this.errorHandler.handleError(error), + }), + ); + private readonly deck = createDeck(this.deckProps); + + private readonly nodesLayer = createNodesLayer(this.nodesView, this.selection, this.colorMapView); + private readonly edgesLayer = createEdgesLayer(this.nodesView, this.edgesView, this.selection, this.colorMapView); + private readonly scaleBarLayer = createScaleBarLayer(this.nodesView, this.viewSize, this.viewState); + private readonly layers = computed(() => [this.nodesLayer(), this.edgesLayer(), this.scaleBarLayer()]); + + private activeHover: AnyDataEntry | undefined = undefined; + + constructor() { + effect(() => this.deck()?.setProps({ layers: this.layers() })); + inject(DestroyRef).onDestroy(() => this.deck()?.finalize()); + } + + private getInitialViewState() { + return { + version: this.viewStateVersionCounter++, + orbitAxis: 'Y', + camera: 'orbit', + zoom: 9, + minRotationX: -90, + maxRotationX: 90, + rotationX: 0, + rotationOrbit: 0, + dragMode: 'rotate', + target: [0.5, 0.5], + }; + } + + private getCursor(isDragging: boolean, isHovering: boolean): string { + if (isDragging) { + return 'grabbing'; + } else if (isHovering) { + return 'pointer'; + } else { + return 'grab'; + } + } + + private onClick(info: PickingInfo): void { + if (info.picked) { + this.nodeClick.emit(info.object); + } + } + + private onHover(info: PickingInfo): void { + const obj = info.picked ? info.object : undefined; + if (obj !== this.activeHover) { + this.nodeHover.emit(obj); + this.activeHover = obj; + } + } +} + // import { CommonModule } from '@angular/common'; // import { // ChangeDetectionStrategy, From 3c4cf48f33fe9e5a58498cbb8521d5fcb77b9259 Mon Sep 17 00:00:00 2001 From: Daniel Bolin Date: Thu, 17 Oct 2024 14:27:47 -0400 Subject: [PATCH 09/23] refactor: lint fix --- libs/node-dist-vis/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/node-dist-vis/package.json b/libs/node-dist-vis/package.json index f6dcc7eee..22115d9b0 100644 --- a/libs/node-dist-vis/package.json +++ b/libs/node-dist-vis/package.json @@ -7,12 +7,12 @@ "papaparse": "^5.4.1", "@hra-ui/utils": "0.0.1", "rxjs": "7.8.0", - "@angular/common": "18.2.1", "@deck.gl/carto": "~8.8.20", "@deck.gl/core": "~8.8.20", "@deck.gl/extensions": "~8.8.20", "@deck.gl/layers": "~8.8.20", - "@vivjs/layers": "0.16.1" + "@vivjs/layers": "0.16.1", + "ngxtension": "^3.5.5" }, "sideEffects": [ "index.ts" From a3cf153f048421fd203118d953278ca0ac7f3443 Mon Sep 17 00:00:00 2001 From: Daniel Bolin Date: Thu, 17 Oct 2024 15:29:33 -0400 Subject: [PATCH 10/23] refactor(node-dist-vis): Remove export --- .vscode/settings.json | 3 ++- libs/node-dist-vis/src/index.ts | 2 -- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index bdaa61ed7..10f58a3a5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,6 +13,7 @@ "ccf-organ-info", "design-system", "body-ui", - "eui" + "eui", + "node-dist-vis" ] } diff --git a/libs/node-dist-vis/src/index.ts b/libs/node-dist-vis/src/index.ts index 0050f7e7f..51354d3db 100644 --- a/libs/node-dist-vis/src/index.ts +++ b/libs/node-dist-vis/src/index.ts @@ -7,5 +7,3 @@ export * from './lib/node-dist-vis/node-dist-vis.component'; export const CdeVisualizationElement = createCustomElement('hra-node-dist-vis', NodeDistVisComponent, { providers: [], }); - -export * from './lib/deck-gl-visualization/deck-gl-visualization.component'; From 9f341bcdb64fd92a34961ca3be636a4a22abfa52 Mon Sep 17 00:00:00 2001 From: Daniel Bolin Date: Mon, 21 Oct 2024 11:00:36 -0400 Subject: [PATCH 11/23] feat(lib:common): Create library 'common' --- .vscode/settings.json | 3 +- libs/common/.eslintrc.json | 40 +++++++++++++++++++ libs/common/README.md | 7 ++++ libs/common/jest.config.ts | 22 ++++++++++ libs/common/ng-package.json | 7 ++++ libs/common/package.json | 9 +++++ libs/common/project.json | 36 +++++++++++++++++ libs/common/src/index.ts | 1 + .../src/lib/common/common.component.html | 1 + .../src/lib/common/common.component.scss | 3 ++ .../src/lib/common/common.component.spec.ts | 21 ++++++++++ .../common/src/lib/common/common.component.ts | 12 ++++++ libs/common/src/test-setup.ts | 8 ++++ libs/common/tsconfig.json | 28 +++++++++++++ libs/common/tsconfig.lib.json | 12 ++++++ libs/common/tsconfig.lib.prod.json | 9 +++++ libs/common/tsconfig.spec.json | 11 +++++ tsconfig.base.json | 3 ++ 18 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 libs/common/.eslintrc.json create mode 100644 libs/common/README.md create mode 100644 libs/common/jest.config.ts create mode 100644 libs/common/ng-package.json create mode 100644 libs/common/package.json create mode 100644 libs/common/project.json create mode 100644 libs/common/src/index.ts create mode 100644 libs/common/src/lib/common/common.component.html create mode 100644 libs/common/src/lib/common/common.component.scss create mode 100644 libs/common/src/lib/common/common.component.spec.ts create mode 100644 libs/common/src/lib/common/common.component.ts create mode 100644 libs/common/src/test-setup.ts create mode 100644 libs/common/tsconfig.json create mode 100644 libs/common/tsconfig.lib.json create mode 100644 libs/common/tsconfig.lib.prod.json create mode 100644 libs/common/tsconfig.spec.json diff --git a/.vscode/settings.json b/.vscode/settings.json index 10f58a3a5..c7a26ff35 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,6 +14,7 @@ "design-system", "body-ui", "eui", - "node-dist-vis" + "node-dist-vis", + "lib:common" ] } diff --git a/libs/common/.eslintrc.json b/libs/common/.eslintrc.json new file mode 100644 index 000000000..fc700f9b7 --- /dev/null +++ b/libs/common/.eslintrc.json @@ -0,0 +1,40 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "extends": ["plugin:@nx/angular", "plugin:@angular-eslint/template/process-inline-templates"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "hra", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "hra", + "style": "kebab-case" + } + ] + } + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": "error" + } + } + ] +} diff --git a/libs/common/README.md b/libs/common/README.md new file mode 100644 index 000000000..dd1a64c60 --- /dev/null +++ b/libs/common/README.md @@ -0,0 +1,7 @@ +# common + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test common` to execute the unit tests. diff --git a/libs/common/jest.config.ts b/libs/common/jest.config.ts new file mode 100644 index 000000000..ec6dbc855 --- /dev/null +++ b/libs/common/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'common', + preset: '../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../coverage/libs/common', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/common/ng-package.json b/libs/common/ng-package.json new file mode 100644 index 000000000..7889568f6 --- /dev/null +++ b/libs/common/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/libs/common", + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/common/package.json b/libs/common/package.json new file mode 100644 index 000000000..9152264d1 --- /dev/null +++ b/libs/common/package.json @@ -0,0 +1,9 @@ +{ + "name": "@hra-ui/common", + "version": "0.0.1", + "peerDependencies": { + "@angular/common": "^18.2.0", + "@angular/core": "^18.2.0" + }, + "sideEffects": false +} diff --git a/libs/common/project.json b/libs/common/project.json new file mode 100644 index 000000000..fa43f1169 --- /dev/null +++ b/libs/common/project.json @@ -0,0 +1,36 @@ +{ + "name": "common", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/common/src", + "prefix": "hra", + "projectType": "library", + "tags": ["type:lib"], + "targets": { + "build": { + "executor": "@nx/angular:package", + "outputs": ["{workspaceRoot}/dist/{projectRoot}"], + "options": { + "project": "libs/common/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "libs/common/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "libs/common/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/common/jest.config.ts" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libs/common/src/index.ts b/libs/common/src/index.ts new file mode 100644 index 000000000..65a09eae4 --- /dev/null +++ b/libs/common/src/index.ts @@ -0,0 +1 @@ +export * from './lib/common/common.component'; diff --git a/libs/common/src/lib/common/common.component.html b/libs/common/src/lib/common/common.component.html new file mode 100644 index 000000000..01517cf06 --- /dev/null +++ b/libs/common/src/lib/common/common.component.html @@ -0,0 +1 @@ +

common works!

diff --git a/libs/common/src/lib/common/common.component.scss b/libs/common/src/lib/common/common.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/libs/common/src/lib/common/common.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/libs/common/src/lib/common/common.component.spec.ts b/libs/common/src/lib/common/common.component.spec.ts new file mode 100644 index 000000000..2effb8621 --- /dev/null +++ b/libs/common/src/lib/common/common.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonComponent } from './common.component'; + +describe('CommonComponent', () => { + let component: CommonComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CommonComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(CommonComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/common/src/lib/common/common.component.ts b/libs/common/src/lib/common/common.component.ts new file mode 100644 index 000000000..664ffccd2 --- /dev/null +++ b/libs/common/src/lib/common/common.component.ts @@ -0,0 +1,12 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'hra-common', + standalone: true, + imports: [CommonModule], + templateUrl: './common.component.html', + styleUrl: './common.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CommonComponent {} diff --git a/libs/common/src/test-setup.ts b/libs/common/src/test-setup.ts new file mode 100644 index 000000000..ab1eeeb33 --- /dev/null +++ b/libs/common/src/test-setup.ts @@ -0,0 +1,8 @@ +// @ts-expect-error https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment +globalThis.ngJest = { + testEnvironmentOptions: { + errorOnUnknownElements: true, + errorOnUnknownProperties: true, + }, +}; +import 'jest-preset-angular/setup-jest'; diff --git a/libs/common/tsconfig.json b/libs/common/tsconfig.json new file mode 100644 index 000000000..56deb89f6 --- /dev/null +++ b/libs/common/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "es2022", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/common/tsconfig.lib.json b/libs/common/tsconfig.lib.json new file mode 100644 index 000000000..4cab05d46 --- /dev/null +++ b/libs/common/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": ["src/**/*.spec.ts", "src/test-setup.ts", "jest.config.ts", "src/**/*.test.ts"], + "include": ["src/**/*.ts"] +} diff --git a/libs/common/tsconfig.lib.prod.json b/libs/common/tsconfig.lib.prod.json new file mode 100644 index 000000000..2a2faa884 --- /dev/null +++ b/libs/common/tsconfig.lib.prod.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/libs/common/tsconfig.spec.json b/libs/common/tsconfig.spec.json new file mode 100644 index 000000000..7870b7c01 --- /dev/null +++ b/libs/common/tsconfig.spec.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 0a75fa38a..ee2a9fcd0 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -47,6 +47,9 @@ "@hra-ui/cdk/styling": [ "libs/cdk/styling/src/index.ts" ], + "@hra-ui/common": [ + "libs/common/src/index.ts" + ], "@hra-ui/components/atoms": [ "libs/components/atoms/src/index.ts" ], From 2f8f66ee6c11219ff05a5b63f9ba91a5ed55f74c Mon Sep 17 00:00:00 2001 From: Daniel Bolin Date: Mon, 21 Oct 2024 13:35:30 -0400 Subject: [PATCH 12/23] refactor(lib:common): Move file loaders into 'common/fs' --- .../file-upload/file-upload.component.ts | 2 +- .../create-visualization-page.component.ts | 3 +- apps/node-dist-vis-wc/project.json | 10 +- apps/node-dist-vis-wc/src/styles.scss | 4 + libs/cde-visualization/src/index.ts | 3 - .../cde-visualization.component.ts | 7 +- .../services/data/color-map-loader.service.ts | 4 +- .../lib/services/data/data-loader.service.ts | 2 +- libs/common/fs/README.md | 3 + .../fs}/ng-package.json | 0 libs/common/fs/src/index.ts | 3 + .../loaders}/csv-file-loader.service.spec.ts | 0 .../lib/loaders}/csv-file-loader.service.ts | 0 .../fs/src/lib/loaders}/file-loader.ts | 0 .../loaders}/json-file-loader.service.spec.ts | 0 .../lib/loaders}/json-file-loader.service.ts | 0 libs/common/src/index.ts | 2 +- libs/common/src/lib/.gitkeep | 0 .../src/lib/common/common.component.html | 1 - .../src/lib/common/common.component.scss | 3 - .../src/lib/common/common.component.spec.ts | 21 --- .../common/src/lib/common/common.component.ts | 12 -- libs/common/tsconfig.lib.json | 4 +- libs/shared/utils/file-loaders/README.md | 3 - libs/shared/utils/file-loaders/src/index.ts | 3 - .../src/lib/csv-file-loader.service.spec.ts | 160 ------------------ .../src/lib/csv-file-loader.service.ts | 123 -------------- .../utils/file-loaders/src/lib/file-loader.ts | 31 ---- .../src/lib/json-file-loader.service.spec.ts | 104 ------------ .../src/lib/json-file-loader.service.ts | 89 ---------- tsconfig.base.json | 6 +- 31 files changed, 28 insertions(+), 575 deletions(-) create mode 100644 libs/common/fs/README.md rename libs/{shared/utils/file-loaders => common/fs}/ng-package.json (100%) create mode 100644 libs/common/fs/src/index.ts rename libs/{cde-visualization/src/lib/services/file-loader => common/fs/src/lib/loaders}/csv-file-loader.service.spec.ts (100%) rename libs/{cde-visualization/src/lib/services/file-loader => common/fs/src/lib/loaders}/csv-file-loader.service.ts (100%) rename libs/{cde-visualization/src/lib/services/file-loader => common/fs/src/lib/loaders}/file-loader.ts (100%) rename libs/{cde-visualization/src/lib/services/file-loader => common/fs/src/lib/loaders}/json-file-loader.service.spec.ts (100%) rename libs/{cde-visualization/src/lib/services/file-loader => common/fs/src/lib/loaders}/json-file-loader.service.ts (100%) create mode 100644 libs/common/src/lib/.gitkeep delete mode 100644 libs/common/src/lib/common/common.component.html delete mode 100644 libs/common/src/lib/common/common.component.scss delete mode 100644 libs/common/src/lib/common/common.component.spec.ts delete mode 100644 libs/common/src/lib/common/common.component.ts delete mode 100644 libs/shared/utils/file-loaders/README.md delete mode 100644 libs/shared/utils/file-loaders/src/index.ts delete mode 100644 libs/shared/utils/file-loaders/src/lib/csv-file-loader.service.spec.ts delete mode 100644 libs/shared/utils/file-loaders/src/lib/csv-file-loader.service.ts delete mode 100644 libs/shared/utils/file-loaders/src/lib/file-loader.ts delete mode 100644 libs/shared/utils/file-loaders/src/lib/json-file-loader.service.spec.ts delete mode 100644 libs/shared/utils/file-loaders/src/lib/json-file-loader.service.ts diff --git a/apps/cde-ui/src/app/components/file-upload/file-upload.component.ts b/apps/cde-ui/src/app/components/file-upload/file-upload.component.ts index fbcdcf4c5..7410c4a62 100644 --- a/apps/cde-ui/src/app/components/file-upload/file-upload.component.ts +++ b/apps/cde-ui/src/app/components/file-upload/file-upload.component.ts @@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject, Injector, input, output, Type } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; -import { FileLoader, FileLoaderEvent } from '@hra-ui/cde-visualization'; +import { FileLoader, FileLoaderEvent } from '@hra-ui/common/fs'; import { reduce, Subscription } from 'rxjs'; /** diff --git a/apps/cde-ui/src/app/pages/create-visualization-page/create-visualization-page.component.ts b/apps/cde-ui/src/app/pages/create-visualization-page/create-visualization-page.component.ts index f70d013b5..0142eb7f6 100644 --- a/apps/cde-ui/src/app/pages/create-visualization-page/create-visualization-page.component.ts +++ b/apps/cde-ui/src/app/pages/create-visualization-page/create-visualization-page.component.ts @@ -12,8 +12,6 @@ import { Router, RouterModule } from '@angular/router'; import { ColorMapEntry, ColorMapFileLoaderService, - CsvFileLoaderOptions, - CsvFileLoaderService, DEFAULT_COLOR_MAP_KEY, DEFAULT_COLOR_MAP_VALUE_KEY, DEFAULT_NODE_TARGET_KEY, @@ -24,6 +22,7 @@ import { import { FooterComponent } from '@hra-ui/design-system/footer'; import { ParseError } from 'papaparse'; +import { CsvFileLoaderOptions, CsvFileLoaderService } from '@hra-ui/common/fs'; import { MarkEmptyFormControlDirective } from '../../components/empty-form-control/empty-form-control.directive'; import { FileLoadError, FileUploadComponent } from '../../components/file-upload/file-upload.component'; import { HeaderComponent } from '../../components/header/header.component'; diff --git a/apps/node-dist-vis-wc/project.json b/apps/node-dist-vis-wc/project.json index 9f6df8882..5475cd74c 100644 --- a/apps/node-dist-vis-wc/project.json +++ b/apps/node-dist-vis-wc/project.json @@ -23,23 +23,23 @@ } ], "styles": ["apps/node-dist-vis-wc/src/styles.scss"], - "scripts": [] + "scripts": [], + "outputHashing": "none" }, "configurations": { "production": { "budgets": [ { "type": "initial", - "maximumWarning": "500kb", - "maximumError": "1mb" + "maximumWarning": "1mb", + "maximumError": "2mb" }, { "type": "anyComponentStyle", "maximumWarning": "2kb", "maximumError": "4kb" } - ], - "outputHashing": "all" + ] }, "development": { "optimization": false, diff --git a/apps/node-dist-vis-wc/src/styles.scss b/apps/node-dist-vis-wc/src/styles.scss index 90d4ee007..c40bb56ae 100644 --- a/apps/node-dist-vis-wc/src/styles.scss +++ b/apps/node-dist-vis-wc/src/styles.scss @@ -1 +1,5 @@ /* You can add global styles to this file, and also import other style files */ + +body { + margin: 0; +} diff --git a/libs/cde-visualization/src/index.ts b/libs/cde-visualization/src/index.ts index 7c8c4622e..3220617e2 100644 --- a/libs/cde-visualization/src/index.ts +++ b/libs/cde-visualization/src/index.ts @@ -15,9 +15,6 @@ export * from './lib/shared/tooltip-position'; // TODO: Move these exports into a separate library export * from './lib/services/data/color-map-loader.service'; -export * from './lib/services/file-loader/csv-file-loader.service'; -export * from './lib/services/file-loader/file-loader'; -export * from './lib/services/file-loader/json-file-loader.service'; /** Type for CdeVisualizationElement instance */ export type CdeVisualizationElement = InstanceType; diff --git a/libs/cde-visualization/src/lib/cde-visualization/cde-visualization.component.ts b/libs/cde-visualization/src/lib/cde-visualization/cde-visualization.component.ts index 02cf471b1..32ef3bc36 100644 --- a/libs/cde-visualization/src/lib/cde-visualization/cde-visualization.component.ts +++ b/libs/cde-visualization/src/lib/cde-visualization/cde-visualization.component.ts @@ -12,28 +12,27 @@ import { signal, ViewContainerRef, } from '@angular/core'; +import { CsvFileLoaderService, JsonFileLoaderService } from '@hra-ui/common/fs'; +import { rgbToHex } from '@hra-ui/design-system/color-picker'; import { CellTypesComponent } from '../components/cell-types/cell-types.component'; import { HistogramComponent } from '../components/histogram/histogram.component'; import { MetadataComponent } from '../components/metadata/metadata.component'; import { NodeDistVisualizationComponent } from '../components/node-dist-visualization/node-dist-visualization.component'; import { VisualizationHeaderComponent } from '../components/visualization-header/visualization-header.component'; import { CellTypeEntry } from '../models/cell-type'; -import { rgbToHex } from '@hra-ui/design-system/color-picker'; import { ColorMapColorKey, ColorMapEntry, + colorMapToLookup, ColorMapTypeKey, DEFAULT_COLOR_MAP_KEY, DEFAULT_COLOR_MAP_VALUE_KEY, - colorMapToLookup, } from '../models/color-map'; import { DEFAULT_MAX_EDGE_DISTANCE, EdgeEntry } from '../models/edge'; import { Metadata } from '../models/metadata'; import { DEFAULT_NODE_TARGET_KEY, NodeEntry, NodeTargetKey, selectNodeTargetValue } from '../models/node'; import { ColorMapFileLoaderService } from '../services/data/color-map-loader.service'; import { DataLoaderService } from '../services/data/data-loader.service'; -import { CsvFileLoaderService } from '../services/file-loader/csv-file-loader.service'; -import { JsonFileLoaderService } from '../services/file-loader/json-file-loader.service'; import { FileSaverService } from '../services/file-saver/file-saver.service'; import { brandAttribute, numberAttribute } from '../shared/attribute-transform'; import { createColorGenerator } from '../shared/color-generator'; diff --git a/libs/cde-visualization/src/lib/services/data/color-map-loader.service.ts b/libs/cde-visualization/src/lib/services/data/color-map-loader.service.ts index 33703d4ed..cf6566605 100644 --- a/libs/cde-visualization/src/lib/services/data/color-map-loader.service.ts +++ b/libs/cde-visualization/src/lib/services/data/color-map-loader.service.ts @@ -1,9 +1,7 @@ import { inject, Injectable } from '@angular/core'; +import { CsvFileLoaderOptions, CsvFileLoaderService, FileLoader, FileLoaderEvent } from '@hra-ui/common/fs'; import { map, Observable } from 'rxjs'; - import { ColorMapEntry } from '../../models/color-map'; -import { CsvFileLoaderOptions, CsvFileLoaderService } from '../file-loader/csv-file-loader.service'; -import { FileLoader, FileLoaderEvent } from '../file-loader/file-loader'; /** Service to load color map entries from CSV files */ @Injectable({ diff --git a/libs/cde-visualization/src/lib/services/data/data-loader.service.ts b/libs/cde-visualization/src/lib/services/data/data-loader.service.ts index d57b8dcfa..3b46bb3c4 100644 --- a/libs/cde-visualization/src/lib/services/data/data-loader.service.ts +++ b/libs/cde-visualization/src/lib/services/data/data-loader.service.ts @@ -1,8 +1,8 @@ import { Injectable, Injector, Signal, Type, inject, runInInjectionContext } from '@angular/core'; import { ToSignalOptions, toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { FileLoader, FileLoaderDataEvent, FileLoaderOptions } from '@hra-ui/common/fs'; import { Observable, filter, map, of, switchAll, takeLast } from 'rxjs'; import { createAbsoluteUrl } from '../../shared/url-normalization'; -import { FileLoader, FileLoaderDataEvent, FileLoaderOptions } from '../file-loader/file-loader'; /** Service for loading data using specified loaders */ @Injectable({ diff --git a/libs/common/fs/README.md b/libs/common/fs/README.md new file mode 100644 index 000000000..253f24c19 --- /dev/null +++ b/libs/common/fs/README.md @@ -0,0 +1,3 @@ +# @hra-ui/common/fs + +Secondary entry point of `@hra-ui/common`. It can be used by importing from `@hra-ui/common/fs`. diff --git a/libs/shared/utils/file-loaders/ng-package.json b/libs/common/fs/ng-package.json similarity index 100% rename from libs/shared/utils/file-loaders/ng-package.json rename to libs/common/fs/ng-package.json diff --git a/libs/common/fs/src/index.ts b/libs/common/fs/src/index.ts new file mode 100644 index 000000000..925e3a1e7 --- /dev/null +++ b/libs/common/fs/src/index.ts @@ -0,0 +1,3 @@ +export * from './lib/loaders/csv-file-loader.service'; +export * from './lib/loaders/file-loader'; +export * from './lib/loaders/json-file-loader.service'; diff --git a/libs/cde-visualization/src/lib/services/file-loader/csv-file-loader.service.spec.ts b/libs/common/fs/src/lib/loaders/csv-file-loader.service.spec.ts similarity index 100% rename from libs/cde-visualization/src/lib/services/file-loader/csv-file-loader.service.spec.ts rename to libs/common/fs/src/lib/loaders/csv-file-loader.service.spec.ts diff --git a/libs/cde-visualization/src/lib/services/file-loader/csv-file-loader.service.ts b/libs/common/fs/src/lib/loaders/csv-file-loader.service.ts similarity index 100% rename from libs/cde-visualization/src/lib/services/file-loader/csv-file-loader.service.ts rename to libs/common/fs/src/lib/loaders/csv-file-loader.service.ts diff --git a/libs/cde-visualization/src/lib/services/file-loader/file-loader.ts b/libs/common/fs/src/lib/loaders/file-loader.ts similarity index 100% rename from libs/cde-visualization/src/lib/services/file-loader/file-loader.ts rename to libs/common/fs/src/lib/loaders/file-loader.ts diff --git a/libs/cde-visualization/src/lib/services/file-loader/json-file-loader.service.spec.ts b/libs/common/fs/src/lib/loaders/json-file-loader.service.spec.ts similarity index 100% rename from libs/cde-visualization/src/lib/services/file-loader/json-file-loader.service.spec.ts rename to libs/common/fs/src/lib/loaders/json-file-loader.service.spec.ts diff --git a/libs/cde-visualization/src/lib/services/file-loader/json-file-loader.service.ts b/libs/common/fs/src/lib/loaders/json-file-loader.service.ts similarity index 100% rename from libs/cde-visualization/src/lib/services/file-loader/json-file-loader.service.ts rename to libs/common/fs/src/lib/loaders/json-file-loader.service.ts diff --git a/libs/common/src/index.ts b/libs/common/src/index.ts index 65a09eae4..3c7f24a30 100644 --- a/libs/common/src/index.ts +++ b/libs/common/src/index.ts @@ -1 +1 @@ -export * from './lib/common/common.component'; +export const placeholder = ''; diff --git a/libs/common/src/lib/.gitkeep b/libs/common/src/lib/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/libs/common/src/lib/common/common.component.html b/libs/common/src/lib/common/common.component.html deleted file mode 100644 index 01517cf06..000000000 --- a/libs/common/src/lib/common/common.component.html +++ /dev/null @@ -1 +0,0 @@ -

common works!

diff --git a/libs/common/src/lib/common/common.component.scss b/libs/common/src/lib/common/common.component.scss deleted file mode 100644 index 5d4e87f30..000000000 --- a/libs/common/src/lib/common/common.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -:host { - display: block; -} diff --git a/libs/common/src/lib/common/common.component.spec.ts b/libs/common/src/lib/common/common.component.spec.ts deleted file mode 100644 index 2effb8621..000000000 --- a/libs/common/src/lib/common/common.component.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { CommonComponent } from './common.component'; - -describe('CommonComponent', () => { - let component: CommonComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [CommonComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(CommonComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/libs/common/src/lib/common/common.component.ts b/libs/common/src/lib/common/common.component.ts deleted file mode 100644 index 664ffccd2..000000000 --- a/libs/common/src/lib/common/common.component.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -@Component({ - selector: 'hra-common', - standalone: true, - imports: [CommonModule], - templateUrl: './common.component.html', - styleUrl: './common.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class CommonComponent {} diff --git a/libs/common/tsconfig.lib.json b/libs/common/tsconfig.lib.json index 4cab05d46..b55f7284a 100644 --- a/libs/common/tsconfig.lib.json +++ b/libs/common/tsconfig.lib.json @@ -7,6 +7,6 @@ "inlineSources": true, "types": [] }, - "exclude": ["src/**/*.spec.ts", "src/test-setup.ts", "jest.config.ts", "src/**/*.test.ts"], - "include": ["src/**/*.ts"] + "exclude": ["**/*.spec.ts", "test-setup.ts", "jest.config.ts", "**/*.test.ts"], + "include": ["**/*.ts"] } diff --git a/libs/shared/utils/file-loaders/README.md b/libs/shared/utils/file-loaders/README.md deleted file mode 100644 index 7fc84e18d..000000000 --- a/libs/shared/utils/file-loaders/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# @hra-ui/utils/file-loaders - -Secondary entry point of `@hra-ui/utils`. It can be used by importing from `@hra-ui/utils/file-loaders`. diff --git a/libs/shared/utils/file-loaders/src/index.ts b/libs/shared/utils/file-loaders/src/index.ts deleted file mode 100644 index 1be66a56c..000000000 --- a/libs/shared/utils/file-loaders/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './lib/csv-file-loader.service'; -export * from './lib/file-loader'; -export * from './lib/json-file-loader.service'; diff --git a/libs/shared/utils/file-loaders/src/lib/csv-file-loader.service.spec.ts b/libs/shared/utils/file-loaders/src/lib/csv-file-loader.service.spec.ts deleted file mode 100644 index e79227080..000000000 --- a/libs/shared/utils/file-loaders/src/lib/csv-file-loader.service.spec.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { mock } from 'jest-mock-extended'; -import { ParseError, ParseLocalConfig, ParseMeta, ParseResult, Parser, parse } from 'papaparse'; -import { Observable, firstValueFrom, toArray } from 'rxjs'; -import { CsvFileLoaderService } from './csv-file-loader.service'; -import { FileLoaderEvent } from './file-loader'; - -jest.mock('papaparse', () => ({ - parse: jest.fn(), - LocalChunkSize: 100, - RemoteChunkSize: 200, -})); - -describe('CsvFileLoaderService', () => { - const url = 'https://example.com'; - const data = [ - { a: 1, b: 2 }, - { a: 2, b: 3 }, - ]; - const meta: ParseMeta = { - aborted: false, - cursor: 0, - delimiter: ',', - linebreak: '\n', - truncated: false, - }; - const chunkResult: ParseResult = { - data, - meta, - errors: [], - }; - const completeResult: ParseResult = { - data: [], - errors: [], - meta, - }; - const dataEvent: FileLoaderEvent = { - type: 'data', - data: data, - }; - const parser = mock(); - let service: CsvFileLoaderService; - - async function getEvents(source: Observable>): Promise[]> { - return firstValueFrom(source.pipe(toArray())); - } - - function getConfig(): ParseLocalConfig { - return jest.mocked(parse).mock.calls[0][1] as ParseLocalConfig; - } - - beforeEach(() => { - service = TestBed.inject(CsvFileLoaderService); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('loads a local file', async () => { - const file = { size: 20 } as File; - const result$ = service.load(file, {}); - const eventsPromise = getEvents(result$); - expect(parse).toHaveBeenCalledWith(file, expect.anything()); - - const config = getConfig(); - config.chunk?.(chunkResult, parser); - config.complete?.(completeResult, undefined); - - expect(await eventsPromise).toEqual([ - { - type: 'progress', - loaded: 20, - total: 20, - }, - dataEvent, - ]); - }); - - it('loads a remote file', async () => { - const result$ = service.load(url, {}); - const eventsPromise = getEvents(result$); - expect(parse).toHaveBeenCalledWith(url, expect.anything()); - - const config = getConfig(); - config.chunk?.(chunkResult, parser); - config.complete?.(completeResult, undefined); - - expect(await eventsPromise).toEqual([ - { - type: 'progress', - loaded: 200, - }, - dataEvent, - ]); - }); - - it('emits multiple data events when not in collect mode', async () => { - const result$ = service.load(url, { collect: false }); - const eventsPromise = getEvents(result$); - expect(parse).toHaveBeenCalledWith(url, expect.anything()); - - const config = getConfig(); - config.chunk?.(chunkResult, parser); - config.chunk?.(chunkResult, parser); - config.complete?.(completeResult, undefined); - - expect(await eventsPromise).toEqual([ - { - type: 'progress', - loaded: 200, - }, - dataEvent, - { - type: 'progress', - loaded: 400, - }, - dataEvent, - ]); - }); - - it('aborts the loading when there are no observers', async () => { - const result$ = service.load(url, {}); - result$.subscribe().unsubscribe(); - - const config = getConfig(); - config.chunk?.(chunkResult, parser); - expect(parser.abort).toHaveBeenCalled(); - }); - - it('aborts the loading when there are too many errors', async () => { - const errors: ParseError[] = [{ code: 'TooFewFields', message: '', type: 'FieldMismatch' }]; - const result$ = service.load(url, { errorTolerance: 0 }); - const eventsPromise = getEvents(result$); - - const config = getConfig(); - config.chunk?.( - { - data, - errors, - meta, - }, - parser, - ); - - expect(parser.abort).toHaveBeenCalled(); - expect(eventsPromise).rejects.toEqual(errors); - }); - - it('forwards other errors to the subscriber', async () => { - const error = new Error('some other error'); - const result$ = service.load(url, { errorTolerance: 0 }); - const eventsPromise = getEvents(result$); - - const config = getConfig(); - config.error?.(error, undefined); - - expect(eventsPromise).rejects.toEqual(error); - }); -}); diff --git a/libs/shared/utils/file-loaders/src/lib/csv-file-loader.service.ts b/libs/shared/utils/file-loaders/src/lib/csv-file-loader.service.ts deleted file mode 100644 index 09a924db8..000000000 --- a/libs/shared/utils/file-loaders/src/lib/csv-file-loader.service.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { Injectable } from '@angular/core'; -import { LocalChunkSize, ParseError, ParseLocalConfig, ParseRemoteConfig, RemoteChunkSize, parse } from 'papaparse'; -import { Observable, Subject, defer } from 'rxjs'; -import { FileLoader, FileLoaderEvent } from './file-loader'; - -/** Any function type */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type AnyFunction = (...args: any[]) => any; - -/** Configuration keys that are either overridden or functions that can't be sent to a worker */ -type ReservedPapaparseConfigKeys = - | 'transformHeader' - | 'transform' - | 'dynamicTyping' - | 'worker' - | 'download' - | 'beforeFirstChunk' - | 'step' - | 'chunk' - | 'complete' - | 'error'; - -/** Properties picked from remote configuration */ -type RemoteRequestKeys = 'downloadRequestHeaders' | 'downloadRequestBody' | 'withCredentials'; - -/** Dynamic typing option but without the ability to pass a function */ -type DynamicTyping = { dynamicTyping?: Exclude }; - -/** Additional options for loading from URLs */ -type RemoteRequest = Pick; - -/** Accepted papaparse configuration subset */ -export type PapaparseConfig = Omit & DynamicTyping & RemoteRequest; - -/** Csv file loader options */ -export interface CsvFileLoaderOptions { - /** Whether to collect the results into a single data event or emit multiple events */ - collect?: boolean; - /** Number of parsing errors that can happen before the load aborts */ - errorTolerance?: false | number; - /** Additional papaparse configuration */ - papaparse?: PapaparseConfig; -} - -/** Appends items to an array */ -function arrayAppend(array: T[], items: T[]): void { - for (const item of items) { - array.push(item); - } -} - -/** Service for loading CSV files */ -@Injectable({ - providedIn: 'root', -}) -export class CsvFileLoaderService implements FileLoader { - /** Loads a CSV file and returns an observable of the loader events */ - load(file: string | File, options: CsvFileLoaderOptions): Observable> { - return defer(() => this.loadImpl(file, options)); - } - - /** Implementation of the CSV file loading logic */ - private loadImpl(file: string | File, options: CsvFileLoaderOptions): Observable> { - const isLocalFile = typeof file === 'object'; - const fileSize = isLocalFile ? file.size : undefined; - const defaultChunkSize = isLocalFile ? LocalChunkSize : RemoteChunkSize; - const { collect = true, errorTolerance = false, papaparse = {} } = options; - const { chunkSize = defaultChunkSize } = papaparse; - const data: DataT[] = []; - const errors: ParseError[] = []; - const subject = new Subject>(); - let chunkProcessed = 0; - - parse( - file as never, - { - skipEmptyLines: 'greedy', - ...papaparse, - worker: true, - download: !isLocalFile, - chunk(results, parser) { - if (!subject.observed) { - parser.abort(); - return; - } - - if (errorTolerance !== false) { - arrayAppend(errors, results.errors); - if (errors.length > errorTolerance) { - subject.error(errors); - parser.abort(); - return; - } - } - - chunkProcessed += 1; - subject.next({ - type: 'progress', - loaded: Math.min(chunkProcessed * chunkSize, fileSize ?? Infinity), - total: fileSize, - }); - - if (collect) { - arrayAppend(data, results.data); - } else { - subject.next({ type: 'data', data: results.data }); - } - }, - complete() { - if (collect) { - subject.next({ type: 'data', data }); - } - subject.complete(); - }, - error(error) { - subject.error(error); - }, - } as ParseLocalConfig, - ); - - return subject; - } -} diff --git a/libs/shared/utils/file-loaders/src/lib/file-loader.ts b/libs/shared/utils/file-loaders/src/lib/file-loader.ts deleted file mode 100644 index 2b96c51c6..000000000 --- a/libs/shared/utils/file-loaders/src/lib/file-loader.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Observable } from 'rxjs'; - -/** Event type for data loading, containing the loaded data */ -export interface FileLoaderDataEvent { - /** Indicates this is a data event */ - type: 'data'; - /** The loaded data of type DataT */ - data: DataT; -} - -/** Event type for progress updates during file loading */ -export interface FileLoaderProgressEvent { - /** Indicates this is a progress event */ - type: 'progress'; - /** Number of bytes loaded */ - loaded: number; - /** Total number of bytes */ - total?: number; -} - -/** Union type for file loader events, can be either data or progress */ -export type FileLoaderEvent = FileLoaderDataEvent | FileLoaderProgressEvent; - -/** Extracts options type from a FileLoader based on the provided LoaderT */ -export type FileLoaderOptions = LoaderT extends FileLoader ? OptionsT : never; - -/** Interface for file loader that defines the load method */ -export interface FileLoader { - /** Loads a file and returns an observable of file loader events */ - load(file: string | File, options: OptionsT): Observable>; -} diff --git a/libs/shared/utils/file-loaders/src/lib/json-file-loader.service.spec.ts b/libs/shared/utils/file-loaders/src/lib/json-file-loader.service.spec.ts deleted file mode 100644 index c0201ad0e..000000000 --- a/libs/shared/utils/file-loaders/src/lib/json-file-loader.service.spec.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { HttpEventType, provideHttpClient } from '@angular/common/http'; -import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { TestBed } from '@angular/core/testing'; -import { mock } from 'jest-mock-extended'; -import { Observable, firstValueFrom, toArray } from 'rxjs'; -import { FileLoaderEvent } from './file-loader'; -import { JsonFileLoaderService } from './json-file-loader.service'; - -describe('JsonFileLoaderService', () => { - const url = 'https://example.com'; - const data = { a: 1, b: 2 }; - const serializedData = JSON.stringify(data); - const size = serializedData.length; - - async function getEvents(source: Observable>): Promise[]> { - return firstValueFrom(source.pipe(toArray())); - } - - it('loads a local file', async () => { - const file = mock({ - size: serializedData.length, - text: () => Promise.resolve(serializedData), - }); - const service = TestBed.inject(JsonFileLoaderService); - const result$ = service.load(file, {}); - const events = await getEvents(result$); - - expect(events).toEqual([ - { - type: 'progress', - loaded: 0, - total: file.size, - }, - { - type: 'progress', - loaded: file.size, - total: file.size, - }, - { - type: 'data', - data: data, - }, - ]); - }); - - it('loads from an url', async () => { - TestBed.configureTestingModule({ - providers: [provideHttpClient(), provideHttpClientTesting()], - }); - - const service = TestBed.inject(JsonFileLoaderService); - const result$ = service.load(url, {}); - const eventsPromise = getEvents(result$); - const http = TestBed.inject(HttpTestingController); - const request = http.expectOne(url); - - request.event({ - type: HttpEventType.DownloadProgress, - loaded: size, - total: size, - }); - request.event({ - type: HttpEventType.User, - }); - request.flush(data); - - expect(await eventsPromise).toEqual([ - { - type: 'progress', - loaded: 0, - }, - { - type: 'progress', - loaded: size, - total: size, - }, - { - type: 'data', - data: data, - }, - ]); - }); - - it('throws an error if HttpClient in not available', async () => { - const service = TestBed.inject(JsonFileLoaderService); - const result$ = service.load(url, {}); - expect(getEvents(result$)).rejects.toMatch(/HttpClient/); - }); - - it("throws if the response can't be parsed", async () => { - TestBed.configureTestingModule({ - providers: [provideHttpClient(), provideHttpClientTesting()], - }); - - const service = TestBed.inject(JsonFileLoaderService); - const result$ = service.load(url, {}); - const eventsPromise = getEvents(result$); - const http = TestBed.inject(HttpTestingController); - const request = http.expectOne(url); - - request.flush(null); - expect(eventsPromise).rejects.toMatch(/parse/); - }); -}); diff --git a/libs/shared/utils/file-loaders/src/lib/json-file-loader.service.ts b/libs/shared/utils/file-loaders/src/lib/json-file-loader.service.ts deleted file mode 100644 index 63472d3e8..000000000 --- a/libs/shared/utils/file-loaders/src/lib/json-file-loader.service.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { HttpClient, HttpEvent, HttpEventType } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; -import { Observable, concatMap, defer, filter, from, map, of, startWith } from 'rxjs'; -import { FileLoader, FileLoaderEvent, FileLoaderProgressEvent } from './file-loader'; - -/** Options for loading JSON files */ -export type JsonFileLoaderOptions = Record; - -/** Service for loading JSON files, either locally or remotely */ -@Injectable({ - providedIn: 'root', -}) -export class JsonFileLoaderService implements FileLoader { - /** Reference to the HTTP client */ - private readonly http = inject(HttpClient, { optional: true }); - - /** Loads a JSON file and returns an observable of file loader events */ - load(file: string | File, options: JsonFileLoaderOptions): Observable> { - return defer(() => this.loadImpl(file, options)); - } - - /** Implementation of the load method, handling local and remote files */ - private loadImpl(file: string | File, _options: JsonFileLoaderOptions): Observable> { - if (typeof file === 'object') { - return this.loadLocalFile(file); - } else { - return this.loadRemoteFile(file); - } - } - - /** Loads a local JSON file and emits progress and data events */ - private loadLocalFile(file: File): Observable> { - const fileSize = file.size; - const progressStart: FileLoaderProgressEvent = { - type: 'progress', - loaded: 0, - total: fileSize, - }; - const progressEnd: FileLoaderProgressEvent = { - type: 'progress', - loaded: fileSize, - total: fileSize, - }; - - return from(file.text()).pipe( - map((text): FileLoaderEvent => ({ type: 'data', data: JSON.parse(text) })), - concatMap((dataEvent) => of(progressEnd, dataEvent)), - startWith(progressStart), - ); - } - - /** Loads a remote JSON file and emits progress and data events */ - private loadRemoteFile(file: string): Observable> { - const { http } = this; - if (!http) { - throw new Error('HttpClient is required to load remote json files'); - } - - const event$ = http.get(file, { - responseType: 'json', - observe: 'events', - }); - - return event$.pipe( - map((event) => this.httpEventToFileLoaderEvent(event)), - filter((event): event is FileLoaderEvent => event !== undefined), - ); - } - - /** Converts HTTP events to file loader events */ - private httpEventToFileLoaderEvent(event: HttpEvent): FileLoaderEvent | undefined { - switch (event.type) { - case HttpEventType.Sent: - return { type: 'progress', loaded: 0 }; - - case HttpEventType.DownloadProgress: - return { type: 'progress', loaded: event.loaded, total: event.total }; - - case HttpEventType.Response: - if (!event.body) { - throw new Error('Could not parse response as json'); - } - return { type: 'data', data: event.body }; - - default: - return undefined; - } - } -} diff --git a/tsconfig.base.json b/tsconfig.base.json index ee2a9fcd0..139497636 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -50,6 +50,9 @@ "@hra-ui/common": [ "libs/common/src/index.ts" ], + "@hra-ui/common/fs": [ + "libs/common/fs/src/index.ts" + ], "@hra-ui/components/atoms": [ "libs/components/atoms/src/index.ts" ], @@ -155,9 +158,6 @@ "@hra-ui/utils": [ "libs/shared/utils/src/index.ts" ], - "@hra-ui/utils/file-loaders": [ - "libs/shared/utils/file-loaders/src/index.ts" - ], "@hra-ui/utils/testing": [ "libs/shared/testing/src/index.ts" ], From c055cdae5ec9c702ed500e63c2c0e7825ee9fee4 Mon Sep 17 00:00:00 2001 From: Daniel Bolin Date: Tue, 22 Oct 2024 12:48:16 -0400 Subject: [PATCH 13/23] refactor(node-dist-vis): Implement data loading --- libs/node-dist-vis/package.json | 4 +- libs/node-dist-vis/src/lib/deckgl/deck.ts | 23 +- .../src/lib/deckgl/{layers => }/edges.ts | 10 +- .../src/lib/deckgl/{layers => }/nodes.ts | 8 +- .../src/lib/deckgl/{layers => }/scale-bar.ts | 9 +- .../deckgl/{layers => }/utils/color-coding.ts | 2 +- .../{layers => }/utils/position-scaling.ts | 0 .../{layers => }/utils/selection-filter.ts | 0 .../node-dist-vis/src/lib/models/color-map.ts | 48 +- .../node-dist-vis/src/lib/models/data-view.ts | 188 ++++++- libs/node-dist-vis/src/lib/models/edges.ts | 74 ++- libs/node-dist-vis/src/lib/models/filters.ts | 4 + libs/node-dist-vis/src/lib/models/nodes.ts | 63 ++- libs/node-dist-vis/src/lib/models/utils.ts | 11 + .../node-dist-vis/node-dist-vis.component.ts | 513 ++++-------------- 15 files changed, 495 insertions(+), 462 deletions(-) rename libs/node-dist-vis/src/lib/deckgl/{layers => }/edges.ts (90%) rename libs/node-dist-vis/src/lib/deckgl/{layers => }/nodes.ts (91%) rename libs/node-dist-vis/src/lib/deckgl/{layers => }/scale-bar.ts (79%) rename libs/node-dist-vis/src/lib/deckgl/{layers => }/utils/color-coding.ts (93%) rename libs/node-dist-vis/src/lib/deckgl/{layers => }/utils/position-scaling.ts (100%) rename libs/node-dist-vis/src/lib/deckgl/{layers => }/utils/selection-filter.ts (100%) create mode 100644 libs/node-dist-vis/src/lib/models/filters.ts create mode 100644 libs/node-dist-vis/src/lib/models/utils.ts diff --git a/libs/node-dist-vis/package.json b/libs/node-dist-vis/package.json index 22115d9b0..6febaa8c3 100644 --- a/libs/node-dist-vis/package.json +++ b/libs/node-dist-vis/package.json @@ -5,14 +5,14 @@ "@angular/core": "^18.2.0", "@hra-ui/webcomponents": "0.0.1", "papaparse": "^5.4.1", - "@hra-ui/utils": "0.0.1", "rxjs": "7.8.0", "@deck.gl/carto": "~8.8.20", "@deck.gl/core": "~8.8.20", "@deck.gl/extensions": "~8.8.20", "@deck.gl/layers": "~8.8.20", "@vivjs/layers": "0.16.1", - "ngxtension": "^3.5.5" + "ngxtension": "^3.5.5", + "@hra-ui/common": "0.0.1" }, "sideEffects": [ "index.ts" diff --git a/libs/node-dist-vis/src/lib/deckgl/deck.ts b/libs/node-dist-vis/src/lib/deckgl/deck.ts index ca6d2b5bc..0298b2a8d 100644 --- a/libs/node-dist-vis/src/lib/deckgl/deck.ts +++ b/libs/node-dist-vis/src/lib/deckgl/deck.ts @@ -1,15 +1,18 @@ -import { Signal } from '@angular/core'; +import { computed, effect, Signal } from '@angular/core'; import { Deck, DeckProps } from '@deck.gl/core/typed'; -import { derivedAsync } from 'ngxtension/derived-async'; -export function createDeck(props: Signal): Signal { - return derivedAsync((previous) => { - previous?.finalize(); - return new Promise((resolve) => { - const deck = new Deck({ - ...props(), - onLoad: () => resolve(deck), - }); +export function createDeck(canvas: Signal, props: DeckProps): Signal { + const deck = computed(() => { + return new Deck({ + canvas: canvas(), + ...props, }); }); + + effect((onCleanup) => { + const instance = deck(); + onCleanup(() => instance.finalize()); + }); + + return deck; } diff --git a/libs/node-dist-vis/src/lib/deckgl/layers/edges.ts b/libs/node-dist-vis/src/lib/deckgl/edges.ts similarity index 90% rename from libs/node-dist-vis/src/lib/deckgl/layers/edges.ts rename to libs/node-dist-vis/src/lib/deckgl/edges.ts index d0d24d0ee..52c841d05 100644 --- a/libs/node-dist-vis/src/lib/deckgl/layers/edges.ts +++ b/libs/node-dist-vis/src/lib/deckgl/edges.ts @@ -2,10 +2,10 @@ import { computed, Signal } from '@angular/core'; import { COORDINATE_SYSTEM } from '@deck.gl/core/typed'; import { DataFilterExtension, DataFilterExtensionProps } from '@deck.gl/extensions/typed'; import { LineLayer } from '@deck.gl/layers/typed'; -import { ColorMapView } from '../../models/color-map'; -import { AnyData, AnyDataEntry } from '../../models/data-view'; -import { EdgesView } from '../../models/edges'; -import { NodesView } from '../../models/nodes'; +import { ColorMapView } from '../models/color-map'; +import { AnyData, AnyDataEntry } from '../models/data-view'; +import { EdgesView } from '../models/edges'; +import { NodesView } from '../models/nodes'; import { createColorAccessor } from './utils/color-coding'; import { createScaledPositionAccessor } from './utils/position-scaling'; import { createSelectionFilterAccessor, FILTER_RANGE } from './utils/selection-filter'; @@ -44,7 +44,7 @@ export function createEdgesLayer( return computed(() => { return new LineLayer({ id: 'edges', - data: edges().data, + data: edges(), getSourcePosition: sourcePositionAccessor(), getTargetPosition: targetPositionAccessor(), getColor: colorAccessor(), diff --git a/libs/node-dist-vis/src/lib/deckgl/layers/nodes.ts b/libs/node-dist-vis/src/lib/deckgl/nodes.ts similarity index 91% rename from libs/node-dist-vis/src/lib/deckgl/layers/nodes.ts rename to libs/node-dist-vis/src/lib/deckgl/nodes.ts index 91c0e0e93..9afaa03e2 100644 --- a/libs/node-dist-vis/src/lib/deckgl/layers/nodes.ts +++ b/libs/node-dist-vis/src/lib/deckgl/nodes.ts @@ -2,9 +2,9 @@ import { computed, Signal } from '@angular/core'; import { COORDINATE_SYSTEM } from '@deck.gl/core/typed'; import { DataFilterExtension, DataFilterExtensionProps } from '@deck.gl/extensions/typed'; import { PointCloudLayer } from '@deck.gl/layers/typed'; -import { ColorMapView } from '../../models/color-map'; -import { AnyData } from '../../models/data-view'; -import { NodesView } from '../../models/nodes'; +import { ColorMapView } from '../models/color-map'; +import { AnyData } from '../models/data-view'; +import { NodesView } from '../models/nodes'; import { createColorAccessor } from './utils/color-coding'; import { createScaledPositionAccessor } from './utils/position-scaling'; import { createSelectionFilterAccessor, FILTER_RANGE } from './utils/selection-filter'; @@ -34,7 +34,7 @@ export function createNodesLayer( return computed(() => { return new PointCloudLayer({ id: 'nodes', - data: nodes().data, + data: nodes(), getPosition: positionAccessor(), getColor: colorAccessor(), pickable: true, diff --git a/libs/node-dist-vis/src/lib/deckgl/layers/scale-bar.ts b/libs/node-dist-vis/src/lib/deckgl/scale-bar.ts similarity index 79% rename from libs/node-dist-vis/src/lib/deckgl/layers/scale-bar.ts rename to libs/node-dist-vis/src/lib/deckgl/scale-bar.ts index 392c8b11a..e9850dc7a 100644 --- a/libs/node-dist-vis/src/lib/deckgl/layers/scale-bar.ts +++ b/libs/node-dist-vis/src/lib/deckgl/scale-bar.ts @@ -1,22 +1,23 @@ import { computed, Signal } from '@angular/core'; import { Layer } from '@deck.gl/core/typed'; import { ScaleBarLayer as ScaleBarLayerConstructor } from '@vivjs/layers'; -import { NodesView } from '../../models/nodes'; +import { NodesView } from '../models/nodes'; type ScaleBarLayerProps = ConstructorParameters[0]; export type ScaleBarLayer = Layer; export function createScaleBarLayer( nodes: Signal, - viewSize: Signal<[number, number]>, + viewSize: Signal<{ width: number; height: number }>, viewState: Signal, ): Signal { const size = computed(() => { const [min, max] = nodes().getDimensions(); - return (max - min) / (1 - min); + const result = (max - min) / (1 - min); + return Number.isFinite(result) ? result : 1; }); const state = computed(() => { - const [width, height] = viewSize(); + const { width, height } = viewSize(); return { ...viewState(), width: width - 136, diff --git a/libs/node-dist-vis/src/lib/deckgl/layers/utils/color-coding.ts b/libs/node-dist-vis/src/lib/deckgl/utils/color-coding.ts similarity index 93% rename from libs/node-dist-vis/src/lib/deckgl/layers/utils/color-coding.ts rename to libs/node-dist-vis/src/lib/deckgl/utils/color-coding.ts index 56021c14e..2afce9f7f 100644 --- a/libs/node-dist-vis/src/lib/deckgl/layers/utils/color-coding.ts +++ b/libs/node-dist-vis/src/lib/deckgl/utils/color-coding.ts @@ -1,5 +1,5 @@ import { AccessorContext, AccessorFunction, Color } from '@deck.gl/core/typed'; -import { ColorMap } from '../../../models/color-map'; +import { ColorMap } from '../../models/color-map'; import { colorCategories } from '@deck.gl/carto/typed'; type Color2 = [r: number, g: number, b: number, a?: number]; diff --git a/libs/node-dist-vis/src/lib/deckgl/layers/utils/position-scaling.ts b/libs/node-dist-vis/src/lib/deckgl/utils/position-scaling.ts similarity index 100% rename from libs/node-dist-vis/src/lib/deckgl/layers/utils/position-scaling.ts rename to libs/node-dist-vis/src/lib/deckgl/utils/position-scaling.ts diff --git a/libs/node-dist-vis/src/lib/deckgl/layers/utils/selection-filter.ts b/libs/node-dist-vis/src/lib/deckgl/utils/selection-filter.ts similarity index 100% rename from libs/node-dist-vis/src/lib/deckgl/layers/utils/selection-filter.ts rename to libs/node-dist-vis/src/lib/deckgl/utils/selection-filter.ts diff --git a/libs/node-dist-vis/src/lib/models/color-map.ts b/libs/node-dist-vis/src/lib/models/color-map.ts index 8e9f039eb..9ad4b443f 100644 --- a/libs/node-dist-vis/src/lib/models/color-map.ts +++ b/libs/node-dist-vis/src/lib/models/color-map.ts @@ -1,8 +1,19 @@ +import { Signal } from '@angular/core'; import { Color } from '@deck.gl/core/typed'; -import { createDataViewClass } from './data-view'; +import { + createDataView, + createDataViewClass, + DataViewInput, + inferViewKeyMapping, + KeyMappingInput, + loadViewData, + loadViewKeyMapping, +} from './data-view'; + +export type ColorMapInput = DataViewInput; +export type ColorMapKeysInput = KeyMappingInput; export interface ColorMapEntry { - // TODO verify key names 'Cell Type': string; 'Cell Color': Color; } @@ -12,13 +23,14 @@ export interface ColorMap { range: Color[]; } -const COLOR_MAP_KEYS: (keyof ColorMapEntry)[] = ['Cell Type', 'Cell Color']; -const BaseColorMapView = createDataViewClass(COLOR_MAP_KEYS); +const REQUIRED_KEYS: (keyof ColorMapEntry)[] = ['Cell Type', 'Cell Color']; +const OPTIONAL_KEYS: (keyof ColorMapEntry)[] = []; +const BaseColorMapView = createDataViewClass([...REQUIRED_KEYS, ...OPTIONAL_KEYS]); export class ColorMapView extends BaseColorMapView { readonly getColorMap = () => { - if (this._colorMap) { - return this._colorMap; + if (this.colorMap) { + return this.colorMap; } const domain: string[] = []; @@ -28,11 +40,31 @@ export class ColorMapView extends BaseColorMapView { range.push(this.getCellColorFor(obj)); } - return (this._colorMap = { domain, range }); + return (this.colorMap = { domain, range }); }; readonly getDomain = () => this.getColorMap().domain; readonly getRange = () => this.getColorMap().range; - private _colorMap?: ColorMap = undefined; + private colorMap?: ColorMap = undefined; +} + +export function loadColorMap( + input: Signal, + keys: Signal, + colorMapKey?: Signal, + colorMapValue?: Signal, +): Signal { + const data = loadViewData(input, ColorMapView); + const mapping = loadViewKeyMapping(keys, { + 'Cell Type': colorMapKey, + 'Cell Color': colorMapValue, + }); + const inferred = inferViewKeyMapping(data, mapping, REQUIRED_KEYS, OPTIONAL_KEYS); + const emptyView = new ColorMapView([], { + 'Cell Type': 0, + 'Cell Color': 1, + }); + + return createDataView(ColorMapView, data, inferred, emptyView); } diff --git a/libs/node-dist-vis/src/lib/models/data-view.ts b/libs/node-dist-vis/src/lib/models/data-view.ts index 1d24c529b..3da1cf7c4 100644 --- a/libs/node-dist-vis/src/lib/models/data-view.ts +++ b/libs/node-dist-vis/src/lib/models/data-view.ts @@ -1,3 +1,9 @@ +import { computed, inject, Signal, Type } from '@angular/core'; +import { CsvFileLoaderService, FileLoader, JsonFileLoaderService } from '@hra-ui/common/fs'; +import { derivedAsync } from 'ngxtension/derived-async'; +import { filter, map } from 'rxjs'; +import { tryParseJson } from './utils'; + type RemoveWhiteSpace = S extends `${infer Pre} ${infer Post}` ? RemoveWhiteSpace<`${Pre}${Post}`> : S; @@ -12,7 +18,12 @@ type Accessor = (arg: Arg) => Entry[P]; export type AnyDataEntry = unknown[] | object; export type AnyData = unknown[][] | object[]; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type DataViewInput> = V | AnyData | string | undefined; export type KeyMapping = { [P in keyof Entry]: PropertyKey }; +export type KeyMappingWithDataOffset = KeyMapping & { [DATA_VIEW_OFFSET]?: number }; +export type KeyMappingMixins = { [P in keyof Entry]?: Signal }; +export type KeyMappingInput = Partial> | string | undefined; export type DataViewAccessors = { [P in keyof Entry as AccessorName]-?: Accessor; @@ -24,15 +35,21 @@ export interface DataView { readonly keys: (keyof Entry)[]; readonly data: AnyData; readonly keyMapping: KeyMapping; + readonly offset: number; + readonly length: number; readonly getPropertyAt:

(index: number, property: P) => Entry[P]; readonly getPropertyFor:

(obj: AnyDataEntry, property: P) => Entry[P]; + + [Symbol.iterator](): IterableIterator; } export interface DataViewConstructor { - new (data: AnyData, keyMapping?: KeyMapping): DataView & DataViewAccessors; + new (data: AnyData, keyMapping: KeyMapping, offset?: number): DataView & DataViewAccessors; } +export const DATA_VIEW_OFFSET = Symbol('data offset'); + function createAccessorName(property: keyof Entry, postfix: AccessorPostfixes): string { const trimmedProperty = String(property).replace(/\s+/g, ''); const capitalizedProperty = trimmedProperty.slice(0, 1).toUpperCase() + trimmedProperty.slice(1); @@ -58,9 +75,10 @@ function attachAccessors(instance: DataView, keys: (keyof Entry)[] export function createDataViewClass(keys: (keyof Entry)[]): DataViewConstructor { class DataViewImpl implements DataView { readonly keys = keys; + readonly length: number; readonly getPropertyAt =

(index: number, property: P): Entry[P] => { - return this.getPropertyFor(this.data[index], property); + return this.getPropertyFor(this.data[index + this.offset] ?? {}, property); }; readonly getPropertyFor =

(obj: AnyDataEntry, property: P): Entry[P] => { const key = this.keyMapping[property]; @@ -74,10 +92,176 @@ export function createDataViewClass(keys: (keyof Entry)[]): DataViewConst constructor( readonly data: AnyData, readonly keyMapping: KeyMapping, + readonly offset = 0, ) { + this.length = data.length - offset; attachAccessors(this, this.keys); } + + [Symbol.iterator]() { + const iter = this.data[Symbol.iterator](); + for (let index = 0; index < this.offset; index++) { + iter.next(); + } + return iter; + } } return DataViewImpl as unknown as DataViewConstructor; } + +function loadData( + input: Signal, + loaderService: Type>, + options: Opts, +): Signal { + const loader = inject(loaderService); + return derivedAsync(() => { + const data = tryParseJson(input()); + if (typeof data === 'string' || data instanceof File) { + return loader.load(data, options).pipe( + filter((event) => event.type === 'data'), + map((event) => event.data), + ); + } + + return data; + }); +} + +export function loadViewData( + input: Signal, + viewCls: Type, +): Signal { + const data = loadData(input, CsvFileLoaderService, { + papaparse: { + dynamicTyping: true, + header: false, + skipEmptyLines: 'greedy', + }, + }); + + return computed(() => { + const result = data(); + return result instanceof viewCls || Array.isArray(result) ? result : []; + }); +} + +export function loadViewKeyMapping( + input: Signal> | string | undefined>, + mixins: KeyMappingMixins = {}, +): Signal>> { + const data = loadData(input, JsonFileLoaderService, {}); + return computed(() => { + const result = data(); + const mapping = typeof result === 'object' && result !== null ? (result as Record) : {}; + + for (const key in mixins) { + if (mapping[key] === undefined && mixins[key] !== undefined) { + mapping[key] = mixins[key](); + } + } + + for (const key in mapping) { + if (mapping[key] === undefined) { + delete mapping[key]; + } + } + + return mapping as Partial>; + }); +} + +function inferViewKeyMappingImpl( + entry: AnyDataEntry, + mapping: Partial>, + keys: (keyof T)[], +): void { + const icase = (value: unknown) => String(value).toLowerCase(); + const isArrayEntry = Array.isArray(entry); + let header: unknown[]; + + if (isArrayEntry) { + if (entry.every((value) => typeof value === 'number')) { + header = keys; + } else { + header = entry; + mapping[DATA_VIEW_OFFSET] = 1; + } + } else { + header = Object.keys(entry); + } + + for (const key of keys) { + const prop = mapping[key] ?? key; + const propICase = icase(prop); + const index = header.findIndex((candidate) => icase(candidate) === propICase); + if (index >= 0) { + mapping[key] = (isArrayEntry ? index : header[index]) as never; + } + } +} + +function validateViewKeyMapping(mapping: Partial>, requiredKeys: (keyof T)[]): Error | void { + const missingKeys: (keyof T)[] = []; + for (const key of requiredKeys) { + if (mapping[key] === undefined) { + missingKeys.push(key); + } + } + + if (missingKeys.length > 0) { + return new Error(`Missing required keys: ${missingKeys.join(', ')}`); + } +} + +export function inferViewKeyMapping( + data: Signal | AnyData>, + mapping: Signal>>, + requiredKeys: (keyof T)[], + optionalKeys: (keyof T)[], +): Signal | undefined> { + const keys = [...requiredKeys, ...optionalKeys]; + const defaultArrayKeyMapping = {} as KeyMapping; + keys.forEach((key, index) => (defaultArrayKeyMapping[key] = index)); + + return computed(() => { + const viewData = data(); + if (!Array.isArray(viewData)) { + return viewData.keyMapping; + } else if (viewData.length === 0) { + return defaultArrayKeyMapping; + } + + const viewMapping = mapping(); + inferViewKeyMappingImpl(viewData[0], viewMapping, keys); + + const error = validateViewKeyMapping(viewMapping, requiredKeys); + if (error !== undefined) { + return undefined; + } + + return viewMapping as KeyMappingWithDataOffset; + }); +} + +export function createDataView( + viewCls: new (data: AnyData, keyMapping: KeyMapping, offset?: number) => V, + data: Signal, + keyMapping: Signal | undefined>, + defaultView: V, +): Signal { + return computed(() => { + const viewData = data(); + if (viewData instanceof viewCls) { + return viewData; + } + + const viewMapping = keyMapping(); + if (viewMapping !== undefined) { + return new viewCls(viewData as AnyData, viewMapping, viewMapping[DATA_VIEW_OFFSET]); + } + + return defaultView; + }); +} diff --git a/libs/node-dist-vis/src/lib/models/edges.ts b/libs/node-dist-vis/src/lib/models/edges.ts index cfe330272..f1c921be4 100644 --- a/libs/node-dist-vis/src/lib/models/edges.ts +++ b/libs/node-dist-vis/src/lib/models/edges.ts @@ -1,4 +1,18 @@ -import { AnyDataEntry, createDataViewClass } from './data-view'; +import { Signal } from '@angular/core'; +import { AccessorContext } from '@deck.gl/core/typed'; +import { + AnyDataEntry, + createDataView, + createDataViewClass, + DataViewInput, + inferViewKeyMapping, + KeyMappingInput, + loadViewData, + loadViewKeyMapping, +} from './data-view'; + +export type EdgesInput = DataViewInput; +export type EdgeKeysInput = KeyMappingInput; export interface EdgeEntry { 'Cell ID': number; @@ -10,21 +24,51 @@ export interface EdgeEntry { Z2: number; } -const EDGE_KEYS: (keyof EdgeEntry)[] = ['Cell ID', 'X1', 'Y1', 'Z1', 'X2', 'Y2', 'Z2']; -const BaseEdgesView = createDataViewClass(EDGE_KEYS); +const REQUIRED_KEYS: (keyof EdgeEntry)[] = ['Cell ID', 'X1', 'Y1', 'Z1', 'X2', 'Y2', 'Z2']; +const OPTIONAL_KEYS: (keyof EdgeEntry)[] = []; +const BaseEdgesView = createDataViewClass([...REQUIRED_KEYS, ...OPTIONAL_KEYS]); export class EdgesView extends BaseEdgesView { - readonly getSourcePositionAt = (index: number) => this.getSourcePositionFor(this.data[index]); - readonly getSourcePositionFor = (obj: AnyDataEntry): [number, number, number] => [ - this.getX1For(obj), - this.getY1For(obj), - this.getZ1For(obj), - ]; + readonly getSourcePositionAt = (index: number, info?: AccessorContext) => + this.getSourcePositionFor(this.data[index], info); + readonly getSourcePositionFor = ( + obj: AnyDataEntry, + info?: AccessorContext, + ): [number, number, number] => { + const position = (info?.target ?? new Array(3)) as [number, number, number]; + position[0] = this.getX1For(obj); + position[1] = this.getY1For(obj); + position[2] = this.getZ1For(obj); + return position; + }; + + readonly getTargetPositionAt = (index: number, info?: AccessorContext) => + this.getTargetPositionFor(this.data[index], info); + readonly getTargetPositionFor = ( + obj: AnyDataEntry, + info?: AccessorContext, + ): [number, number, number] => { + const position = (info?.target ?? new Array(3)) as [number, number, number]; + position[0] = this.getX2For(obj); + position[1] = this.getY2For(obj); + position[2] = this.getZ2For(obj); + return position; + }; +} + +export function loadEdges(input: Signal, keys: Signal): Signal { + const data = loadViewData(input, EdgesView); + const mapping = loadViewKeyMapping(keys); + const inferred = inferViewKeyMapping(data, mapping, REQUIRED_KEYS, OPTIONAL_KEYS); + const emptyView = new EdgesView([], { + 'Cell ID': 0, + X1: 1, + Y1: 2, + Z1: 3, + X2: 4, + Y2: 5, + Z2: 6, + }); - readonly getTargetPositionAt = (index: number) => this.getTargetPositionFor(this.data[index]); - readonly getTargetPositionFor = (obj: AnyDataEntry): [number, number, number] => [ - this.getX2For(obj), - this.getY2For(obj), - this.getZ2For(obj), - ]; + return createDataView(EdgesView, data, inferred, emptyView); } diff --git a/libs/node-dist-vis/src/lib/models/filters.ts b/libs/node-dist-vis/src/lib/models/filters.ts new file mode 100644 index 000000000..57d50a75e --- /dev/null +++ b/libs/node-dist-vis/src/lib/models/filters.ts @@ -0,0 +1,4 @@ +export interface NodeFilter { + include?: (string | number)[]; + exclude?: (string | number)[]; +} diff --git a/libs/node-dist-vis/src/lib/models/nodes.ts b/libs/node-dist-vis/src/lib/models/nodes.ts index 552ecef05..341c4a4b8 100644 --- a/libs/node-dist-vis/src/lib/models/nodes.ts +++ b/libs/node-dist-vis/src/lib/models/nodes.ts @@ -1,4 +1,18 @@ -import { AnyDataEntry, createDataViewClass } from './data-view'; +import { Signal } from '@angular/core'; +import { + AnyDataEntry, + createDataView, + createDataViewClass, + DataViewInput, + inferViewKeyMapping, + KeyMappingInput, + loadViewData, + loadViewKeyMapping, +} from './data-view'; +import { AccessorContext } from '@deck.gl/core/typed'; + +export type NodesInput = DataViewInput; +export type NodeKeysInput = KeyMappingInput; export interface NodeEntry { 'Cell Type': string; @@ -8,25 +22,29 @@ export interface NodeEntry { Z?: number; } -const NODE_KEYS: (keyof NodeEntry)[] = ['Cell Type', 'Cell Ontology ID', 'X', 'Y', 'Z']; -const BaseNodesView = createDataViewClass(NODE_KEYS); +const REQUIRED_KEYS: (keyof NodeEntry)[] = ['Cell Type', 'X', 'Y']; +const OPTIONAL_KEYS: (keyof NodeEntry)[] = ['Cell Ontology ID', 'Z']; +const BaseNodesView = createDataViewClass([...REQUIRED_KEYS, ...OPTIONAL_KEYS]); export class NodesView extends BaseNodesView { - readonly getPositionAt = (index: number) => this.getPositionFor(this.data[index]); - readonly getPositionFor = (obj: AnyDataEntry): [number, number, number] => [ - this.getXFor(obj), - this.getYFor(obj), - this.getZFor(obj) ?? 0, - ]; + readonly getPositionAt = (index: number, info?: AccessorContext) => + this.getPositionFor(this.data[index], info); + readonly getPositionFor = (obj: AnyDataEntry, info?: AccessorContext): [number, number, number] => { + const position = (info?.target ?? new Array(3)) as [number, number, number]; + position[0] = this.getXFor(obj); + position[1] = this.getYFor(obj); + position[2] = this.getZFor(obj) ?? 0; + return position; + }; readonly getDimensions = (): [number, number] => { - if (this._dimensions) { - return this._dimensions; + if (this.dimensions) { + return this.dimensions; } let min = Number.MAX_VALUE; let max = -Number.MAX_VALUE; - for (const obj of this.data) { + for (const obj of this) { const x = this.getXFor(obj); const y = this.getYFor(obj); const z = this.getZFor(obj) ?? 0; @@ -34,8 +52,25 @@ export class NodesView extends BaseNodesView { max = Math.max(max, x, y, z); } - return (this._dimensions = [min, max]); + return (this.dimensions = [min, max]); }; - private _dimensions?: [number, number] = undefined; + private dimensions?: [number, number] = undefined; +} + +export function loadNodes( + input: Signal, + keys: Signal, + nodeTargetKey?: Signal, +): Signal { + const data = loadViewData(input, NodesView); + const mapping = loadViewKeyMapping(keys, { 'Cell Type': nodeTargetKey }); + const inferred = inferViewKeyMapping(data, mapping, REQUIRED_KEYS, OPTIONAL_KEYS); + const emptyView = new NodesView([], { + 'Cell Type': 0, + X: 1, + Y: 2, + }); + + return createDataView(NodesView, data, inferred, emptyView); } diff --git a/libs/node-dist-vis/src/lib/models/utils.ts b/libs/node-dist-vis/src/lib/models/utils.ts new file mode 100644 index 000000000..cb920af61 --- /dev/null +++ b/libs/node-dist-vis/src/lib/models/utils.ts @@ -0,0 +1,11 @@ +export function tryParseJson(value: unknown): unknown { + try { + if (typeof value === 'string') { + return JSON.parse(value); + } + } catch { + // Ignore errors + } + + return value; +} diff --git a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts index 842df9324..d37226079 100644 --- a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts +++ b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts @@ -2,120 +2,156 @@ import { ChangeDetectionStrategy, Component, computed, - DestroyRef, effect, ElementRef, ErrorHandler, inject, + input, output, signal, - untracked, viewChild, } from '@angular/core'; import { DeckProps, OrbitView, PickingInfo } from '@deck.gl/core/typed'; import { createDeck } from '../deckgl/deck'; -import { createEdgesLayer } from '../deckgl/layers/edges'; -import { createNodesLayer } from '../deckgl/layers/nodes'; -import { createScaleBarLayer } from '../deckgl/layers/scale-bar'; -import { ColorMapView } from '../models/color-map'; -import { AnyDataEntry } from '../models/data-view'; -import { EdgesView } from '../models/edges'; -import { NodesView } from '../models/nodes'; +import { createEdgesLayer } from '../deckgl/edges'; +import { createNodesLayer } from '../deckgl/nodes'; +import { createScaleBarLayer } from '../deckgl/scale-bar'; +import { ColorMapEntry, ColorMapView, loadColorMap } from '../models/color-map'; +import { AnyData, AnyDataEntry, KeyMapping } from '../models/data-view'; +import { EdgeKeysInput, EdgesInput, EdgesView, loadEdges } from '../models/edges'; +import { NodeFilter } from '../models/filters'; +import { loadNodes, NodeKeysInput, NodesInput, NodesView } from '../models/nodes'; + +// CursorState is not exported by deckgl! +type CursorState = Parameters>[0]; + +export type Mode = 'explore' | 'inspect' | 'select'; + +const INITIAL_VIEW_STATE = { + version: 0, + orbitAxis: 'Y', + camera: 'orbit', + zoom: 9, + minRotationX: -90, + maxRotationX: 90, + rotationX: 0, + rotationOrbit: 0, + dragMode: 'rotate', + target: [0.5, 0.5], +}; + +const TEST_NODES = new NodesView( + [ + { x: 659, y: 72, position: [659, 72, 0], 'Cell Type': 'T-Helper' }, + { x: 178, y: 73, position: [178, 73, 0], 'Cell Type': 'T-Helper' }, + { x: 170, y: 74, position: [170, 74, 0], 'Cell Type': 'T-Helper' }, + { x: 173, y: 75, position: [173, 75, 0], 'Cell Type': 'T-Helper' }, + { x: 174, y: 76, position: [174, 76, 0], 'Cell Type': 'T-Helper' }, + ], + { 'Cell Type': 'Cell Type', X: 'x', Y: 'y' }, +); + +const TEST_EDGES = new EdgesView( + [ + [0, 659, 72, 0, 630, 105, 5], + [1, 178, 73, 0, 177, 71, 2], + [2, 170, 74, 0, 166, 79, 2], + [3, 173, 74, 0, 177, 71, 2], + [4, 174, 75, 0, 177, 71, 2], + ], + { 'Cell ID': 0, X1: 1, Y1: 2, Z1: 3, X2: 4, Y2: 5, Z2: 6 }, +); + +const TEST_COLOR_MAP = new ColorMapView([['T-Helper', [112, 165, 168]]], { 'Cell Type': 0, 'Cell Color': 1 }); @Component({ selector: 'hra-node-dist-vis', standalone: true, template: '', - styles: ':host { display: block; width: 100%; height: 100%; }', + styles: ':host { display: block; }', changeDetection: ChangeDetectionStrategy.OnPush, }) export class NodeDistVisComponent { + readonly mode = input('explore'); + + readonly nodes = input(TEST_NODES); // TODO remove default + readonly nodeKeys = input(); + readonly nodeTargetSelector = input(); // TODO default (must take nodeTargetValue into consideration, i.e. don't set default on this input) + /** @deprecated */ + readonly nodeTargetKey = input(); + /** @deprecated */ + readonly nodeTargetValue = input(); + + readonly edges = input(TEST_EDGES); // TODO remove default + readonly edgeKeys = input(); + readonly maxEdgeDistance = input(); // TODO default + transform + + readonly colorMap = input(TEST_COLOR_MAP); // TODO remove default + readonly colorMapKeys = input | string>(); + /** @deprecated */ + readonly colorMapKey = input(); + /** @deprecated */ + readonly colorMapValue = input(); + + readonly nodeFilter = input(); + /** @deprecated */ + readonly selection = input(); + readonly nodeClick = output(); readonly nodeHover = output(); + // TODO nodesSelected (nodeSelected?) // check material/html/etc. for selected vs selection + + readonly canvas = computed(() => this.canvasElementRef().nativeElement); + readonly deck = createDeck(this.canvas, { + controller: true, + views: [new OrbitView({ orbitAxis: 'Y' })], + initialViewState: INITIAL_VIEW_STATE, + layers: [], + getCursor: this.getCursor.bind(this), + onClick: this.onClick.bind(this), + onHover: this.onHover.bind(this), + onViewStateChange: ({ viewState }) => this.viewState.set(viewState), + onError: (error) => this.errorHandler.handleError(error), + }); - private readonly nodesView = signal( - new NodesView( - [ - { x: 659, y: 72, position: [659, 72, 0], type: 'T-Helper' }, - { x: 178, y: 73, position: [178, 73, 0], type: 'T-Helper' }, - { x: 170, y: 74, position: [170, 74, 0], type: 'T-Helper' }, - { x: 173, y: 75, position: [173, 75, 0], type: 'T-Helper' }, - { x: 174, y: 76, position: [174, 76, 0], type: 'T-Helper' }, - ], - { 'Cell Type': 'type', X: 'x', Y: 'y' }, - ), - ); - private readonly edgesView = signal( - new EdgesView( - [ - [0, 659, 72, 0, 630, 105, 5], - [1, 178, 73, 0, 177, 71, 2], - [2, 170, 74, 0, 166, 79, 2], - [3, 173, 74, 0, 177, 71, 2], - [4, 174, 75, 0, 177, 71, 2], - ], - { 'Cell ID': 0, X1: 1, Y1: 2, Z1: 3, X2: 4, Y2: 5, Z2: 6 }, - ), - ); - private readonly colorMapView = signal( - new ColorMapView([['T-Helper', [112, 165, 168]]], { 'Cell Type': 0, 'Cell Color': 1 }), - ); - private readonly selection = signal(undefined); - - private readonly canvas = viewChild.required>('canvas'); + private readonly canvasElementRef = viewChild.required>('canvas'); private readonly errorHandler = inject(ErrorHandler); - private viewStateVersionCounter = 0; - private readonly viewState = signal(this.getInitialViewState()); - private readonly viewSize = computed((): [number, number] => { - const { width, height } = this.canvas().nativeElement; - return [width, height]; - }); + private viewStateVersion = INITIAL_VIEW_STATE.version; + private readonly viewState = signal(INITIAL_VIEW_STATE); - private readonly deckProps = computed( - (): DeckProps => ({ - canvas: this.canvas().nativeElement, - controller: true, - views: [new OrbitView({ orbitAxis: 'Y' })], - initialViewState: untracked(this.viewState), - layers: [], - getCursor: ({ isDragging, isHovering }) => this.getCursor(isDragging, isHovering), - onClick: (info) => this.onClick(info), - onHover: (info) => this.onHover(info), - onViewStateChange: ({ viewState }) => this.viewState.set(viewState), - onError: (error) => this.errorHandler.handleError(error), - }), - ); - private readonly deck = createDeck(this.deckProps); + private readonly nodesView = loadNodes(this.nodes, this.nodeKeys, this.nodeTargetKey); + private readonly edgesView = loadEdges(this.edges, this.edgeKeys); + private readonly colorMapView = loadColorMap(this.colorMap, this.colorMapKeys, this.colorMapKey, this.colorMapValue); + private readonly selectionFilter = signal(undefined); // TODO rename? Need both inclusion and exclusion filters - private readonly nodesLayer = createNodesLayer(this.nodesView, this.selection, this.colorMapView); - private readonly edgesLayer = createEdgesLayer(this.nodesView, this.edgesView, this.selection, this.colorMapView); - private readonly scaleBarLayer = createScaleBarLayer(this.nodesView, this.viewSize, this.viewState); + private readonly nodesLayer = createNodesLayer(this.nodesView, this.selectionFilter, this.colorMapView); + private readonly edgesLayer = createEdgesLayer( + this.nodesView, + this.edgesView, + this.selectionFilter, + this.colorMapView, + ); + private readonly scaleBarLayer = createScaleBarLayer(this.nodesView, this.canvas, this.viewState); private readonly layers = computed(() => [this.nodesLayer(), this.edgesLayer(), this.scaleBarLayer()]); private activeHover: AnyDataEntry | undefined = undefined; constructor() { - effect(() => this.deck()?.setProps({ layers: this.layers() })); - inject(DestroyRef).onDestroy(() => this.deck()?.finalize()); + effect(() => this.deck().setProps({ layers: this.layers() })); + console.log(this); // TODO remove me!!! } - private getInitialViewState() { - return { - version: this.viewStateVersionCounter++, - orbitAxis: 'Y', - camera: 'orbit', - zoom: 9, - minRotationX: -90, - maxRotationX: 90, - rotationX: 0, - rotationOrbit: 0, - dragMode: 'rotate', - target: [0.5, 0.5], - }; + resetView(): void { + this.deck().setProps({ + initialViewState: { + ...INITIAL_VIEW_STATE, + version: this.viewStateVersion++, + }, + }); } - private getCursor(isDragging: boolean, isHovering: boolean): string { + private getCursor({ isDragging, isHovering }: CursorState): string { if (isDragging) { return 'grabbing'; } else if (isHovering) { @@ -139,320 +175,3 @@ export class NodeDistVisComponent { } } } - -// import { CommonModule } from '@angular/common'; -// import { -// ChangeDetectionStrategy, -// Component, -// EffectRef, -// ElementRef, -// inject, -// input, -// Input, -// output, -// ViewChild, -// } from '@angular/core'; -// import { EdgeEntry } from '../models/edges'; -// import { NodeEntry, NodeTargetKey } from '../models/nodes'; -// // import { EdgeDataService } from '../services/edge-data.service'; -// import { NodeDataService } from '../services/node-data.service'; -// import { DeckGlVisualizationComponent } from '../deck-gl-visualization/deck-gl-visualization.component'; - -// @Component({ -// selector: 'hra-node-dist-vis', -// standalone: true, -// imports: [CommonModule, DeckGlVisualizationComponent], -// providers: [NodeDataService], -// templateUrl: './node-dist-vis.component.html', -// styleUrl: './node-dist-vis.component.scss', -// changeDetection: ChangeDetectionStrategy.OnPush, -// }) -// export class NodeDistVisComponent { -// readonly nodeTargetKey: NodeTargetKey = 'Cell Type' as NodeTargetKey; - -// readonly nodes: NodeEntry[] = [ -// { x: 659, y: 72, position: [659, 72, 0], [this.nodeTargetKey]: 'T-Helper' }, -// { x: 178, y: 73, position: [178, 73, 0], [this.nodeTargetKey]: 'T-Helper' }, -// { x: 170, y: 74, position: [170, 74, 0], [this.nodeTargetKey]: 'T-Helper' }, -// { x: 173, y: 75, position: [173, 75, 0], [this.nodeTargetKey]: 'T-Helper' }, -// { x: 174, y: 76, position: [174, 76, 0], [this.nodeTargetKey]: 'T-Helper' }, -// ] as NodeEntry[]; - -// readonly edges: EdgeEntry[] = [ -// [0, 659, 72, 0, 630, 105, 5], -// [1, 178, 73, 0, 177, 71, 2], -// [2, 170, 74, 0, 166, 79, 2], -// [3, 173, 74, 0, 177, 71, 2], -// [4, 174, 75, 0, 177, 71, 2], -// ]; - -// readonly selection: string[] = ['T-Helper']; -// readonly colorMap: { domain: string[]; range: [[number, number, number]] } = { -// domain: ['T-Helper'], -// range: [[112, 165, 168]], -// }; - -// // log(label: string, value: T): T { -// // console.log(label, value); -// // return value; -// // } - -// // readonly nodes = input(); -// // readonly edges = input(); - -// // nodesUrl = input(); -// // nodesData = input(); -// // edgesUrl = input(); -// // edgesData = input(); -// // colorMapUrl = input(); -// // colorMapKey = input('cell_type'); -// // colorMapValue = input('cell_color'); -// // nodeTargetKey = input(); -// // nodeTargetValue = input(); -// // maxEdgeDistance = input(); -// // dispatchEvent = output(); -// // @Input() selection?: any[]; -// // @ViewChild('visCanvas', { static: true }) visCanvas!: ElementRef; - -// // toDispose: EffectRef[] = []; -// // initialized = false; -// // edgesVersion = 0; - -// // private readonly nodeDataService = inject(NodeDataService); -// // private readonly edgeDataService = inject(EdgeDataService); - -// // constructor() { -// // console.log(this) -// // } - -// // constructor() { -// // effect(() => { -// // this.nodeDataService.nodesInput.next(this.nodes()); -// // this.edgeDataService.edgesInput.next(untracked(this.edges)); -// // }); - -// // effect(() => { -// // this.edgeDataService.edgesInput.next(this.edges()); -// // }); -// // } - -// // private deck!: Deck; -// // // private nodes: NodeEntry[] | undefined = []; -// // private colorCoding: any; - -// // static readonly observedAttributes = [ -// // 'nodes', -// // 'edges', -// // 'color-map', -// // 'color-map-key', -// // 'color-map-value', -// // 'node-target-key', -// // 'node-target-value', -// // 'max-edge-distance', -// // 'selection', -// // ]; - -// // private async loadData() { -// // if (this.nodesData) { -// // this.nodes = this.nodesData(); -// // } else { -// // this.nodes = await this.fetchCsv(this.nodesUrl() ?? ''); -// // } - -// // if (this.edgesData) { -// // this.edges = this.edgesData(); -// // } else { -// // this.edges = await this.fetchCsv(this.edgesUrl() ?? ''); -// // } -// // this.colorCoding = await this.loadColorCoding(); -// // this.updateLayers(); -// // } - -// // // private changedCallback(name: string, newValue: string | number) { -// // // if (this.initialized) { -// // // if (name === 'max-edge-distance' && typeof newValue === 'string') { -// // // newValue = parseFloat(newValue); -// // // } else if (name === 'selection' && typeof newValue === 'string') { -// // // newValue = this.parseSelectionValue(newValue); -// // // } -// // // this.attributesLookup[name].value = newValue; -// // // } -// // // } - -// // ngOnInit() { -// // this.loadData(); -// // // this.initializeDeck(); -// // } - -// // ngOnChanges(): void { -// // // this.changedCallback(); -// // } -// // ngOnDestroy() { -// // // this.toDispose.forEach((dispose) => dispose()); -// // this.toDispose = []; -// // this.deck.finalize(); -// // } - -// // // private initializeDeck() { -// // // let isHovering = false; -// // // let hoveredObject = undefined; -// // // this.deck = new Deck({ -// // // canvas: this.visCanvas.nativeElement, -// // // controller: true, -// // // views: [new OrbitView({ id: 'orbit', orbitAxis: 'Y' })], -// // // initialViewState: this.getInitialViewState(), -// // // onClick: (e: Event) => (e.picked ? this.dispatch('nodeClicked', e.object) : undefined), -// // // onViewStateChange: ({ viewState }) => (this.viewState.value = viewState), -// // // onLoad: () => (this.viewState.value = this.deck.viewState), -// // // onHover: (e) => { -// // // isHovering = e.picked; -// // // if (isHovering) { -// // // if (hoveredObject !== e.object) { -// // // this.dispatch('nodeHovering', e.object); -// // // hoveredObject = e.object; -// // // } -// // // } else { -// // // if (hoveredObject) { -// // // this.dispatch('nodeHovering', undefined); -// // // hoveredObject = undefined; -// // // } -// // // } -// // // }, -// // // getCursor: (e) => (isHovering ? 'pointer' : e.isDragging ? 'grabbing' : 'grab'), -// // // layers: [], -// // // }); - -// // // this.trackDisposal( -// // // effect(() => { -// // // const layers = [this.nodesLayer.value, this.edgesLayer.value, this.scaleBarLayer.value].filter((l) => !!l); -// // // this.deck.setProps({ layers }); -// // // }), -// // // ); - -// // // this.trackDisposal( -// // // effect(async () => { -// // // this.nodes.value = []; -// // // this.nodes.value = await this.nodes$.value; -// // // this.dispatch('nodes', this.nodes.value); -// // // }), -// // // ); - -// // // this.trackDisposal( -// // // effect(async () => { -// // // this.edges.value = []; -// // // const edges = await this.edges$.value; -// // // if (edges) { -// // // this.edges.value = edges; -// // // this.dispatch('edges', this.edges.value); -// // // } -// // // }), -// // // ); - -// // // this.trackDisposal( -// // // effect(async () => { -// // // const colorCoding = await this.colorCoding$.value; -// // // if (colorCoding) { -// // // this.colorCoding.value = colorCoding; -// // // } -// // // }), -// // // ); - -// // // batch(() => { -// // // this.nodesUrl = this.visCanvas.nativeElement.getAttribute('nodes'); -// // // this.edgesUrl.value = this.visCanvas.nativeElement.getAttribute('edges'); -// // // this.colorMapUrl.value = this.visCanvas.nativeElement.getAttribute('color-map'); -// // // this.colorMapKey.value = this.visCanvas.nativeElement.getAttribute('color-map-key') || 'cell_type'; -// // // this.colorMapValue.value = this.visCanvas.nativeElement.getAttribute('color-map-value') || 'cell_color'; -// // // this.nodeTargetKey.value = this.visCanvas.nativeElement.getAttribute('node-target-key'); -// // // this.nodeTargetValue.value = this.visCanvas.nativeElement.getAttribute('node-target-value'); -// // // this.maxEdgeDistance.value = parseFloat(this.getAttribute('max-edge-distance')); -// // // this.selection.value = this.parseSelectionValue(this.getAttribute('selection')); -// // // this.initialized = true; -// // // }); -// // } - -// // trackDisposal(disposable: EffectRef) { -// // this.toDispose.push(disposable); -// // } - -// // private parseSelectionValue(value: string) { -// // if (value === '') { -// // return undefined; -// // } -// // return typeof value === 'string' ? JSON.parse(value) : value; -// // } - -// // attributesLookup = { -// // nodes: this.nodesUrl, -// // edges: this.edgesUrl, -// // 'color-map': this.colorMapUrl, -// // 'color-map-key': this.colorMapKey, -// // 'color-map-value': this.colorMapValue, -// // 'node-target-key': this.nodeTargetKey, -// // 'node-target-value': this.nodeTargetValue, -// // 'max-edge-distance': this.maxEdgeDistance, -// // selection: this.selection, -// // }; - -// // viewStateVersionCounter = 0; -// // private getInitialViewState() { -// // return { -// // // ... initial view state configuration -// // version: this.viewStateVersionCounter++, -// // orbitAxis: 'Y', -// // camera: 'orbit', -// // zoom: 9, -// // minRotationX: -90, -// // maxRotationX: 90, -// // rotationX: 0, -// // rotationOrbit: 0, -// // dragMode: 'rotate', -// // target: [0.5, 0.5], -// // }; -// // } - -// // private async fetchCsv(url: string): Promise { -// // return new Promise((resolve) => { -// // Papa.parse(url, { -// // header: true, -// // skipEmptyLines: true, -// // dynamicTyping: true, -// // complete: (results) => { -// // resolve(results.data); -// // }, -// // }); -// // }); -// // } - -// // private async loadColorCoding() { -// // // Implement color coding logic -// // } - -// // private updateLayers() { -// // const layers = [this.createNodesLayer(), this.createEdgesLayer(), this.createScaleBarLayer()].filter((l) => !!l); - -// // this.deck.setProps({ layers }); -// // } - -// // private createNodesLayer() { -// // // Implement PointCloudLayer creation -// // } - -// // private createEdgesLayer() { -// // // Implement LineLayer creation -// // } - -// // private createScaleBarLayer() { -// // // Implement ScaleBarLayer creation -// // } - -// // private dispatch(eventName: string, payload = undefined) { -// // let event; -// // if (payload) { -// // event = new CustomEvent(eventName, { detail: payload }); -// // } else { -// // event = new Event(eventName); -// // } -// // this.dispatchEvent.emit(event); -// // } -// } From e677de0e42ac66d8c9c0ec8976dd8763bb616fe6 Mon Sep 17 00:00:00 2001 From: Daniel Bolin Date: Tue, 22 Oct 2024 12:50:06 -0400 Subject: [PATCH 14/23] chore: Lint fix --- libs/cde-visualization/package.json | 3 ++- libs/common/package.json | 4 +++- libs/design-system/package.json | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/libs/cde-visualization/package.json b/libs/cde-visualization/package.json index c1e4220cd..b00bd29ac 100644 --- a/libs/cde-visualization/package.json +++ b/libs/cde-visualization/package.json @@ -16,7 +16,8 @@ "@angular/core": "18.2.1", "@angular/platform-browser": "18.2.1", "@angular/cdk": "18.2.1", - "@angular/material": "18.2.1" + "@angular/material": "18.2.1", + "@hra-ui/common": "0.0.1" }, "dependencies": {}, "sideEffects": [ diff --git a/libs/common/package.json b/libs/common/package.json index 9152264d1..643f0859e 100644 --- a/libs/common/package.json +++ b/libs/common/package.json @@ -3,7 +3,9 @@ "version": "0.0.1", "peerDependencies": { "@angular/common": "^18.2.0", - "@angular/core": "^18.2.0" + "@angular/core": "^18.2.0", + "papaparse": "^5.4.1", + "rxjs": "7.8.0" }, "sideEffects": false } diff --git a/libs/design-system/package.json b/libs/design-system/package.json index 9a904e643..f7461b733 100644 --- a/libs/design-system/package.json +++ b/libs/design-system/package.json @@ -30,7 +30,8 @@ "@angular/material": "18.2.1", "ngx-scrollbar": "^15.1.0", "@angular/router": "18.2.1", - "@angular/cdk": "18.2.1" + "@angular/cdk": "18.2.1", + "ngx-color-picker": "^17.0.0" }, "dependencies": {}, "sideEffects": false From b61d9e39d46a2bd2e301054f95c59849609a886a Mon Sep 17 00:00:00 2001 From: Daniel Bolin Date: Tue, 22 Oct 2024 18:03:14 -0400 Subject: [PATCH 15/23] refactor(node-dist-vis): :coffin: Remove unused data services --- .../src/lib/services/edge-data.service.ts | 76 --------------- .../src/lib/services/node-data.service.ts | 95 ------------------- 2 files changed, 171 deletions(-) delete mode 100644 libs/node-dist-vis/src/lib/services/edge-data.service.ts delete mode 100644 libs/node-dist-vis/src/lib/services/node-data.service.ts diff --git a/libs/node-dist-vis/src/lib/services/edge-data.service.ts b/libs/node-dist-vis/src/lib/services/edge-data.service.ts deleted file mode 100644 index 322d7d0fd..000000000 --- a/libs/node-dist-vis/src/lib/services/edge-data.service.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { inject, Injectable } from '@angular/core'; -import { toObservable, toSignal } from '@angular/core/rxjs-interop'; -import { combineLatest, distinctUntilChanged, ObservableInput, of, Subject, switchMap } from 'rxjs'; -import { EdgeEntry } from '../models/edges'; -import { NodeEntry, NodeTargetKey } from '../models/nodes'; -import { fetchCsv } from '../utils/helper'; -import { NodeDataService, NodesData } from './node-data.service'; - -export type EdgesInput = string | EdgeEntry[] | undefined; - -export interface EdgesData { - edges: EdgeEntry[] | undefined; - maxEdgeDistance: number; -} - -@Injectable() -export class EdgeDataService { - readonly edgesInput = new Subject(); - private readonly loadedEdges = this.edgesInput.pipe( - distinctUntilChanged(), - switchMap((data) => this.loadEdges(data)), - ); - - private readonly nodeDataService = inject(NodeDataService); - readonly edges = toSignal( - combineLatest([toObservable(this.nodeDataService.nodesData), this.loadedEdges]).pipe( - switchMap(([nodes, edges]) => this.computeEdges(nodes, { edges, maxEdgeDistance: 0 })), - ), - { - initialValue: undefined, - }, - ); - - private loadEdges(data: EdgesInput): ObservableInput { - if (Array.isArray(data)) { - return of(data); - } else if (typeof data === 'string') { - const edgesData = fetchCsv(data, { header: false }); - edgesData.then((res) => of(res)); - } - return of([]); - } - - private async customDistanceEdges(nodes: NodeEntry[], key: NodeTargetKey, value: string, maxEdgeDist: number) { - if (typeof Worker !== 'undefined') { - const worker = new Worker(new URL('../node-dist-vis/node-dist-vis.worker', import.meta.url)); - return new Promise((resolve) => { - worker.onmessage = (e) => { - if (e.data.status === 'processing') { - console.log(`Computing edges; ${e.data.percentage}% complete.`); - } else if (e.data.status === 'complete') { - resolve(e.data.edges); - worker.terminate(); - } - }; - worker.postMessage({ nodes, key, value, maxEdgeDist }); - }); - } else { - return; - // Web workers are not supported in this environment. - // You should add a fallback so that your program still executes correctly. - } - } - - private computeEdges(nodesData: NodesData, edgesData: EdgesData): ObservableInput { - if (nodesData.nodes.length === 0) { - return of({ edges: undefined, maxEdgeDistance: 0 }); - } else if (edgesData.edges === undefined) { - const { nodes, key, value } = nodesData; - const distEdges = this.customDistanceEdges(nodes, key, value, edgesData.maxEdgeDistance); - distEdges.then((res) => of(res)); - } - - return of(edgesData); - } -} diff --git a/libs/node-dist-vis/src/lib/services/node-data.service.ts b/libs/node-dist-vis/src/lib/services/node-data.service.ts deleted file mode 100644 index 10014c582..000000000 --- a/libs/node-dist-vis/src/lib/services/node-data.service.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { computed, DestroyRef, inject, Injectable, signal } from '@angular/core'; -import { CsvFileLoaderService } from '@hra-ui/utils/file-loaders'; -import { Subscription } from 'rxjs'; -import { NodeEntry, NodeTargetKey } from '../models/nodes'; - -export type NodesInput = string | NodeEntry[] | undefined; - -export interface NodesData { - nodes: NodeEntry[]; - key: NodeTargetKey; - value: string; -} - -const EMPTY_DATA: NodesData = { - nodes: [], - key: '' as NodeTargetKey, - value: '', -}; - -@Injectable() -export class NodeDataService { - private url?: string; - private data?: NodeEntry[]; - private key = EMPTY_DATA.key; - private value = EMPTY_DATA.value; - private subscription?: Subscription; - private readonly csvFileLoader = inject(CsvFileLoaderService); - private readonly nodeDataMut = signal(EMPTY_DATA); - readonly nodeData = this.nodeDataMut.asReadonly(); - readonly nodes = computed(() => this.nodeData().nodes); - - constructor() { - inject(DestroyRef).onDestroy(() => this.clear()); - } - - setInput(input: NodesInput, key: NodeTargetKey, value: string): void { - if (input === undefined || Array.isArray(input)) { - input ??= []; - this.clear(); - this.setPositions(input); - this.emit(input, key, value); - } else if (input !== this.url) { - this.clear(); - this.url = input; - this.key = key; - this.value = value; - this.load(input); - } else if (this.data) { - this.emit(this.data, key, value); - } else { - this.key = key; - this.value = value; - } - } - - private emit(nodes: NodeEntry[], key: NodeTargetKey, value: string): void { - this.nodeDataMut.set({ nodes, key, value }); - } - - private load(url: string): void { - this.subscription = this.csvFileLoader - .load(url, { - papaparse: { - header: true, - dynamicTyping: { - x: true, - y: true, - z: true, - }, - }, - }) - .subscribe((event) => { - if (event.type === 'data') { - this.data = event.data; - this.setPositions(this.data); - this.emit(this.data, this.key, this.value); - } - }); - } - - private setPositions(nodes: NodeEntry[]): void { - for (const node of nodes) { - node.position = [node.x ?? 0, node.y ?? 0, node.z ?? 0]; - } - } - - private clear(): void { - this.subscription?.unsubscribe(); - this.url = undefined; - this.data = undefined; - this.key = EMPTY_DATA.key; - this.value = EMPTY_DATA.value; - this.subscription = undefined; - } -} From b03961c15e631afbe95f16f18a5fb6ca03a4bfbc Mon Sep 17 00:00:00 2001 From: Daniel Bolin Date: Tue, 22 Oct 2024 18:05:48 -0400 Subject: [PATCH 16/23] refactor(node-dist-vis): Add mode logic (WIP), improve filters (WIP), & tweaks --- libs/node-dist-vis/src/lib/deckgl/nodes.ts | 4 +- .../node-dist-vis/src/lib/models/color-map.ts | 2 +- .../node-dist-vis/src/lib/models/data-view.ts | 27 ++--------- libs/node-dist-vis/src/lib/models/filters.ts | 48 +++++++++++++++++-- libs/node-dist-vis/src/lib/models/mode.ts | 19 ++++++++ libs/node-dist-vis/src/lib/models/utils.ts | 24 ++++++++++ .../node-dist-vis/node-dist-vis.component.ts | 34 ++++++++----- 7 files changed, 118 insertions(+), 40 deletions(-) create mode 100644 libs/node-dist-vis/src/lib/models/mode.ts diff --git a/libs/node-dist-vis/src/lib/deckgl/nodes.ts b/libs/node-dist-vis/src/lib/deckgl/nodes.ts index 9afaa03e2..077992281 100644 --- a/libs/node-dist-vis/src/lib/deckgl/nodes.ts +++ b/libs/node-dist-vis/src/lib/deckgl/nodes.ts @@ -4,6 +4,7 @@ import { DataFilterExtension, DataFilterExtensionProps } from '@deck.gl/extensio import { PointCloudLayer } from '@deck.gl/layers/typed'; import { ColorMapView } from '../models/color-map'; import { AnyData } from '../models/data-view'; +import { getNodeSize, Mode } from '../models/mode'; import { NodesView } from '../models/nodes'; import { createColorAccessor } from './utils/color-coding'; import { createScaledPositionAccessor } from './utils/position-scaling'; @@ -12,6 +13,7 @@ import { createSelectionFilterAccessor, FILTER_RANGE } from './utils/selection-f export type NodesLayer = PointCloudLayer>; export function createNodesLayer( + mode: Signal, nodes: Signal, selection: Signal, colorMap: Signal, @@ -39,7 +41,7 @@ export function createNodesLayer( getColor: colorAccessor(), pickable: true, coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, - pointSize: 1.5, + pointSize: getNodeSize(mode()), getFilterValue: filterValueAccessor(), filterRange: FILTER_RANGE, filterEnabled: selection() !== undefined, diff --git a/libs/node-dist-vis/src/lib/models/color-map.ts b/libs/node-dist-vis/src/lib/models/color-map.ts index 9ad4b443f..cc012efac 100644 --- a/libs/node-dist-vis/src/lib/models/color-map.ts +++ b/libs/node-dist-vis/src/lib/models/color-map.ts @@ -35,7 +35,7 @@ export class ColorMapView extends BaseColorMapView { const domain: string[] = []; const range: Color[] = []; - for (const obj of this.data) { + for (const obj of this) { domain.push(this.getCellTypeFor(obj)); range.push(this.getCellColorFor(obj)); } diff --git a/libs/node-dist-vis/src/lib/models/data-view.ts b/libs/node-dist-vis/src/lib/models/data-view.ts index 3da1cf7c4..62ddbd5af 100644 --- a/libs/node-dist-vis/src/lib/models/data-view.ts +++ b/libs/node-dist-vis/src/lib/models/data-view.ts @@ -1,8 +1,6 @@ -import { computed, inject, Signal, Type } from '@angular/core'; -import { CsvFileLoaderService, FileLoader, JsonFileLoaderService } from '@hra-ui/common/fs'; -import { derivedAsync } from 'ngxtension/derived-async'; -import { filter, map } from 'rxjs'; -import { tryParseJson } from './utils'; +import { computed, Signal, Type } from '@angular/core'; +import { CsvFileLoaderService, JsonFileLoaderService } from '@hra-ui/common/fs'; +import { loadData } from './utils'; type RemoveWhiteSpace = S extends `${infer Pre} ${infer Post}` ? RemoveWhiteSpace<`${Pre}${Post}`> @@ -110,25 +108,6 @@ export function createDataViewClass(keys: (keyof Entry)[]): DataViewConst return DataViewImpl as unknown as DataViewConstructor; } -function loadData( - input: Signal, - loaderService: Type>, - options: Opts, -): Signal { - const loader = inject(loaderService); - return derivedAsync(() => { - const data = tryParseJson(input()); - if (typeof data === 'string' || data instanceof File) { - return loader.load(data, options).pipe( - filter((event) => event.type === 'data'), - map((event) => event.data), - ); - } - - return data; - }); -} - export function loadViewData( input: Signal, viewCls: Type, diff --git a/libs/node-dist-vis/src/lib/models/filters.ts b/libs/node-dist-vis/src/lib/models/filters.ts index 57d50a75e..fa7fd8622 100644 --- a/libs/node-dist-vis/src/lib/models/filters.ts +++ b/libs/node-dist-vis/src/lib/models/filters.ts @@ -1,4 +1,46 @@ -export interface NodeFilter { - include?: (string | number)[]; - exclude?: (string | number)[]; +import { computed, Signal } from '@angular/core'; +import { JsonFileLoaderService } from '@hra-ui/common/fs'; +import { loadData } from './utils'; + +export type NodesFilterEntry = string | number; +export type NodesFilterInput = NodesFilter | string | undefined; + +export interface NodesFilter { + include?: NodesFilterEntry[]; + exclude?: NodesFilterEntry[]; +} + +export class NodesFilterView { + constructor( + readonly include: NodesFilterEntry[] | undefined, + readonly exclude: NodesFilterEntry[] | undefined, + ) { + // + } + + equals(other: unknown): boolean { + if (!(other instanceof NodesFilterView)) { + return false; + } + + // TODO + + return true; + } +} + +export function loadNodesFilter( + input: Signal, + selection: Signal, +): Signal { + const data = loadData(input, JsonFileLoaderService, {}); + return computed(() => { + const result = data(); + if (typeof result !== 'object' || result === null) { + return new NodesFilterView(selection(), undefined); + } + + const { include, exclude } = result as NodesFilter; + return new NodesFilterView(include, exclude); + }); } diff --git a/libs/node-dist-vis/src/lib/models/mode.ts b/libs/node-dist-vis/src/lib/models/mode.ts new file mode 100644 index 000000000..b1c7b64ea --- /dev/null +++ b/libs/node-dist-vis/src/lib/models/mode.ts @@ -0,0 +1,19 @@ +import { DeckProps } from '@deck.gl/core/typed'; + +export type Mode = 'explore' | 'inspect' | 'select'; + +const DEFAULT_NODE_SIZE = 1.5; +const INSPECT_NODE_SIZE = 3; + +export function getNodeSize(mode: Mode): number { + return mode === 'inspect' ? INSPECT_NODE_SIZE : DEFAULT_NODE_SIZE; +} + +const DEFAULT_CONTROLLER_OPTIONS: DeckProps['controller'] = true; +const SELECT_CONTROLLER_OPTIONS: DeckProps['controller'] = { + dragRotate: false, +}; + +export function getControllerOptions(mode: Mode): DeckProps['controller'] { + return mode === 'select' ? SELECT_CONTROLLER_OPTIONS : DEFAULT_CONTROLLER_OPTIONS; +} diff --git a/libs/node-dist-vis/src/lib/models/utils.ts b/libs/node-dist-vis/src/lib/models/utils.ts index cb920af61..7995d3d05 100644 --- a/libs/node-dist-vis/src/lib/models/utils.ts +++ b/libs/node-dist-vis/src/lib/models/utils.ts @@ -1,3 +1,8 @@ +import { inject, Signal, Type } from '@angular/core'; +import { FileLoader } from '@hra-ui/common/fs'; +import { derivedAsync } from 'ngxtension/derived-async'; +import { filter, map } from 'rxjs'; + export function tryParseJson(value: unknown): unknown { try { if (typeof value === 'string') { @@ -9,3 +14,22 @@ export function tryParseJson(value: unknown): unknown { return value; } + +export function loadData( + input: Signal, + loaderService: Type>, + options: Opts, +): Signal { + const loader = inject(loaderService); + return derivedAsync(() => { + const data = tryParseJson(input()); + if (typeof data === 'string' || data instanceof File) { + return loader.load(data, options).pipe( + filter((event) => event.type === 'data'), + map((event) => event.data), + ); + } + + return data; + }); +} diff --git a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts index d37226079..3dc47fd76 100644 --- a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts +++ b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts @@ -11,7 +11,7 @@ import { signal, viewChild, } from '@angular/core'; -import { DeckProps, OrbitView, PickingInfo } from '@deck.gl/core/typed'; +import { DeckProps, OrbitView, OrbitViewState, PickingInfo, View } from '@deck.gl/core/typed'; import { createDeck } from '../deckgl/deck'; import { createEdgesLayer } from '../deckgl/edges'; import { createNodesLayer } from '../deckgl/nodes'; @@ -19,13 +19,16 @@ import { createScaleBarLayer } from '../deckgl/scale-bar'; import { ColorMapEntry, ColorMapView, loadColorMap } from '../models/color-map'; import { AnyData, AnyDataEntry, KeyMapping } from '../models/data-view'; import { EdgeKeysInput, EdgesInput, EdgesView, loadEdges } from '../models/edges'; -import { NodeFilter } from '../models/filters'; +import { NodesFilterInput } from '../models/filters'; +import { getControllerOptions, Mode } from '../models/mode'; import { loadNodes, NodeKeysInput, NodesInput, NodesView } from '../models/nodes'; -// CursorState is not exported by deckgl! +// CursorState is not exported by deckgl type CursorState = Parameters>[0]; -export type Mode = 'explore' | 'inspect' | 'select'; +// OrbitView's constructor is poorly typed +type OrbitViewProps = ConstructorParameters[0] & + ConstructorParameters>[0]; const INITIAL_VIEW_STATE = { version: 0, @@ -93,24 +96,27 @@ export class NodeDistVisComponent { /** @deprecated */ readonly colorMapValue = input(); - readonly nodeFilter = input(); + readonly nodeFilter = input(); /** @deprecated */ readonly selection = input(); readonly nodeClick = output(); readonly nodeHover = output(); - // TODO nodesSelected (nodeSelected?) // check material/html/etc. for selected vs selection + // TODO nodesSelected (nodeSelected?) // check material/html/etc. for selected vs selection (candidates: [node]SelectionChange) readonly canvas = computed(() => this.canvasElementRef().nativeElement); readonly deck = createDeck(this.canvas, { - controller: true, - views: [new OrbitView({ orbitAxis: 'Y' })], + controller: getControllerOptions(this.mode()), + views: new OrbitView({ id: 'orbit', orbitAxis: 'Y' } as OrbitViewProps), initialViewState: INITIAL_VIEW_STATE, layers: [], getCursor: this.getCursor.bind(this), onClick: this.onClick.bind(this), onHover: this.onHover.bind(this), - onViewStateChange: ({ viewState }) => this.viewState.set(viewState), + onViewStateChange: (params) => { + console.log(params); // TODO remove + this.viewState.set(params.viewState); + }, onError: (error) => this.errorHandler.handleError(error), }); @@ -125,7 +131,7 @@ export class NodeDistVisComponent { private readonly colorMapView = loadColorMap(this.colorMap, this.colorMapKeys, this.colorMapKey, this.colorMapValue); private readonly selectionFilter = signal(undefined); // TODO rename? Need both inclusion and exclusion filters - private readonly nodesLayer = createNodesLayer(this.nodesView, this.selectionFilter, this.colorMapView); + private readonly nodesLayer = createNodesLayer(this.mode, this.nodesView, this.selectionFilter, this.colorMapView); private readonly edgesLayer = createEdgesLayer( this.nodesView, this.edgesView, @@ -134,11 +140,17 @@ export class NodeDistVisComponent { ); private readonly scaleBarLayer = createScaleBarLayer(this.nodesView, this.canvas, this.viewState); private readonly layers = computed(() => [this.nodesLayer(), this.edgesLayer(), this.scaleBarLayer()]); + private readonly props = computed((): DeckProps => { + return { + controller: getControllerOptions(this.mode()), + layers: this.layers(), + }; + }); private activeHover: AnyDataEntry | undefined = undefined; constructor() { - effect(() => this.deck().setProps({ layers: this.layers() })); + effect(() => this.deck().setProps(this.props())); console.log(this); // TODO remove me!!! } From 7efe04f307b9fa9fbff52fc8ba89e437f9226c2c Mon Sep 17 00:00:00 2001 From: Daniel Bolin Date: Wed, 23 Oct 2024 15:41:19 -0400 Subject: [PATCH 17/23] refactor(node-dist-vis): Implement improved node filtering & minor fixes --- .../src/lib/deckgl/controller.ts | 14 ++++ libs/node-dist-vis/src/lib/deckgl/edges.ts | 12 +-- libs/node-dist-vis/src/lib/deckgl/nodes.ts | 23 ++++-- .../src/lib/deckgl/utils/filters.ts | 17 ++++ .../src/lib/deckgl/utils/selection-filter.ts | 20 ----- .../node-dist-vis/src/lib/models/data-view.ts | 18 +++-- libs/node-dist-vis/src/lib/models/filters.ts | 79 +++++++++++++------ libs/node-dist-vis/src/lib/models/mode.ts | 19 ----- libs/node-dist-vis/src/lib/models/utils.ts | 4 + .../node-dist-vis/src/lib/models/view-mode.ts | 1 + .../node-dist-vis/node-dist-vis.component.ts | 36 +++++---- 11 files changed, 143 insertions(+), 100 deletions(-) create mode 100644 libs/node-dist-vis/src/lib/deckgl/controller.ts create mode 100644 libs/node-dist-vis/src/lib/deckgl/utils/filters.ts delete mode 100644 libs/node-dist-vis/src/lib/deckgl/utils/selection-filter.ts delete mode 100644 libs/node-dist-vis/src/lib/models/mode.ts create mode 100644 libs/node-dist-vis/src/lib/models/view-mode.ts diff --git a/libs/node-dist-vis/src/lib/deckgl/controller.ts b/libs/node-dist-vis/src/lib/deckgl/controller.ts new file mode 100644 index 000000000..19a076474 --- /dev/null +++ b/libs/node-dist-vis/src/lib/deckgl/controller.ts @@ -0,0 +1,14 @@ +import { computed, Signal } from '@angular/core'; +import { DeckProps } from '@deck.gl/core/typed'; +import { ViewMode } from '../models/view-mode'; + +const DEFAULT_CONTROLLER_OPTIONS: DeckProps['controller'] = true; +const SELECT_CONTROLLER_OPTIONS: DeckProps['controller'] = { + dragRotate: false, +}; + +export function createController(mode: Signal): Signal { + return computed(() => { + return mode() === 'select' ? SELECT_CONTROLLER_OPTIONS : DEFAULT_CONTROLLER_OPTIONS; + }); +} diff --git a/libs/node-dist-vis/src/lib/deckgl/edges.ts b/libs/node-dist-vis/src/lib/deckgl/edges.ts index 52c841d05..65bd835ff 100644 --- a/libs/node-dist-vis/src/lib/deckgl/edges.ts +++ b/libs/node-dist-vis/src/lib/deckgl/edges.ts @@ -5,17 +5,18 @@ import { LineLayer } from '@deck.gl/layers/typed'; import { ColorMapView } from '../models/color-map'; import { AnyData, AnyDataEntry } from '../models/data-view'; import { EdgesView } from '../models/edges'; +import { NodeFilterView } from '../models/filters'; import { NodesView } from '../models/nodes'; import { createColorAccessor } from './utils/color-coding'; +import { createNodeFilterAccessor, FILTER_RANGE } from './utils/filters'; import { createScaledPositionAccessor } from './utils/position-scaling'; -import { createSelectionFilterAccessor, FILTER_RANGE } from './utils/selection-filter'; export type EdgesLayer = LineLayer>; export function createEdgesLayer( nodes: Signal, edges: Signal, - selection: Signal, + nodeFilter: Signal, colorMap: Signal, ): Signal { const sourcePositionAccessor = computed(() => { @@ -38,7 +39,8 @@ export function createEdgesLayer( return createColorAccessor(cellTypeAccessor(), map); }); const filterValueAccessor = computed(() => { - return createSelectionFilterAccessor(cellTypeAccessor(), selection()); + const filterFn = nodeFilter().includes; + return createNodeFilterAccessor(cellTypeAccessor(), filterFn); }); return computed(() => { @@ -53,11 +55,11 @@ export function createEdgesLayer( getWidth: 1, getFilterValue: filterValueAccessor(), filterRange: FILTER_RANGE, - filterEnabled: selection() !== undefined, + filterEnabled: !nodeFilter().isEmpty(), extensions: [new DataFilterExtension()], updateTriggers: { getColor: colorMap().getRange(), - getFilterValue: selection(), + getFilterValue: nodeFilter(), }, }); }); diff --git a/libs/node-dist-vis/src/lib/deckgl/nodes.ts b/libs/node-dist-vis/src/lib/deckgl/nodes.ts index 077992281..848ce01d1 100644 --- a/libs/node-dist-vis/src/lib/deckgl/nodes.ts +++ b/libs/node-dist-vis/src/lib/deckgl/nodes.ts @@ -4,18 +4,26 @@ import { DataFilterExtension, DataFilterExtensionProps } from '@deck.gl/extensio import { PointCloudLayer } from '@deck.gl/layers/typed'; import { ColorMapView } from '../models/color-map'; import { AnyData } from '../models/data-view'; -import { getNodeSize, Mode } from '../models/mode'; +import { NodeFilterView } from '../models/filters'; import { NodesView } from '../models/nodes'; +import { ViewMode } from '../models/view-mode'; import { createColorAccessor } from './utils/color-coding'; +import { createNodeFilterAccessor, FILTER_RANGE } from './utils/filters'; import { createScaledPositionAccessor } from './utils/position-scaling'; -import { createSelectionFilterAccessor, FILTER_RANGE } from './utils/selection-filter'; export type NodesLayer = PointCloudLayer>; +const DEFAULT_NODE_SIZE = 5; // 1.5; +const INSPECT_NODE_SIZE = 3; + +function getNodeSize(mode: ViewMode): number { + return mode === 'inspect' ? INSPECT_NODE_SIZE : DEFAULT_NODE_SIZE; +} + export function createNodesLayer( - mode: Signal, + mode: Signal, nodes: Signal, - selection: Signal, + nodeFilter: Signal, colorMap: Signal, ): Signal { const positionAccessor = computed(() => { @@ -30,7 +38,8 @@ export function createNodesLayer( }); const filterValueAccessor = computed(() => { const accessor = nodes().getCellTypeFor; - return createSelectionFilterAccessor(accessor, selection()); + const filterFn = nodeFilter().includes; + return createNodeFilterAccessor(accessor, filterFn); }); return computed(() => { @@ -44,11 +53,11 @@ export function createNodesLayer( pointSize: getNodeSize(mode()), getFilterValue: filterValueAccessor(), filterRange: FILTER_RANGE, - filterEnabled: selection() !== undefined, + filterEnabled: !nodeFilter().isEmpty(), extensions: [new DataFilterExtension()], updateTriggers: { getColor: colorMap().getRange(), - getFilterValue: selection(), + getFilterValue: nodeFilter(), }, }); }); diff --git a/libs/node-dist-vis/src/lib/deckgl/utils/filters.ts b/libs/node-dist-vis/src/lib/deckgl/utils/filters.ts new file mode 100644 index 000000000..0ae9a469e --- /dev/null +++ b/libs/node-dist-vis/src/lib/deckgl/utils/filters.ts @@ -0,0 +1,17 @@ +import { AccessorFunction } from '@deck.gl/core/typed'; +import { NodeFilterPredFn } from '../../models/filters'; + +const FILTER_INCLUDE_VALUE = 1; +const FILTER_EXCLUDE_VALUE = 3; +export const FILTER_RANGE: [number, number] = [0, 2]; + +export function createNodeFilterAccessor( + accessor: AccessorFunction, + filterFn: NodeFilterPredFn, +): AccessorFunction { + return (obj, info) => { + const type = accessor(obj, info); + const result = filterFn(type, info.index); + return result ? FILTER_INCLUDE_VALUE : FILTER_EXCLUDE_VALUE; + }; +} diff --git a/libs/node-dist-vis/src/lib/deckgl/utils/selection-filter.ts b/libs/node-dist-vis/src/lib/deckgl/utils/selection-filter.ts deleted file mode 100644 index 9534fa3a8..000000000 --- a/libs/node-dist-vis/src/lib/deckgl/utils/selection-filter.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { AccessorFunction } from '@deck.gl/core/typed'; - -const FILTER_INCLUDE_VALUE = 1; -const FILTER_EXCLUDE_VALUE = 3; -export const FILTER_RANGE: [number, number] = [0, 2]; - -export function createSelectionFilterAccessor( - accessor: AccessorFunction, - selection: string[] | undefined, -): AccessorFunction { - if (selection === undefined) { - return () => FILTER_INCLUDE_VALUE; - } - - const selectionSet = new Set(selection); - return (obj, info) => { - const value = accessor(obj, info); - return selectionSet.has(value) ? FILTER_INCLUDE_VALUE : FILTER_EXCLUDE_VALUE; - }; -} diff --git a/libs/node-dist-vis/src/lib/models/data-view.ts b/libs/node-dist-vis/src/lib/models/data-view.ts index 62ddbd5af..7b0f4240f 100644 --- a/libs/node-dist-vis/src/lib/models/data-view.ts +++ b/libs/node-dist-vis/src/lib/models/data-view.ts @@ -1,6 +1,6 @@ import { computed, Signal, Type } from '@angular/core'; import { CsvFileLoaderService, JsonFileLoaderService } from '@hra-ui/common/fs'; -import { loadData } from './utils'; +import { isRecordObject, loadData } from './utils'; type RemoveWhiteSpace = S extends `${infer Pre} ${infer Post}` ? RemoveWhiteSpace<`${Pre}${Post}`> @@ -36,6 +36,7 @@ export interface DataView { readonly offset: number; readonly length: number; + readonly at: (index: number) => AnyDataEntry; readonly getPropertyAt:

(index: number, property: P) => Entry[P]; readonly getPropertyFor:

(obj: AnyDataEntry, property: P) => Entry[P]; @@ -75,8 +76,9 @@ export function createDataViewClass(keys: (keyof Entry)[]): DataViewConst readonly keys = keys; readonly length: number; + readonly at = (index: number) => this.data[this.offset + index]; readonly getPropertyAt =

(index: number, property: P): Entry[P] => { - return this.getPropertyFor(this.data[index + this.offset] ?? {}, property); + return this.getPropertyFor(this.data[this.offset + index] ?? {}, property); }; readonly getPropertyFor =

(obj: AnyDataEntry, property: P): Entry[P] => { const key = this.keyMapping[property]; @@ -133,7 +135,7 @@ export function loadViewKeyMapping( const data = loadData(input, JsonFileLoaderService, {}); return computed(() => { const result = data(); - const mapping = typeof result === 'object' && result !== null ? (result as Record) : {}; + const mapping = isRecordObject(result) ? { ...result } : {}; for (const key in mixins) { if (mapping[key] === undefined && mixins[key] !== undefined) { @@ -161,8 +163,10 @@ function inferViewKeyMappingImpl( let header: unknown[]; if (isArrayEntry) { - if (entry.every((value) => typeof value === 'number')) { - header = keys; + const isAllNumeric = entry.every((value) => typeof value === 'number'); + const isBackwardsCompatibleEdges = entry.length === 7 && keys.length >= 7 && isAllNumeric; + if (isBackwardsCompatibleEdges) { + header = keys.slice(0, 7); } else { header = entry; mapping[DATA_VIEW_OFFSET] = 1; @@ -177,6 +181,8 @@ function inferViewKeyMappingImpl( const index = header.findIndex((candidate) => icase(candidate) === propICase); if (index >= 0) { mapping[key] = (isArrayEntry ? index : header[index]) as never; + } else { + delete mapping[key]; } } } @@ -212,7 +218,7 @@ export function inferViewKeyMapping( return defaultArrayKeyMapping; } - const viewMapping = mapping(); + const viewMapping = { ...mapping() }; inferViewKeyMappingImpl(viewData[0], viewMapping, keys); const error = validateViewKeyMapping(viewMapping, requiredKeys); diff --git a/libs/node-dist-vis/src/lib/models/filters.ts b/libs/node-dist-vis/src/lib/models/filters.ts index fa7fd8622..b6eae5cb6 100644 --- a/libs/node-dist-vis/src/lib/models/filters.ts +++ b/libs/node-dist-vis/src/lib/models/filters.ts @@ -1,46 +1,73 @@ import { computed, Signal } from '@angular/core'; import { JsonFileLoaderService } from '@hra-ui/common/fs'; -import { loadData } from './utils'; +import { isRecordObject, loadData } from './utils'; -export type NodesFilterEntry = string | number; -export type NodesFilterInput = NodesFilter | string | undefined; +export type NodeFilterEntry = string | number; +export type NodeFilterInput = NodeFilter | string | undefined; +export type NodeFilterPredFn = (type: string, index: number) => boolean; -export interface NodesFilter { - include?: NodesFilterEntry[]; - exclude?: NodesFilterEntry[]; +export interface NodeFilter { + include?: NodeFilterEntry[]; + exclude?: NodeFilterEntry[]; } -export class NodesFilterView { +function truthy(): boolean { + return true; +} + +function falsy(): boolean { + return false; +} + +export class NodeFilterView { + readonly includes = this.selectFilterFn(); + readonly isEmpty = () => { + const { include, exclude = [] } = this; + return include === undefined && exclude.length === 0; + }; + constructor( - readonly include: NodesFilterEntry[] | undefined, - readonly exclude: NodesFilterEntry[] | undefined, - ) { - // - } + readonly include: NodeFilterEntry[] | undefined, + readonly exclude: NodeFilterEntry[] | undefined, + ) {} - equals(other: unknown): boolean { - if (!(other instanceof NodesFilterView)) { - return false; - } + private selectFilterFn(): NodeFilterPredFn { + const { include, exclude = [] } = this; + const includeFn = this.createFilterFn(include); + const excludeFn = this.createFilterFn(exclude); - // TODO + if (include === undefined) { + return exclude.length === 0 ? truthy : (type, index) => !excludeFn(type, index); + } else if (include.length === 0) { + return falsy; + } else if (exclude.length === 0) { + return includeFn; + } else { + return (type, index) => includeFn(type, index) && !excludeFn(type, index); + } + } - return true; + private createFilterFn(entries: NodeFilterEntry[] | undefined): NodeFilterPredFn { + const entriesSet = new Set(entries); + return (type, index) => entriesSet.has(type) || entriesSet.has(index); } } -export function loadNodesFilter( - input: Signal, - selection: Signal, -): Signal { +export function loadNodeFilter( + input: Signal, + selection: Signal, +): Signal { const data = loadData(input, JsonFileLoaderService, {}); + const selectionData = loadData(selection, JsonFileLoaderService, {}); return computed(() => { const result = data(); - if (typeof result !== 'object' || result === null) { - return new NodesFilterView(selection(), undefined); + if (isRecordObject(result)) { + const { include, exclude } = result as NodeFilter; + return new NodeFilterView(include, exclude); } - const { include, exclude } = result as NodesFilter; - return new NodesFilterView(include, exclude); + const includeSelection = selectionData(); + const include = Array.isArray(includeSelection) ? includeSelection : undefined; + return new NodeFilterView(include, undefined); }); } diff --git a/libs/node-dist-vis/src/lib/models/mode.ts b/libs/node-dist-vis/src/lib/models/mode.ts deleted file mode 100644 index b1c7b64ea..000000000 --- a/libs/node-dist-vis/src/lib/models/mode.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { DeckProps } from '@deck.gl/core/typed'; - -export type Mode = 'explore' | 'inspect' | 'select'; - -const DEFAULT_NODE_SIZE = 1.5; -const INSPECT_NODE_SIZE = 3; - -export function getNodeSize(mode: Mode): number { - return mode === 'inspect' ? INSPECT_NODE_SIZE : DEFAULT_NODE_SIZE; -} - -const DEFAULT_CONTROLLER_OPTIONS: DeckProps['controller'] = true; -const SELECT_CONTROLLER_OPTIONS: DeckProps['controller'] = { - dragRotate: false, -}; - -export function getControllerOptions(mode: Mode): DeckProps['controller'] { - return mode === 'select' ? SELECT_CONTROLLER_OPTIONS : DEFAULT_CONTROLLER_OPTIONS; -} diff --git a/libs/node-dist-vis/src/lib/models/utils.ts b/libs/node-dist-vis/src/lib/models/utils.ts index 7995d3d05..2d8c797fe 100644 --- a/libs/node-dist-vis/src/lib/models/utils.ts +++ b/libs/node-dist-vis/src/lib/models/utils.ts @@ -3,6 +3,10 @@ import { FileLoader } from '@hra-ui/common/fs'; import { derivedAsync } from 'ngxtension/derived-async'; import { filter, map } from 'rxjs'; +export function isRecordObject(obj: unknown): obj is Record { + return typeof obj === 'object' && obj !== null; +} + export function tryParseJson(value: unknown): unknown { try { if (typeof value === 'string') { diff --git a/libs/node-dist-vis/src/lib/models/view-mode.ts b/libs/node-dist-vis/src/lib/models/view-mode.ts new file mode 100644 index 000000000..e3cbc68cf --- /dev/null +++ b/libs/node-dist-vis/src/lib/models/view-mode.ts @@ -0,0 +1 @@ +export type ViewMode = 'explore' | 'inspect' | 'select'; diff --git a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts index 3dc47fd76..75d6644fe 100644 --- a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts +++ b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts @@ -12,6 +12,7 @@ import { viewChild, } from '@angular/core'; import { DeckProps, OrbitView, OrbitViewState, PickingInfo, View } from '@deck.gl/core/typed'; +import { createController } from '../deckgl/controller'; import { createDeck } from '../deckgl/deck'; import { createEdgesLayer } from '../deckgl/edges'; import { createNodesLayer } from '../deckgl/nodes'; @@ -19,9 +20,9 @@ import { createScaleBarLayer } from '../deckgl/scale-bar'; import { ColorMapEntry, ColorMapView, loadColorMap } from '../models/color-map'; import { AnyData, AnyDataEntry, KeyMapping } from '../models/data-view'; import { EdgeKeysInput, EdgesInput, EdgesView, loadEdges } from '../models/edges'; -import { NodesFilterInput } from '../models/filters'; -import { getControllerOptions, Mode } from '../models/mode'; +import { loadNodeFilter, NodeFilterInput } from '../models/filters'; import { loadNodes, NodeKeysInput, NodesInput, NodesView } from '../models/nodes'; +import { ViewMode } from '../models/view-mode'; // CursorState is not exported by deckgl type CursorState = Parameters>[0]; @@ -75,7 +76,7 @@ const TEST_COLOR_MAP = new ColorMapView([['T-Helper', [112, 165, 168]]], { 'Cell changeDetection: ChangeDetectionStrategy.OnPush, }) export class NodeDistVisComponent { - readonly mode = input('explore'); + readonly mode = input('explore'); readonly nodes = input(TEST_NODES); // TODO remove default readonly nodeKeys = input(); @@ -96,27 +97,24 @@ export class NodeDistVisComponent { /** @deprecated */ readonly colorMapValue = input(); - readonly nodeFilter = input(); + readonly nodeFilter = input(); /** @deprecated */ readonly selection = input(); readonly nodeClick = output(); readonly nodeHover = output(); - // TODO nodesSelected (nodeSelected?) // check material/html/etc. for selected vs selection (candidates: [node]SelectionChange) + readonly nodeSelectionChange = output(); // TODO fix type readonly canvas = computed(() => this.canvasElementRef().nativeElement); readonly deck = createDeck(this.canvas, { - controller: getControllerOptions(this.mode()), + controller: true, views: new OrbitView({ id: 'orbit', orbitAxis: 'Y' } as OrbitViewProps), initialViewState: INITIAL_VIEW_STATE, layers: [], getCursor: this.getCursor.bind(this), onClick: this.onClick.bind(this), onHover: this.onHover.bind(this), - onViewStateChange: (params) => { - console.log(params); // TODO remove - this.viewState.set(params.viewState); - }, + onViewStateChange: ({ viewState }) => this.viewState.set(viewState), onError: (error) => this.errorHandler.handleError(error), }); @@ -129,20 +127,22 @@ export class NodeDistVisComponent { private readonly nodesView = loadNodes(this.nodes, this.nodeKeys, this.nodeTargetKey); private readonly edgesView = loadEdges(this.edges, this.edgeKeys); private readonly colorMapView = loadColorMap(this.colorMap, this.colorMapKeys, this.colorMapKey, this.colorMapValue); - private readonly selectionFilter = signal(undefined); // TODO rename? Need both inclusion and exclusion filters + private readonly nodeFilterView = loadNodeFilter(this.nodeFilter, this.selection); - private readonly nodesLayer = createNodesLayer(this.mode, this.nodesView, this.selectionFilter, this.colorMapView); + private readonly nodesLayer = createNodesLayer(this.mode, this.nodesView, this.nodeFilterView, this.colorMapView); private readonly edgesLayer = createEdgesLayer( this.nodesView, this.edgesView, - this.selectionFilter, + this.nodeFilterView, this.colorMapView, ); private readonly scaleBarLayer = createScaleBarLayer(this.nodesView, this.canvas, this.viewState); private readonly layers = computed(() => [this.nodesLayer(), this.edgesLayer(), this.scaleBarLayer()]); + + private readonly controller = createController(this.mode); private readonly props = computed((): DeckProps => { return { - controller: getControllerOptions(this.mode()), + controller: this.controller(), layers: this.layers(), }; }); @@ -174,13 +174,15 @@ export class NodeDistVisComponent { } private onClick(info: PickingInfo): void { - if (info.picked) { - this.nodeClick.emit(info.object); + const { picked, index } = info; + if (picked) { + this.nodeClick.emit(this.nodesView().at(index)); } } private onHover(info: PickingInfo): void { - const obj = info.picked ? info.object : undefined; + const { picked, index } = info; + const obj = picked ? this.nodesView().at(index) : undefined; if (obj !== this.activeHover) { this.nodeHover.emit(obj); this.activeHover = obj; From 431f0d752dbca0b5d059ba9a228e8943ddaca74d Mon Sep 17 00:00:00 2001 From: Daniel Bolin Date: Wed, 23 Oct 2024 16:48:57 -0400 Subject: [PATCH 18/23] fix(node-dist-vis): Fix edge filtering bug --- libs/node-dist-vis/src/lib/deckgl/edges.ts | 3 ++- libs/node-dist-vis/src/lib/deckgl/nodes.ts | 8 ++++++-- libs/node-dist-vis/src/lib/deckgl/utils/filters.ts | 4 +++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/libs/node-dist-vis/src/lib/deckgl/edges.ts b/libs/node-dist-vis/src/lib/deckgl/edges.ts index 65bd835ff..3b3522a4f 100644 --- a/libs/node-dist-vis/src/lib/deckgl/edges.ts +++ b/libs/node-dist-vis/src/lib/deckgl/edges.ts @@ -39,8 +39,9 @@ export function createEdgesLayer( return createColorAccessor(cellTypeAccessor(), map); }); const filterValueAccessor = computed(() => { + const nodeIndex = edges().getCellIDFor; const filterFn = nodeFilter().includes; - return createNodeFilterAccessor(cellTypeAccessor(), filterFn); + return createNodeFilterAccessor(cellTypeAccessor(), nodeIndex, filterFn); }); return computed(() => { diff --git a/libs/node-dist-vis/src/lib/deckgl/nodes.ts b/libs/node-dist-vis/src/lib/deckgl/nodes.ts index 848ce01d1..d01a5358a 100644 --- a/libs/node-dist-vis/src/lib/deckgl/nodes.ts +++ b/libs/node-dist-vis/src/lib/deckgl/nodes.ts @@ -1,5 +1,5 @@ import { computed, Signal } from '@angular/core'; -import { COORDINATE_SYSTEM } from '@deck.gl/core/typed'; +import { AccessorContext, COORDINATE_SYSTEM } from '@deck.gl/core/typed'; import { DataFilterExtension, DataFilterExtensionProps } from '@deck.gl/extensions/typed'; import { PointCloudLayer } from '@deck.gl/layers/typed'; import { ColorMapView } from '../models/color-map'; @@ -20,6 +20,10 @@ function getNodeSize(mode: ViewMode): number { return mode === 'inspect' ? INSPECT_NODE_SIZE : DEFAULT_NODE_SIZE; } +function getIndex(_obj: unknown, info: AccessorContext): number { + return info.index; +} + export function createNodesLayer( mode: Signal, nodes: Signal, @@ -39,7 +43,7 @@ export function createNodesLayer( const filterValueAccessor = computed(() => { const accessor = nodes().getCellTypeFor; const filterFn = nodeFilter().includes; - return createNodeFilterAccessor(accessor, filterFn); + return createNodeFilterAccessor(accessor, getIndex, filterFn); }); return computed(() => { diff --git a/libs/node-dist-vis/src/lib/deckgl/utils/filters.ts b/libs/node-dist-vis/src/lib/deckgl/utils/filters.ts index 0ae9a469e..e989d4797 100644 --- a/libs/node-dist-vis/src/lib/deckgl/utils/filters.ts +++ b/libs/node-dist-vis/src/lib/deckgl/utils/filters.ts @@ -7,11 +7,13 @@ export const FILTER_RANGE: [number, number] = [0, 2]; export function createNodeFilterAccessor( accessor: AccessorFunction, + indexAccessor: AccessorFunction, filterFn: NodeFilterPredFn, ): AccessorFunction { return (obj, info) => { const type = accessor(obj, info); - const result = filterFn(type, info.index); + const index = indexAccessor(obj, info); + const result = filterFn(type, index); return result ? FILTER_INCLUDE_VALUE : FILTER_EXCLUDE_VALUE; }; } From ffa51db420066923baf6f7179587687f9b4765ff Mon Sep 17 00:00:00 2001 From: Daniel Bolin Date: Thu, 24 Oct 2024 11:52:42 -0400 Subject: [PATCH 19/23] test(node-dist-vis): Remove test files until jest + deck.gl is figured out --- .../node-dist-vis.component.spec.ts | 21 ------------------- 1 file changed, 21 deletions(-) delete mode 100644 libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.spec.ts diff --git a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.spec.ts b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.spec.ts deleted file mode 100644 index de49ac38e..000000000 --- a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { NodeDistVisComponent } from './node-dist-vis.component'; - -describe('NodeDistVisComponent', () => { - let component: NodeDistVisComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [NodeDistVisComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(NodeDistVisComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); From e7898ded122f9114e6470afbde8ab049c71b755e Mon Sep 17 00:00:00 2001 From: Daniel Bolin Date: Thu, 24 Oct 2024 12:06:14 -0400 Subject: [PATCH 20/23] test(cde-visualization): Fix a couple of imports --- .../src/lib/services/data/color-map-loader.service.spec.ts | 4 +--- .../src/lib/services/data/data-loader.service.spec.ts | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/libs/cde-visualization/src/lib/services/data/color-map-loader.service.spec.ts b/libs/cde-visualization/src/lib/services/data/color-map-loader.service.spec.ts index 807840869..c994de370 100644 --- a/libs/cde-visualization/src/lib/services/data/color-map-loader.service.spec.ts +++ b/libs/cde-visualization/src/lib/services/data/color-map-loader.service.spec.ts @@ -1,10 +1,8 @@ import { TestBed } from '@angular/core/testing'; +import { CsvFileLoaderService, FileLoaderEvent } from '@hra-ui/common/fs'; import { mock, MockProxy } from 'jest-mock-extended'; import { firstValueFrom, of } from 'rxjs'; - import { ColorMapEntry } from '../../models/color-map'; -import { CsvFileLoaderService } from '../file-loader/csv-file-loader.service'; -import { FileLoaderEvent } from '../file-loader/file-loader'; import { ColorMapFileLoaderService } from './color-map-loader.service'; describe('ColorMapLoaderService', () => { diff --git a/libs/cde-visualization/src/lib/services/data/data-loader.service.spec.ts b/libs/cde-visualization/src/lib/services/data/data-loader.service.spec.ts index 060e627dc..947b26565 100644 --- a/libs/cde-visualization/src/lib/services/data/data-loader.service.spec.ts +++ b/libs/cde-visualization/src/lib/services/data/data-loader.service.spec.ts @@ -1,9 +1,8 @@ import { Signal, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { CsvFileLoaderService, FileLoaderEvent } from '@hra-ui/common/fs'; import { MockProxy, mock } from 'jest-mock-extended'; import { of } from 'rxjs'; -import { CsvFileLoaderService } from '../file-loader/csv-file-loader.service'; -import { FileLoaderEvent } from '../file-loader/file-loader'; import { DataLoaderService } from './data-loader.service'; describe('DataLoaderService', () => { From f276d0ccccb685581b3ec9ffe44fe968f052ed81 Mon Sep 17 00:00:00 2001 From: Daniel Bolin Date: Thu, 24 Oct 2024 12:19:54 -0400 Subject: [PATCH 21/23] refactor(node-dist-vis): Cleanup & code smell fixes --- .../node-dist-vis/src/lib/models/color-map.ts | 3 +- .../node-dist-vis/src/lib/models/data-view.ts | 5 +-- libs/node-dist-vis/src/lib/models/nodes.ts | 3 +- .../node-dist-vis/node-dist-vis.component.ts | 35 +++---------------- 4 files changed, 12 insertions(+), 34 deletions(-) diff --git a/libs/node-dist-vis/src/lib/models/color-map.ts b/libs/node-dist-vis/src/lib/models/color-map.ts index cc012efac..d08a2630e 100644 --- a/libs/node-dist-vis/src/lib/models/color-map.ts +++ b/libs/node-dist-vis/src/lib/models/color-map.ts @@ -40,7 +40,8 @@ export class ColorMapView extends BaseColorMapView { range.push(this.getCellColorFor(obj)); } - return (this.colorMap = { domain, range }); + this.colorMap = { domain, range }; + return this.colorMap; }; readonly getDomain = () => this.getColorMap().domain; diff --git a/libs/node-dist-vis/src/lib/models/data-view.ts b/libs/node-dist-vis/src/lib/models/data-view.ts index 7b0f4240f..f71de9fb2 100644 --- a/libs/node-dist-vis/src/lib/models/data-view.ts +++ b/libs/node-dist-vis/src/lib/models/data-view.ts @@ -110,8 +110,9 @@ export function createDataViewClass(keys: (keyof Entry)[]): DataViewConst return DataViewImpl as unknown as DataViewConstructor; } -export function loadViewData( - input: Signal, +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function loadViewData>( + input: Signal>, viewCls: Type, ): Signal { const data = loadData(input, CsvFileLoaderService, { diff --git a/libs/node-dist-vis/src/lib/models/nodes.ts b/libs/node-dist-vis/src/lib/models/nodes.ts index 341c4a4b8..0f9bce63b 100644 --- a/libs/node-dist-vis/src/lib/models/nodes.ts +++ b/libs/node-dist-vis/src/lib/models/nodes.ts @@ -52,7 +52,8 @@ export class NodesView extends BaseNodesView { max = Math.max(max, x, y, z); } - return (this.dimensions = [min, max]); + this.dimensions = [min, max]; + return this.dimensions; }; private dimensions?: [number, number] = undefined; diff --git a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts index 75d6644fe..3283cb9a3 100644 --- a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts +++ b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts @@ -19,9 +19,9 @@ import { createNodesLayer } from '../deckgl/nodes'; import { createScaleBarLayer } from '../deckgl/scale-bar'; import { ColorMapEntry, ColorMapView, loadColorMap } from '../models/color-map'; import { AnyData, AnyDataEntry, KeyMapping } from '../models/data-view'; -import { EdgeKeysInput, EdgesInput, EdgesView, loadEdges } from '../models/edges'; +import { EdgeKeysInput, EdgesInput, loadEdges } from '../models/edges'; import { loadNodeFilter, NodeFilterInput } from '../models/filters'; -import { loadNodes, NodeKeysInput, NodesInput, NodesView } from '../models/nodes'; +import { loadNodes, NodeKeysInput, NodesInput } from '../models/nodes'; import { ViewMode } from '../models/view-mode'; // CursorState is not exported by deckgl @@ -44,30 +44,6 @@ const INITIAL_VIEW_STATE = { target: [0.5, 0.5], }; -const TEST_NODES = new NodesView( - [ - { x: 659, y: 72, position: [659, 72, 0], 'Cell Type': 'T-Helper' }, - { x: 178, y: 73, position: [178, 73, 0], 'Cell Type': 'T-Helper' }, - { x: 170, y: 74, position: [170, 74, 0], 'Cell Type': 'T-Helper' }, - { x: 173, y: 75, position: [173, 75, 0], 'Cell Type': 'T-Helper' }, - { x: 174, y: 76, position: [174, 76, 0], 'Cell Type': 'T-Helper' }, - ], - { 'Cell Type': 'Cell Type', X: 'x', Y: 'y' }, -); - -const TEST_EDGES = new EdgesView( - [ - [0, 659, 72, 0, 630, 105, 5], - [1, 178, 73, 0, 177, 71, 2], - [2, 170, 74, 0, 166, 79, 2], - [3, 173, 74, 0, 177, 71, 2], - [4, 174, 75, 0, 177, 71, 2], - ], - { 'Cell ID': 0, X1: 1, Y1: 2, Z1: 3, X2: 4, Y2: 5, Z2: 6 }, -); - -const TEST_COLOR_MAP = new ColorMapView([['T-Helper', [112, 165, 168]]], { 'Cell Type': 0, 'Cell Color': 1 }); - @Component({ selector: 'hra-node-dist-vis', standalone: true, @@ -78,7 +54,7 @@ const TEST_COLOR_MAP = new ColorMapView([['T-Helper', [112, 165, 168]]], { 'Cell export class NodeDistVisComponent { readonly mode = input('explore'); - readonly nodes = input(TEST_NODES); // TODO remove default + readonly nodes = input(); readonly nodeKeys = input(); readonly nodeTargetSelector = input(); // TODO default (must take nodeTargetValue into consideration, i.e. don't set default on this input) /** @deprecated */ @@ -86,11 +62,11 @@ export class NodeDistVisComponent { /** @deprecated */ readonly nodeTargetValue = input(); - readonly edges = input(TEST_EDGES); // TODO remove default + readonly edges = input(); readonly edgeKeys = input(); readonly maxEdgeDistance = input(); // TODO default + transform - readonly colorMap = input(TEST_COLOR_MAP); // TODO remove default + readonly colorMap = input(); readonly colorMapKeys = input | string>(); /** @deprecated */ readonly colorMapKey = input(); @@ -151,7 +127,6 @@ export class NodeDistVisComponent { constructor() { effect(() => this.deck().setProps(this.props())); - console.log(this); // TODO remove me!!! } resetView(): void { From 74fac5260d45dfe0a288f4b1309be92d7a2dca3f Mon Sep 17 00:00:00 2001 From: Daniel Bolin Date: Thu, 24 Oct 2024 18:19:18 -0400 Subject: [PATCH 22/23] refactor(node-dist-vis): :memo: Add documentation & minor type and error handling tweaks --- .../src/lib/deckgl/controller.ts | 8 + libs/node-dist-vis/src/lib/deckgl/deck.ts | 8 + libs/node-dist-vis/src/lib/deckgl/edges.ts | 10 + libs/node-dist-vis/src/lib/deckgl/nodes.ts | 27 ++- .../node-dist-vis/src/lib/deckgl/scale-bar.ts | 10 + .../src/lib/deckgl/utils/color-coding.ts | 10 + .../src/lib/deckgl/utils/filters.ts | 11 ++ .../src/lib/deckgl/utils/position-scaling.ts | 15 +- .../node-dist-vis/src/lib/models/color-map.ts | 37 ++++ .../node-dist-vis/src/lib/models/data-view.ts | 185 +++++++++++++++--- libs/node-dist-vis/src/lib/models/edges.ts | 59 ++++++ libs/node-dist-vis/src/lib/models/filters.ts | 42 +++- libs/node-dist-vis/src/lib/models/nodes.ts | 48 ++++- libs/node-dist-vis/src/lib/models/utils.ts | 43 +++- .../node-dist-vis.component.html | 8 - .../node-dist-vis.component.scss | 3 - .../node-dist-vis/node-dist-vis.component.ts | 91 ++++++++- 17 files changed, 559 insertions(+), 56 deletions(-) delete mode 100644 libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.html delete mode 100644 libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.scss diff --git a/libs/node-dist-vis/src/lib/deckgl/controller.ts b/libs/node-dist-vis/src/lib/deckgl/controller.ts index 19a076474..5543fec62 100644 --- a/libs/node-dist-vis/src/lib/deckgl/controller.ts +++ b/libs/node-dist-vis/src/lib/deckgl/controller.ts @@ -2,11 +2,19 @@ import { computed, Signal } from '@angular/core'; import { DeckProps } from '@deck.gl/core/typed'; import { ViewMode } from '../models/view-mode'; +/** Initial/default controller options */ const DEFAULT_CONTROLLER_OPTIONS: DeckProps['controller'] = true; +/** Controller options for 'select' view mode */ const SELECT_CONTROLLER_OPTIONS: DeckProps['controller'] = { dragRotate: false, }; +/** + * Get the controller options based on current view mode + * + * @param mode The view mode + * @returns Controller options + */ export function createController(mode: Signal): Signal { return computed(() => { return mode() === 'select' ? SELECT_CONTROLLER_OPTIONS : DEFAULT_CONTROLLER_OPTIONS; diff --git a/libs/node-dist-vis/src/lib/deckgl/deck.ts b/libs/node-dist-vis/src/lib/deckgl/deck.ts index 0298b2a8d..ae66414ab 100644 --- a/libs/node-dist-vis/src/lib/deckgl/deck.ts +++ b/libs/node-dist-vis/src/lib/deckgl/deck.ts @@ -1,6 +1,14 @@ import { computed, effect, Signal } from '@angular/core'; import { Deck, DeckProps } from '@deck.gl/core/typed'; +/** + * Create a deck instance. Automatically cleans up the deck when the + * surrounding injection context is destroyed. + * + * @param canvas Canvas element + * @param props Additional deckgl props + * @returns A deck instance + */ export function createDeck(canvas: Signal, props: DeckProps): Signal { const deck = computed(() => { return new Deck({ diff --git a/libs/node-dist-vis/src/lib/deckgl/edges.ts b/libs/node-dist-vis/src/lib/deckgl/edges.ts index 3b3522a4f..56987e4b5 100644 --- a/libs/node-dist-vis/src/lib/deckgl/edges.ts +++ b/libs/node-dist-vis/src/lib/deckgl/edges.ts @@ -11,8 +11,18 @@ import { createColorAccessor } from './utils/color-coding'; import { createNodeFilterAccessor, FILTER_RANGE } from './utils/filters'; import { createScaledPositionAccessor } from './utils/position-scaling'; +/** Edges layer */ export type EdgesLayer = LineLayer>; +/** + * Create a deckgl layer for rendering edges + * + * @param nodes Nodes data view + * @param edges Edges data view + * @param nodeFilter Node filter + * @param colorMap Color map + * @returns A deckgl layer + */ export function createEdgesLayer( nodes: Signal, edges: Signal, diff --git a/libs/node-dist-vis/src/lib/deckgl/nodes.ts b/libs/node-dist-vis/src/lib/deckgl/nodes.ts index d01a5358a..c0cec4f6e 100644 --- a/libs/node-dist-vis/src/lib/deckgl/nodes.ts +++ b/libs/node-dist-vis/src/lib/deckgl/nodes.ts @@ -11,19 +11,44 @@ import { createColorAccessor } from './utils/color-coding'; import { createNodeFilterAccessor, FILTER_RANGE } from './utils/filters'; import { createScaledPositionAccessor } from './utils/position-scaling'; +/** Nodes layer */ export type NodesLayer = PointCloudLayer>; -const DEFAULT_NODE_SIZE = 5; // 1.5; +/** Default/initial node size */ +const DEFAULT_NODE_SIZE = 1.5; +/** Node size in the 'inspect' view mode */ const INSPECT_NODE_SIZE = 3; +/** + * Get the node size based on the view mode + * + * @param mode Current view mode + * @returns The node size + */ function getNodeSize(mode: ViewMode): number { return mode === 'inspect' ? INSPECT_NODE_SIZE : DEFAULT_NODE_SIZE; } +/** + * Accessor for getting a node's index + * + * @param _obj Raw node data object + * @param info Accessor context + * @returns The index of the node + */ function getIndex(_obj: unknown, info: AccessorContext): number { return info.index; } +/** + * Create a deckgl for rendering nodes + * + * @param mode View mode + * @param nodes Nodes view + * @param nodeFilter Nodes filter + * @param colorMap Color map + * @returns A deckgl layer + */ export function createNodesLayer( mode: Signal, nodes: Signal, diff --git a/libs/node-dist-vis/src/lib/deckgl/scale-bar.ts b/libs/node-dist-vis/src/lib/deckgl/scale-bar.ts index e9850dc7a..32888a1c7 100644 --- a/libs/node-dist-vis/src/lib/deckgl/scale-bar.ts +++ b/libs/node-dist-vis/src/lib/deckgl/scale-bar.ts @@ -3,9 +3,19 @@ import { Layer } from '@deck.gl/core/typed'; import { ScaleBarLayer as ScaleBarLayerConstructor } from '@vivjs/layers'; import { NodesView } from '../models/nodes'; +/** Scale bar layer props. Not exported by `@vivjs/layers` */ type ScaleBarLayerProps = ConstructorParameters[0]; +/** Scale bar layer */ export type ScaleBarLayer = Layer; +/** + * Create a deckgl for rendering a scale bar + * + * @param nodes Nodes view + * @param viewSize Size of view element + * @param viewState Current state of deckgl + * @returns A deckgl layer + */ export function createScaleBarLayer( nodes: Signal, viewSize: Signal<{ width: number; height: number }>, diff --git a/libs/node-dist-vis/src/lib/deckgl/utils/color-coding.ts b/libs/node-dist-vis/src/lib/deckgl/utils/color-coding.ts index 2afce9f7f..23cab150e 100644 --- a/libs/node-dist-vis/src/lib/deckgl/utils/color-coding.ts +++ b/libs/node-dist-vis/src/lib/deckgl/utils/color-coding.ts @@ -2,10 +2,20 @@ import { AccessorContext, AccessorFunction, Color } from '@deck.gl/core/typed'; import { ColorMap } from '../../models/color-map'; import { colorCategories } from '@deck.gl/carto/typed'; +/** Color format expected by `colorCategories` */ type Color2 = [r: number, g: number, b: number, a?: number]; +/** Color used if no default is provided */ const WHITE: Color2 = [255, 255, 255]; +/** + * Create a color accessor + * + * @param accessor Domain value accessor + * @param colorMap Color map + * @param defaultColor Default color + * @returns An accessor + */ export function createColorAccessor( accessor: AccessorFunction, colorMap: ColorMap, diff --git a/libs/node-dist-vis/src/lib/deckgl/utils/filters.ts b/libs/node-dist-vis/src/lib/deckgl/utils/filters.ts index e989d4797..db088b813 100644 --- a/libs/node-dist-vis/src/lib/deckgl/utils/filters.ts +++ b/libs/node-dist-vis/src/lib/deckgl/utils/filters.ts @@ -1,10 +1,21 @@ import { AccessorFunction } from '@deck.gl/core/typed'; import { NodeFilterPredFn } from '../../models/filters'; +/** Value used to indicate that an item is in the filter */ const FILTER_INCLUDE_VALUE = 1; +/** Value used to indicate that an item is not in the filter */ const FILTER_EXCLUDE_VALUE = 3; +/** Filter value range. Must be set so it includes `FILTER_INCLUDE_VALUE` and excludes `FILTER_EXCLUDE_VALUE` */ export const FILTER_RANGE: [number, number] = [0, 2]; +/** + * Create a filter accessor + * + * @param accessor Type value accessor + * @param indexAccessor Item index accessor + * @param filterFn Filter predicate + * @returns An accessor + */ export function createNodeFilterAccessor( accessor: AccessorFunction, indexAccessor: AccessorFunction, diff --git a/libs/node-dist-vis/src/lib/deckgl/utils/position-scaling.ts b/libs/node-dist-vis/src/lib/deckgl/utils/position-scaling.ts index 526224a07..532c58ab7 100644 --- a/libs/node-dist-vis/src/lib/deckgl/utils/position-scaling.ts +++ b/libs/node-dist-vis/src/lib/deckgl/utils/position-scaling.ts @@ -1,5 +1,12 @@ import { AccessorFunction, Position } from '@deck.gl/core/typed'; +/** + * Create a position accessor that scales position to the range [-1, 1] + * + * @param accessor Unscaled position accessor + * @param dimensions Dimensions of visualization + * @returns An accessor + */ export function createScaledPositionAccessor( accessor: AccessorFunction, dimensions: [number, number], @@ -9,7 +16,11 @@ export function createScaledPositionAccessor( const scale = (value: number) => (value - min) / diff; return (obj, info) => { - const [x, y, z] = accessor(obj, info); - return [scale(x), 1 - scale(y), scale(z)]; + const { target } = info; + const position = accessor(obj, info); + target[0] = scale(position[0]); + target[1] = 1 - scale(position[1]); + target[2] = scale(position[2] ?? 0); + return target as Position; }; } diff --git a/libs/node-dist-vis/src/lib/models/color-map.ts b/libs/node-dist-vis/src/lib/models/color-map.ts index d08a2630e..a47270e29 100644 --- a/libs/node-dist-vis/src/lib/models/color-map.ts +++ b/libs/node-dist-vis/src/lib/models/color-map.ts @@ -10,24 +10,41 @@ import { loadViewKeyMapping, } from './data-view'; +/** Color map input */ export type ColorMapInput = DataViewInput; +/** Color map key mapping input */ export type ColorMapKeysInput = KeyMappingInput; +/** Color map entry */ export interface ColorMapEntry { + /** Cell type */ 'Cell Type': string; + /** Cell color */ 'Cell Color': Color; } +/** Color map */ export interface ColorMap { + /** Unique cell types */ domain: string[]; + /** Associated cell type colors */ range: Color[]; } +/** Required color map keys */ const REQUIRED_KEYS: (keyof ColorMapEntry)[] = ['Cell Type', 'Cell Color']; +/** Optional color map keys */ const OPTIONAL_KEYS: (keyof ColorMapEntry)[] = []; +/** Base data view class for color map */ const BaseColorMapView = createDataViewClass([...REQUIRED_KEYS, ...OPTIONAL_KEYS]); +/** Color map view */ export class ColorMapView extends BaseColorMapView { + /** + * Get the `ColorMap` for this view + * + * @returns A `ColorMap` + */ readonly getColorMap = () => { if (this.colorMap) { return this.colorMap; @@ -44,12 +61,32 @@ export class ColorMapView extends BaseColorMapView { return this.colorMap; }; + /** + * Get the domain of the color map + * + * @returns An array of unique domain values + */ readonly getDomain = () => this.getColorMap().domain; + /** + * Get the range of the color map + * + * @returns An array of colors associated with the domain values + */ readonly getRange = () => this.getColorMap().range; + /** Cached color map object */ private colorMap?: ColorMap = undefined; } +/** + * Load a color map + * + * @param input Raw color map input + * @param keys Raw color mak key mapping input + * @param colorMapKey Backwards compatable 'Cell Type' key mapping + * @param colorMapValue Backwards compatable 'Cell Color' key mapping + * @returns A color map view + */ export function loadColorMap( input: Signal, keys: Signal, diff --git a/libs/node-dist-vis/src/lib/models/data-view.ts b/libs/node-dist-vis/src/lib/models/data-view.ts index f71de9fb2..bf7ce19a0 100644 --- a/libs/node-dist-vis/src/lib/models/data-view.ts +++ b/libs/node-dist-vis/src/lib/models/data-view.ts @@ -1,65 +1,129 @@ -import { computed, Signal, Type } from '@angular/core'; +import { computed, ErrorHandler, inject, Signal, Type } from '@angular/core'; import { CsvFileLoaderService, JsonFileLoaderService } from '@hra-ui/common/fs'; -import { isRecordObject, loadData } from './utils'; +import { DataInput, isRecordObject, loadData } from './utils'; +/** Removes all whitespaces in a string */ type RemoveWhiteSpace = S extends `${infer Pre} ${infer Post}` ? RemoveWhiteSpace<`${Pre}${Post}`> : S; +/** 'At' accessors take an index argument while 'For' accessors takes the data object */ type AccessorPostfixes = 'At' | 'For'; +/** Creates an accessor name from a property name */ type AccessorName< Entry, P extends keyof Entry, Postfix extends AccessorPostfixes, > = `get${Capitalize>}${Postfix}`; +/** Accessor function type */ type Accessor = (arg: Arg) => Entry[P]; +/** View data entry */ export type AnyDataEntry = unknown[] | object; +/** View data */ export type AnyData = unknown[][] | object[]; + +/** Data view input */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type DataViewInput> = V | AnyData | string | undefined; +export type DataViewInput> = DataInput; + +/** Mapping for each entry key to the actual data's properties */ export type KeyMapping = { [P in keyof Entry]: PropertyKey }; -export type KeyMappingWithDataOffset = KeyMapping & { [DATA_VIEW_OFFSET]?: number }; +/** + * Additional key mapping entries mixed into the mapping. + * Used to merge backwards compatibility inputs into the key mapping. + */ export type KeyMappingMixins = { [P in keyof Entry]?: Signal }; -export type KeyMappingInput = Partial> | string | undefined; +/** Key mapping input */ +export type KeyMappingInput = DataInput>>; +/** Accessors automatically created by a data view */ export type DataViewAccessors = { [P in keyof Entry as AccessorName]-?: Accessor; } & { [P in keyof Entry as AccessorName]-?: Accessor; }; +/** Data view */ export interface DataView { + /** Property names of the entry type */ readonly keys: (keyof Entry)[]; + /** Raw underlying data for the view */ readonly data: AnyData; + /** Mapping from entry property names to properties in the raw data */ readonly keyMapping: KeyMapping; + /** Start offset of the first item in the data array */ readonly offset: number; + /** Number of items in the data (raw data length minus the offset) */ readonly length: number; + /** + * Gets the raw item at a specific index. Does **not** accept negative indices + * + * @param index Index of the item + * @returns The raw data object + */ readonly at: (index: number) => AnyDataEntry; + /** + * Gets a property for the item at the specified index + * + * @param index Index of the item + * @param property Property to read + * @returns The property's value + */ readonly getPropertyAt:

(index: number, property: P) => Entry[P]; + /** + * Gets a property for a raw data object + * + * @param obj Raw data object + * @param property Property to read + * @returns The property's value + */ readonly getPropertyFor:

(obj: AnyDataEntry, property: P) => Entry[P]; + /** Raw data iterator */ [Symbol.iterator](): IterableIterator; } -export interface DataViewConstructor { - new (data: AnyData, keyMapping: KeyMapping, offset?: number): DataView & DataViewAccessors; -} - -export const DATA_VIEW_OFFSET = Symbol('data offset'); - +/** Data view constructor */ +export type DataViewConstructor = new ( + data: AnyData, + keyMapping: KeyMapping, + offset?: number, +) => DataView & DataViewAccessors; + +/** + * Create an accessor name for a property + * + * @param property Property name + * @param postfix Accessor postfix + * @returns An accessor name + */ function createAccessorName(property: keyof Entry, postfix: AccessorPostfixes): string { const trimmedProperty = String(property).replace(/\s+/g, ''); const capitalizedProperty = trimmedProperty.slice(0, 1).toUpperCase() + trimmedProperty.slice(1); return `get${capitalizedProperty}${postfix}`; } +/** + * Creates a new accessor bound to a data view + * + * @param instance Instance to bind the accessor to + * @param property Property to access + * @param postfix Accessor prostfix + * @returns A bound accessor function + */ function createAccessor(instance: DataView, property: keyof Entry, postfix: AccessorPostfixes) { const method = `getProperty${postfix}` as const; return (arg: unknown) => instance[method](arg as never, property); } +/** + * Creates and attaches accessors for each entry property on a data view + * + * @param instance Data view instance + * @param keys Entry property keys + */ function attachAccessors(instance: DataView, keys: (keyof Entry)[]): void { const postfixes: AccessorPostfixes[] = ['At', 'For']; for (const key of keys) { @@ -71,6 +135,12 @@ function attachAccessors(instance: DataView, keys: (keyof Entry)[] } } +/** + * Create a new data view base class + * + * @param keys Entry property keys + * @returns A data view base class + */ export function createDataViewClass(keys: (keyof Entry)[]): DataViewConstructor { class DataViewImpl implements DataView { readonly keys = keys; @@ -110,6 +180,14 @@ export function createDataViewClass(keys: (keyof Entry)[]): DataViewConst return DataViewImpl as unknown as DataViewConstructor; } +/** + * Loads view data from either json encoded input, a file or url, + * an existing data view instance, or an array of raw data + * + * @param input Raw data view input + * @param viewCls Data view class + * @returns Either a data view of the specified type or an array of raw data + */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function loadViewData>( input: Signal>, @@ -129,8 +207,16 @@ export function loadViewData>( }); } +/** + * Loads a key mapping from either json encoded input, a file or url, + * or an existing key mapping object + * + * @param input Raw key mapping input + * @param mixins Additional mappings for backwards compatability + * @returns A partial key mapping + */ export function loadViewKeyMapping( - input: Signal> | string | undefined>, + input: Signal>, mixins: KeyMappingMixins = {}, ): Signal>> { const data = loadData(input, JsonFileLoaderService, {}); @@ -154,11 +240,39 @@ export function loadViewKeyMapping( }); } -function inferViewKeyMappingImpl( - entry: AnyDataEntry, - mapping: Partial>, - keys: (keyof T)[], -): void { +/** Type with the `DATA_VIEW_OFFSET` property */ +type WithDataViewOffset = Partial>; +/** Symbol used to "smuggle" the offset between inferViewKeyMapping and createDataView */ +const DATA_VIEW_OFFSET = Symbol('DataView offset'); + +/** + * Gets the `DATA_VIEW_OFFSET` stored in a key mapping + * + * @param mapping Key mapping + * @returns The offset if present + */ +function getDataViewOffset(mapping: Partial>): number | undefined { + return (mapping as WithDataViewOffset)[DATA_VIEW_OFFSET]; +} + +/** + * Sets a new `DATA_VIEW_OFFSET` in a key mapping + * + * @param mapping Key mapping + * @param offset New offset value + */ +function setDataViewOffset(mapping: Partial>, offset: number): void { + (mapping as WithDataViewOffset)[DATA_VIEW_OFFSET] = offset; +} + +/** + * Attempts to infer key mapping properties from raw data + * + * @param entry The first raw data entry in the data array + * @param mapping Mapping to update with inferred keys + * @param keys Expected entry property keys + */ +function inferViewKeyMappingImpl(entry: AnyDataEntry, mapping: Partial>, keys: (keyof T)[]): void { const icase = (value: unknown) => String(value).toLowerCase(); const isArrayEntry = Array.isArray(entry); let header: unknown[]; @@ -170,7 +284,7 @@ function inferViewKeyMappingImpl( header = keys.slice(0, 7); } else { header = entry; - mapping[DATA_VIEW_OFFSET] = 1; + setDataViewOffset(mapping, 1); } } else { header = Object.keys(entry); @@ -188,6 +302,13 @@ function inferViewKeyMappingImpl( } } +/** + * Validates an inferred key mapping + * + * @param mapping Inferred key mapping + * @param requiredKeys Required entry property keys + * @returns undefined if valid, otherwise an error describing the issue + */ function validateViewKeyMapping(mapping: Partial>, requiredKeys: (keyof T)[]): Error | void { const missingKeys: (keyof T)[] = []; for (const key of requiredKeys) { @@ -201,12 +322,22 @@ function validateViewKeyMapping(mapping: Partial>, requiredKeys } } +/** + * Infers a complete key mapping from the data and a partial key mapping + * + * @param data View data + * @param mapping Partial existing key mapping + * @param requiredKeys Required property keys + * @param optionalKeys Optional property keys + * @returns A complete key mapping on success, otherwise undefined + */ export function inferViewKeyMapping( data: Signal | AnyData>, mapping: Signal>>, requiredKeys: (keyof T)[], optionalKeys: (keyof T)[], -): Signal | undefined> { +): Signal | undefined> { + const errorHandler = inject(ErrorHandler); const keys = [...requiredKeys, ...optionalKeys]; const defaultArrayKeyMapping = {} as KeyMapping; keys.forEach((key, index) => (defaultArrayKeyMapping[key] = index)); @@ -224,17 +355,27 @@ export function inferViewKeyMapping( const error = validateViewKeyMapping(viewMapping, requiredKeys); if (error !== undefined) { + errorHandler.handleError(error); return undefined; } - return viewMapping as KeyMappingWithDataOffset; + return viewMapping as KeyMapping; }); } +/** + * Create a data view from data and key mapping + * + * @param viewCls Data view class + * @param data Already existing data view or array of raw data + * @param keyMapping Inferred key mapping for the raw data + * @param defaultView Default data view returned missing a data or key mapping + * @returns A data view of the specified class + */ export function createDataView( viewCls: new (data: AnyData, keyMapping: KeyMapping, offset?: number) => V, data: Signal, - keyMapping: Signal | undefined>, + keyMapping: Signal | undefined>, defaultView: V, ): Signal { return computed(() => { @@ -245,7 +386,7 @@ export function createDataView( const viewMapping = keyMapping(); if (viewMapping !== undefined) { - return new viewCls(viewData as AnyData, viewMapping, viewMapping[DATA_VIEW_OFFSET]); + return new viewCls(viewData as AnyData, viewMapping, getDataViewOffset(viewMapping)); } return defaultView; diff --git a/libs/node-dist-vis/src/lib/models/edges.ts b/libs/node-dist-vis/src/lib/models/edges.ts index f1c921be4..e3f4d34da 100644 --- a/libs/node-dist-vis/src/lib/models/edges.ts +++ b/libs/node-dist-vis/src/lib/models/edges.ts @@ -11,26 +11,59 @@ import { loadViewKeyMapping, } from './data-view'; +/** Edges input */ export type EdgesInput = DataViewInput; +/** Edges key mapping input */ export type EdgeKeysInput = KeyMappingInput; +/** Edge entry */ export interface EdgeEntry { + /** Source node index */ 'Cell ID': number; + /** Source X coordinate */ X1: number; + /** Source Y coordinate */ Y1: number; + /** Source Z coordinate */ Z1: number; + /** Target X coordinate */ X2: number; + /** Target Y coordinate */ Y2: number; + /** Target Z coordinate */ Z2: number; } +/** Required edge keys */ const REQUIRED_KEYS: (keyof EdgeEntry)[] = ['Cell ID', 'X1', 'Y1', 'Z1', 'X2', 'Y2', 'Z2']; +/** Optional edge keys */ const OPTIONAL_KEYS: (keyof EdgeEntry)[] = []; +/** Base data view class for edges */ const BaseEdgesView = createDataViewClass([...REQUIRED_KEYS, ...OPTIONAL_KEYS]); +/** Edges view */ export class EdgesView extends BaseEdgesView { + /** + * Get the source position of an edge. + * If an accessor context is provided the preallocated target + * array will be filled out and returned instead of a new array. + * + * @param index Index of data entry + * @param info Optional accessor context + * @returns The source position in format [x, y, z] + */ readonly getSourcePositionAt = (index: number, info?: AccessorContext) => this.getSourcePositionFor(this.data[index], info); + + /** + * Get the source position of an edge. + * If an accessor context is provided the preallocated target + * array will be filled out and returned instead of a new array. + * + * @param obj Raw edge data entry + * @param info Optional accessor context + * @returns The source position in format [x, y, z] + */ readonly getSourcePositionFor = ( obj: AnyDataEntry, info?: AccessorContext, @@ -42,8 +75,27 @@ export class EdgesView extends BaseEdgesView { return position; }; + /** + * Get the target position of an edge. + * If an accessor context is provided the preallocated target + * array will be filled out and returned instead of a new array. + * + * @param index Index of data entry + * @param info Optional accessor context + * @returns The target position in format [x, y, z] + */ readonly getTargetPositionAt = (index: number, info?: AccessorContext) => this.getTargetPositionFor(this.data[index], info); + + /** + * Get the target position of an edge. + * If an accessor context is provided the preallocated target + * array will be filled out and returned instead of a new array. + * + * @param obj Raw edge data entry + * @param info Optional accessor context + * @returns The target position in format [x, y, z] + */ readonly getTargetPositionFor = ( obj: AnyDataEntry, info?: AccessorContext, @@ -56,6 +108,13 @@ export class EdgesView extends BaseEdgesView { }; } +/** + * Load edges + * + * @param input Raw edges input + * @param keys Raw edges key mapping input + * @returns A edges view + */ export function loadEdges(input: Signal, keys: Signal): Signal { const data = loadViewData(input, EdgesView); const mapping = loadViewKeyMapping(keys); diff --git a/libs/node-dist-vis/src/lib/models/filters.ts b/libs/node-dist-vis/src/lib/models/filters.ts index b6eae5cb6..4d144daaa 100644 --- a/libs/node-dist-vis/src/lib/models/filters.ts +++ b/libs/node-dist-vis/src/lib/models/filters.ts @@ -1,36 +1,59 @@ import { computed, Signal } from '@angular/core'; import { JsonFileLoaderService } from '@hra-ui/common/fs'; -import { isRecordObject, loadData } from './utils'; +import { DataInput, isRecordObject, loadData } from './utils'; +/** Node filter data entry */ export type NodeFilterEntry = string | number; -export type NodeFilterInput = NodeFilter | string | undefined; +/** Node filter input */ +export type NodeFilterInput = DataInput; +/** Node filter predicate signature */ export type NodeFilterPredFn = (type: string, index: number) => boolean; +/** Node filter */ export interface NodeFilter { + /** Node types and indices to include */ include?: NodeFilterEntry[]; + /** Node types and indices to exclude */ exclude?: NodeFilterEntry[]; } +/** Function that always return true */ function truthy(): boolean { return true; } +/** Function that always return false */ function falsy(): boolean { return false; } +/** Node filter view */ export class NodeFilterView { + /** Predicate that tests whether a node is included in the filter */ readonly includes = this.selectFilterFn(); + + /** + * Get whether the filter is empty + * + * @returns Whether the filter is empty, i.e. all nodes are included + */ readonly isEmpty = () => { const { include, exclude = [] } = this; return include === undefined && exclude.length === 0; }; + /** Initialize the filter */ constructor( readonly include: NodeFilterEntry[] | undefined, readonly exclude: NodeFilterEntry[] | undefined, ) {} + /** + * Selects a node filter predicate function based on whether + * parts of the filter is empty + * + * @returns A node filter predicate function + */ private selectFilterFn(): NodeFilterPredFn { const { include, exclude = [] } = this; const includeFn = this.createFilterFn(include); @@ -47,15 +70,28 @@ export class NodeFilterView { } } + /** + * Create a filter predicate for some entries + * + * @param entries Filter entries + * @returns A filter predicate that returns true for value in the entries + */ private createFilterFn(entries: NodeFilterEntry[] | undefined): NodeFilterPredFn { const entriesSet = new Set(entries); return (type, index) => entriesSet.has(type) || entriesSet.has(index); } } +/** + * Load a node filter + * + * @param input Node filter raw input + * @param selection Backwards compatable node filter include array + * @returns A node filter view + */ export function loadNodeFilter( input: Signal, - selection: Signal, + selection: Signal>, ): Signal { const data = loadData(input, JsonFileLoaderService, {}); const selectionData = loadData(selection, JsonFileLoaderService, {}); diff --git a/libs/node-dist-vis/src/lib/models/nodes.ts b/libs/node-dist-vis/src/lib/models/nodes.ts index 0f9bce63b..682a63a73 100644 --- a/libs/node-dist-vis/src/lib/models/nodes.ts +++ b/libs/node-dist-vis/src/lib/models/nodes.ts @@ -1,4 +1,5 @@ import { Signal } from '@angular/core'; +import { AccessorContext } from '@deck.gl/core/typed'; import { AnyDataEntry, createDataView, @@ -9,26 +10,56 @@ import { loadViewData, loadViewKeyMapping, } from './data-view'; -import { AccessorContext } from '@deck.gl/core/typed'; +/** Node view input */ export type NodesInput = DataViewInput; +/** Node view key mapping input */ export type NodeKeysInput = KeyMappingInput; +/** Node entry */ export interface NodeEntry { + /** Cell type */ 'Cell Type': string; + /** Optional cell ontology id */ 'Cell Ontology ID'?: string; + /** X coordinate */ X: number; + /** Y coordinate */ Y: number; + /** Optional Z coordinate */ Z?: number; } +/** Required node keys */ const REQUIRED_KEYS: (keyof NodeEntry)[] = ['Cell Type', 'X', 'Y']; +/** Optional node keys */ const OPTIONAL_KEYS: (keyof NodeEntry)[] = ['Cell Ontology ID', 'Z']; +/** Base nodes view class */ const BaseNodesView = createDataViewClass([...REQUIRED_KEYS, ...OPTIONAL_KEYS]); +/** Nodes view */ export class NodesView extends BaseNodesView { + /** + * Get the position of a node. + * If an accessor context is provided the preallocated target + * array will be filled out and returned instead of a new array. + * + * @param index Index of data entry + * @param info Optional accessor context + * @returns The position in format [x, y, z] + */ readonly getPositionAt = (index: number, info?: AccessorContext) => this.getPositionFor(this.data[index], info); + + /** + * Get the position of a node. + * If an accessor context is provided the preallocated target + * array will be filled out and returned instead of a new array. + * + * @param obj Raw node data entry + * @param info Optional accessor context + * @returns The position in format [x, y, z] + */ readonly getPositionFor = (obj: AnyDataEntry, info?: AccessorContext): [number, number, number] => { const position = (info?.target ?? new Array(3)) as [number, number, number]; position[0] = this.getXFor(obj); @@ -37,6 +68,12 @@ export class NodesView extends BaseNodesView { return position; }; + /** + * Get the dimensions (sometimes called 'extent') of all nodes + * across the X, Y, and Z axes + * + * @returns An array of [minimum, maximum] values + */ readonly getDimensions = (): [number, number] => { if (this.dimensions) { return this.dimensions; @@ -56,9 +93,18 @@ export class NodesView extends BaseNodesView { return this.dimensions; }; + /** Cached dimensions */ private dimensions?: [number, number] = undefined; } +/** + * Load nodes + * + * @param input Raw nodes input + * @param keys Raw nodes key mapping input + * @param nodeTargetKey Backwards compatable 'Cell Type' key mapping + * @returns A nodes view + */ export function loadNodes( input: Signal, keys: Signal, diff --git a/libs/node-dist-vis/src/lib/models/utils.ts b/libs/node-dist-vis/src/lib/models/utils.ts index 2d8c797fe..7b36d1f54 100644 --- a/libs/node-dist-vis/src/lib/models/utils.ts +++ b/libs/node-dist-vis/src/lib/models/utils.ts @@ -1,12 +1,27 @@ -import { inject, Signal, Type } from '@angular/core'; +import { ErrorHandler, inject, Signal, Type } from '@angular/core'; import { FileLoader } from '@hra-ui/common/fs'; import { derivedAsync } from 'ngxtension/derived-async'; -import { filter, map } from 'rxjs'; +import { catchError, EMPTY, filter, map } from 'rxjs'; +/** Accepted data input types */ +export type DataInput = T | File | URL | string | undefined; + +/** + * Tests whether a value is a plain object + * + * @param obj Object to test + * @returns True if `obj` is a plain object, otherwise false + */ export function isRecordObject(obj: unknown): obj is Record { - return typeof obj === 'object' && obj !== null; + return typeof obj === 'object' && obj !== null && !Array.isArray(obj); } +/** + * Tries to parse a value as json + * + * @param value Value to parse + * @returns Parsed json value if possible, otherwise the original value + */ export function tryParseJson(value: unknown): unknown { try { if (typeof value === 'string') { @@ -19,18 +34,34 @@ export function tryParseJson(value: unknown): unknown { return value; } +/** + * Loads data from either an url, file, json encoded string, or passed directly. + * The resulting signal value is undefined until data has has been sucessfully loaded. + * + * @param input Raw input + * @param loaderService Service to load urls and files + * @param options File loader options + * @returns Loaded data + */ export function loadData( - input: Signal, + input: Signal>, loaderService: Type>, options: Opts, ): Signal { const loader = inject(loaderService); + const errorHandler = inject(ErrorHandler); + return derivedAsync(() => { const data = tryParseJson(input()); - if (typeof data === 'string' || data instanceof File) { - return loader.load(data, options).pipe( + if (typeof data === 'string' || data instanceof File || data instanceof URL) { + const source = data instanceof URL ? data.toString() : data; + return loader.load(source, options).pipe( filter((event) => event.type === 'data'), map((event) => event.data), + catchError((error) => { + errorHandler.handleError(error); + return EMPTY; + }), ); } diff --git a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.html b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.html deleted file mode 100644 index 49c336961..000000000 --- a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.html +++ /dev/null @@ -1,8 +0,0 @@ -

node-dist-vis works!

- diff --git a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.scss b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.scss deleted file mode 100644 index 5d4e87f30..000000000 --- a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -:host { - display: block; -} diff --git a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts index 3283cb9a3..01d957875 100644 --- a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts +++ b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts @@ -23,14 +23,16 @@ import { EdgeKeysInput, EdgesInput, loadEdges } from '../models/edges'; import { loadNodeFilter, NodeFilterInput } from '../models/filters'; import { loadNodes, NodeKeysInput, NodesInput } from '../models/nodes'; import { ViewMode } from '../models/view-mode'; +import { TEST_COLOR_MAP, TEST_EDGES, TEST_NODES } from './test-data'; -// CursorState is not exported by deckgl +/** CursorState is not exported by deckgl */ type CursorState = Parameters>[0]; -// OrbitView's constructor is poorly typed +/** OrbitView's constructor is poorly typed */ type OrbitViewProps = ConstructorParameters[0] & ConstructorParameters>[0]; +/** Initial visualization deckgl state */ const INITIAL_VIEW_STATE = { version: 0, orbitAxis: 'Y', @@ -44,6 +46,7 @@ const INITIAL_VIEW_STATE = { target: [0.5, 0.5], }; +/** Node distance visualization */ @Component({ selector: 'hra-node-dist-vis', standalone: true, @@ -52,36 +55,71 @@ const INITIAL_VIEW_STATE = { changeDetection: ChangeDetectionStrategy.OnPush, }) export class NodeDistVisComponent { + /** View mode of the visualization */ readonly mode = input('explore'); - readonly nodes = input(); + /** Node data */ + readonly nodes = input(TEST_NODES); + /** Node key mapping data */ readonly nodeKeys = input(); + /** Node target selector used when calculating edges */ readonly nodeTargetSelector = input(); // TODO default (must take nodeTargetValue into consideration, i.e. don't set default on this input) - /** @deprecated */ + /** + * Column/property of the node's 'Cell Type' values + * + * @deprecated Use `nodeKeys` to specify the column instead + */ readonly nodeTargetKey = input(); - /** @deprecated */ + /** + * Node target selector used when calculating edges + * + * @deprecated Use `nodeTargetSelector` instead + */ readonly nodeTargetValue = input(); - readonly edges = input(); + /** Edge data if already calculated */ + readonly edges = input(TEST_EDGES); + /** Edge key mapping data */ readonly edgeKeys = input(); + /** Max distance to consider when calculating edges */ readonly maxEdgeDistance = input(); // TODO default + transform - readonly colorMap = input(); + /** Color map data */ + readonly colorMap = input(TEST_COLOR_MAP); + /** Color map key mapping data */ readonly colorMapKeys = input | string>(); - /** @deprecated */ + /** + * Column/property of the color map's 'Cell Type' values + * + * @deprecated Use `colorMapKeys` to specify the column instead + */ readonly colorMapKey = input(); - /** @deprecated */ + /** + * Column/property of the color map's 'Cell Color' values + * + * @deprecated Use `colorMapKeys` to specify the column instead + */ readonly colorMapValue = input(); + /** Node filter data */ readonly nodeFilter = input(); - /** @deprecated */ + /** + * Node 'Cell Type's to display + * + * @deprecated Use `nodeFilter`'s `include` property to specify included nodes instead + */ readonly selection = input(); + /** Emits when the user clicks on a node */ readonly nodeClick = output(); + /** Emits when the user starts or stops hovering over a node */ readonly nodeHover = output(); + /** Emits when the user selects one or more nodes in the 'select' view mode */ readonly nodeSelectionChange = output(); // TODO fix type + /** Reference to the rendered canvas element */ readonly canvas = computed(() => this.canvasElementRef().nativeElement); + /** Reference to the deckgl instance */ readonly deck = createDeck(this.canvas, { controller: true, views: new OrbitView({ id: 'orbit', orbitAxis: 'Y' } as OrbitViewProps), @@ -94,28 +132,42 @@ export class NodeDistVisComponent { onError: (error) => this.errorHandler.handleError(error), }); + /** Canvas element wrapped inside an `ElementRef` */ private readonly canvasElementRef = viewChild.required>('canvas'); + /** Error handler for the application */ private readonly errorHandler = inject(ErrorHandler); + /** Current version value of the deckgl view state */ private viewStateVersion = INITIAL_VIEW_STATE.version; + /** Current deckgl view state */ private readonly viewState = signal(INITIAL_VIEW_STATE); + /** View of the node data */ private readonly nodesView = loadNodes(this.nodes, this.nodeKeys, this.nodeTargetKey); + /** View of the edge data */ private readonly edgesView = loadEdges(this.edges, this.edgeKeys); + /** View of the color map */ private readonly colorMapView = loadColorMap(this.colorMap, this.colorMapKeys, this.colorMapKey, this.colorMapValue); + /** View of the node filter */ private readonly nodeFilterView = loadNodeFilter(this.nodeFilter, this.selection); + /** Node layer */ private readonly nodesLayer = createNodesLayer(this.mode, this.nodesView, this.nodeFilterView, this.colorMapView); + /** Edge layer */ private readonly edgesLayer = createEdgesLayer( this.nodesView, this.edgesView, this.nodeFilterView, this.colorMapView, ); + /** Scale bar layer */ private readonly scaleBarLayer = createScaleBarLayer(this.nodesView, this.canvas, this.viewState); + /** All layers as an array */ private readonly layers = computed(() => [this.nodesLayer(), this.edgesLayer(), this.scaleBarLayer()]); + /** Controller options */ private readonly controller = createController(this.mode); + /** Deckgl props */ private readonly props = computed((): DeckProps => { return { controller: this.controller(), @@ -123,12 +175,15 @@ export class NodeDistVisComponent { }; }); + /** Currently hovered node entry */ private activeHover: AnyDataEntry | undefined = undefined; + /** Initialize the visualization */ constructor() { effect(() => this.deck().setProps(this.props())); } + /** Resets the view to the original location and rotation */ resetView(): void { this.deck().setProps({ initialViewState: { @@ -138,6 +193,12 @@ export class NodeDistVisComponent { }); } + /** + * Get the cursor to display + * + * @param state Cursor state + * @returns Which cursor to display + */ private getCursor({ isDragging, isHovering }: CursorState): string { if (isDragging) { return 'grabbing'; @@ -148,6 +209,11 @@ export class NodeDistVisComponent { } } + /** + * Handle a click in deckgl + * + * @param info Deckgl picking information + */ private onClick(info: PickingInfo): void { const { picked, index } = info; if (picked) { @@ -155,6 +221,11 @@ export class NodeDistVisComponent { } } + /** + * Handle hovering in deckgl + * + * @param info Deckgl picking information + */ private onHover(info: PickingInfo): void { const { picked, index } = info; const obj = picked ? this.nodesView().at(index) : undefined; From 0851d2440abaf2b0ee7d1fad4af4ecf9f6d77266 Mon Sep 17 00:00:00 2001 From: Daniel Bolin Date: Thu, 24 Oct 2024 18:21:17 -0400 Subject: [PATCH 23/23] refactor(node-dist-vis): Remove test data --- .../src/lib/node-dist-vis/node-dist-vis.component.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts index 01d957875..e7629c60f 100644 --- a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts +++ b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts @@ -23,7 +23,6 @@ import { EdgeKeysInput, EdgesInput, loadEdges } from '../models/edges'; import { loadNodeFilter, NodeFilterInput } from '../models/filters'; import { loadNodes, NodeKeysInput, NodesInput } from '../models/nodes'; import { ViewMode } from '../models/view-mode'; -import { TEST_COLOR_MAP, TEST_EDGES, TEST_NODES } from './test-data'; /** CursorState is not exported by deckgl */ type CursorState = Parameters>[0]; @@ -59,7 +58,7 @@ export class NodeDistVisComponent { readonly mode = input('explore'); /** Node data */ - readonly nodes = input(TEST_NODES); + readonly nodes = input(); /** Node key mapping data */ readonly nodeKeys = input(); /** Node target selector used when calculating edges */ @@ -78,14 +77,14 @@ export class NodeDistVisComponent { readonly nodeTargetValue = input(); /** Edge data if already calculated */ - readonly edges = input(TEST_EDGES); + readonly edges = input(); /** Edge key mapping data */ readonly edgeKeys = input(); /** Max distance to consider when calculating edges */ readonly maxEdgeDistance = input(); // TODO default + transform /** Color map data */ - readonly colorMap = input(TEST_COLOR_MAP); + readonly colorMap = input(); /** Color map key mapping data */ readonly colorMapKeys = input | string>(); /**