From a7686b6ce6f4028e01cf2018ef2729f3e6a349b4 Mon Sep 17 00:00:00 2001 From: Ara Winters Date: Tue, 9 Jan 2024 11:43:58 -0800 Subject: [PATCH] #608 #616 --- .gitignore | 4 + angular.json | 134 +++ examples/charts/.browserslistrc | 19 + examples/charts/src/app/app.component.html | 12 + examples/charts/src/app/app.component.scss | 27 + examples/charts/src/app/app.component.ts | 15 + examples/charts/src/app/app.module.ts | 44 + examples/charts/src/assets/.gitkeep | 0 .../assets/icons8-building-50-buildout.png | Bin 0 -> 1355 bytes .../src/assets/icons8-building-50-core.png | Bin 0 -> 1352 bytes .../src/assets/icons8-building-50-queried.png | Bin 0 -> 1906 bytes .../charts/src/common/header.component.html | 6 + .../charts/src/common/header.component.scss | 0 .../charts/src/common/header.component.ts | 15 + .../src/environments/environment.prod.ts | 11 + .../charts/src/environments/environment.ts | 24 + examples/charts/src/favicon.ico | Bin 0 -> 61136 bytes examples/charts/src/index.html | 17 + examples/charts/src/main.ts | 12 + examples/charts/src/polyfills.ts | 53 ++ examples/charts/src/styles.scss | 67 ++ examples/charts/tsconfig.app.json | 15 + examples/graph/src/app/app.component.ts | 2 +- package-lock.json | 14 +- package.json | 6 +- .../sz-donut.component.html | 72 ++ .../sz-donut.component.scss | 833 ++++++++++++++++++ .../sz-donut.component.spec.ts | 30 + .../sz-donut.component.ts | 579 ++++++++++++ src/lib/common/utils.ts | 40 +- .../sz-license/sz-license.component.html | 66 ++ .../sz-license/sz-license.component.scss | 25 + .../sz-license/sz-license.component.ts | 198 +++++ src/lib/models/data-license.ts | 9 + src/lib/models/event-basic-event.ts | 2 +- src/lib/models/event-license.ts | 9 + src/lib/models/stats.ts | 16 + src/lib/pipes/decimalpercent.pipe.ts | 18 + src/lib/pipes/shortnumber.pipe.ts | 36 + src/lib/scss/_variables.scss | 1 + src/lib/scss/charts.scss | 40 + src/lib/scss/styles.scss | 1 + src/lib/scss/theme.scss | 10 + src/lib/scss/themes/senzing.css | 4 + src/lib/sdk.module.ts | 31 +- src/lib/services/sz-admin.service.ts | 11 +- src/lib/services/sz-datamart.service.ts | 60 ++ src/package.json | 2 +- src/public-api.ts | 8 + src/tsconfig.lib.json | 1 + tsconfig.json | 1 + 51 files changed, 2572 insertions(+), 28 deletions(-) create mode 100644 examples/charts/.browserslistrc create mode 100644 examples/charts/src/app/app.component.html create mode 100644 examples/charts/src/app/app.component.scss create mode 100644 examples/charts/src/app/app.component.ts create mode 100644 examples/charts/src/app/app.module.ts create mode 100644 examples/charts/src/assets/.gitkeep create mode 100644 examples/charts/src/assets/icons8-building-50-buildout.png create mode 100644 examples/charts/src/assets/icons8-building-50-core.png create mode 100644 examples/charts/src/assets/icons8-building-50-queried.png create mode 100644 examples/charts/src/common/header.component.html create mode 100644 examples/charts/src/common/header.component.scss create mode 100644 examples/charts/src/common/header.component.ts create mode 100644 examples/charts/src/environments/environment.prod.ts create mode 100644 examples/charts/src/environments/environment.ts create mode 100644 examples/charts/src/favicon.ico create mode 100644 examples/charts/src/index.html create mode 100644 examples/charts/src/main.ts create mode 100644 examples/charts/src/polyfills.ts create mode 100644 examples/charts/src/styles.scss create mode 100644 examples/charts/tsconfig.app.json create mode 100644 src/lib/charts/records-by-datasources/sz-donut.component.html create mode 100644 src/lib/charts/records-by-datasources/sz-donut.component.scss create mode 100644 src/lib/charts/records-by-datasources/sz-donut.component.spec.ts create mode 100644 src/lib/charts/records-by-datasources/sz-donut.component.ts create mode 100644 src/lib/configuration/sz-license/sz-license.component.html create mode 100644 src/lib/configuration/sz-license/sz-license.component.scss create mode 100644 src/lib/configuration/sz-license/sz-license.component.ts create mode 100644 src/lib/models/data-license.ts create mode 100644 src/lib/models/event-license.ts create mode 100644 src/lib/models/stats.ts create mode 100644 src/lib/pipes/decimalpercent.pipe.ts create mode 100644 src/lib/pipes/shortnumber.pipe.ts create mode 100644 src/lib/scss/charts.scss create mode 100644 src/lib/services/sz-datamart.service.ts diff --git a/.gitignore b/.gitignore index fc66a94a..43cc2695 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,10 @@ testem.log /typings *.old +# mock data +/stubs +/src/stubs + # System Files .DS_Store Thumbs.db diff --git a/angular.json b/angular.json index 4cb21ffb..bb8f12a4 100644 --- a/angular.json +++ b/angular.json @@ -469,6 +469,140 @@ } } }, + "@senzing/sdk-components-ng/examples/charts": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "inlineTemplate": true, + "inlineStyle": true, + "style": "scss", + "skipTests": true + }, + "@schematics/angular:class": { + "skipTests": true + }, + "@schematics/angular:directive": { + "skipTests": true + }, + "@schematics/angular:guard": { + "skipTests": true + }, + "@schematics/angular:interceptor": { + "skipTests": true + }, + "@schematics/angular:module": { + "skipTests": true + }, + "@schematics/angular:pipe": { + "skipTests": true + }, + "@schematics/angular:service": { + "skipTests": true + } + }, + "root": "examples/charts", + "sourceRoot": "examples/charts/src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/charts", + "index": "examples/charts/src/index.html", + "main": "examples/charts/src/main.ts", + "polyfills": "examples/charts/src/polyfills.ts", + "tsConfig": "examples/charts/tsconfig.app.json", + "aot": true, + "allowedCommonJsDependencies": [ + "core-js/modules/es.promise.js", + "core-js/modules/es.regexp.to-string.js", + "core-js/modules/es.array.reverse.js", + "core-js/modules/es.array.index-of.js", + "core-js/modules/es.string.includes.js", + "core-js/modules/es.string.trim.js", + "core-js/modules/es.string.split.js", + "core-js/modules/es.string.ends-with.js", + "core-js/modules/es.array.reduce.js", + "core-js/modules/web.dom-collections.iterator.js", + "core-js/modules/es.array.iterator.js", + "core-js/modules/es.string.starts-with.js", + "core-js/modules/es.string.replace.js", + "core-js/modules/es.string.match.js", + "raf" + ], + "assets": [ + "examples/charts/src/favicon.ico", + "examples/charts/src/assets" + ], + "styles": [ + "src/lib/scss/styles.scss", + "examples/charts/src/styles.scss" + ], + "scripts": [] + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "examples/charts/src/environments/environment.ts", + "with": "examples/charts/src/environments/environment.prod.ts" + } + ], + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "extractCss": true, + "namedChunks": false, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true, + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "5mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "6kb", + "maximumError": "10kb" + } + ] + }, + "development": { + "buildOptimizer": false, + "optimization": false, + "vendorChunk": true, + "extractLicenses": false, + "sourceMap": true, + "namedChunks": true + } + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "browserTarget": "@senzing/sdk-components-ng/examples/charts:build", + "port": 4300, + "proxyConfig": "proxy.conf.json" + }, + "configurations": { + "production": { + "browserTarget": "@senzing/sdk-components-ng/examples/charts:build:production" + }, + "development": { + "browserTarget": "@senzing/sdk-components-ng/examples/charts:build:development" + } + } + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "@senzing/sdk-components-ng/examples/charts:build" + } + } + } + }, "@senzing/sdk-components-ng/examples/graph": { "projectType": "application", "schematics": { diff --git a/examples/charts/.browserslistrc b/examples/charts/.browserslistrc new file mode 100644 index 00000000..2691f81f --- /dev/null +++ b/examples/charts/.browserslistrc @@ -0,0 +1,19 @@ +# This file is used by the build system to adjust CSS and JS output to support the specified browsers below. +# For additional information regarding the format and rule options, please see: +# https://github.com/browserslist/browserslist#queries + +# For the full list of supported browsers by the Angular framework, please see: +# https://angular.io/guide/browser-support + +# You can see what browsers were selected by your queries by running: +# npx browserslist + +last 3 Electron major versions +last 1 Chrome version +last 1 Firefox version +last 2 Edge major versions +last 2 Safari major versions +last 2 iOS major versions +Firefox ESR +not IE 9-10 # Angular support for IE 9-10 has been deprecated and will be removed as of Angular v11. To opt-in, remove the 'not' prefix on this line. +not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line. diff --git a/examples/charts/src/app/app.component.html b/examples/charts/src/app/app.component.html new file mode 100644 index 00000000..d94b6a6c --- /dev/null +++ b/examples/charts/src/app/app.component.html @@ -0,0 +1,12 @@ + + +
+ + +
\ No newline at end of file diff --git a/examples/charts/src/app/app.component.scss b/examples/charts/src/app/app.component.scss new file mode 100644 index 00000000..2abf18a1 --- /dev/null +++ b/examples/charts/src/app/app.component.scss @@ -0,0 +1,27 @@ + + .graph-context-menu { + border: 1px solid #686868; + } + + :host { + display: block; + width: 1200px; + + sz-record-counts-donut { + height: 200px; + } + sz-license { + margin-left: 20px; + width: 98%; + + padding: 0 20px 20px 20px; + display: flex; + flex-direction: column; + } + } + + .cols { + display: flex;; + flex-direction: row; + + } \ No newline at end of file diff --git a/examples/charts/src/app/app.component.ts b/examples/charts/src/app/app.component.ts new file mode 100644 index 00000000..7e13eedd --- /dev/null +++ b/examples/charts/src/app/app.component.ts @@ -0,0 +1,15 @@ +import { Component, ViewContainerRef } from '@angular/core'; +import { Overlay, OverlayRef } from '@angular/cdk/overlay'; +import { Subscription} from 'rxjs'; + +@Component({ + selector: 'app-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'] +}) +export class AppComponent { + sub: Subscription; + overlayRef: OverlayRef | null; + + constructor( public overlay: Overlay, public viewContainerRef: ViewContainerRef ) {} +} diff --git a/examples/charts/src/app/app.module.ts b/examples/charts/src/app/app.module.ts new file mode 100644 index 00000000..c6ff1936 --- /dev/null +++ b/examples/charts/src/app/app.module.ts @@ -0,0 +1,44 @@ +import { BrowserModule } from '@angular/platform-browser'; +import { NgModule } from '@angular/core'; +import { ReactiveFormsModule, FormsModule } from '@angular/forms'; +import { HttpClientModule } from '@angular/common/http'; +import { SenzingSdkModule, SzRestConfiguration } from '@senzing/sdk-components-ng'; +import { AppComponent } from './app.component'; +import { SzExamplesHeader } from '../common/header.component'; + +/** +* Pull in api configuration(SzRestConfigurationParameters) +* from: environments/environment +* +* @example +* ng build -c production +* ng serve -c docker +*/ +import { apiConfig, environment } from './../environments/environment'; + +/** + * create exportable config factory + * for AOT compilation. + * + * @export + */ +export function SzRestConfigurationFactory() { + return new SzRestConfiguration( (apiConfig ? apiConfig : undefined) ); +} + +@NgModule({ + declarations: [ + AppComponent, + SzExamplesHeader + ], + imports: [ + BrowserModule, + HttpClientModule, + FormsModule, + ReactiveFormsModule, + SenzingSdkModule.forRoot( SzRestConfigurationFactory ) + ], + providers: [], + bootstrap: [AppComponent] +}) +export class AppModule { } diff --git a/examples/charts/src/assets/.gitkeep b/examples/charts/src/assets/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/examples/charts/src/assets/icons8-building-50-buildout.png b/examples/charts/src/assets/icons8-building-50-buildout.png new file mode 100644 index 0000000000000000000000000000000000000000..37655e45c7c25fdd3302b533ba083bdb33cf2ff8 GIT binary patch literal 1355 zcmeAS@N?(olHy`uVBq!ia0vp^Mj*_=1|;R|J2o;fFa>3XM3hAM`dB6B=jtV<?;Zqle1Gx6p~WYGxKbf-tXS8q>!0ns}yePYv5bpoSKp8QB{;0T;&&% zT$P<{nWAKG$7NGt1vDTxwIorYA~z?m*s8)-32d$vkPQ;nS5g2gDap1~itr6kaLzAE zRWQ{v)iY4C<5I9GN=dT{a&d!d2J&o`GD=Dctn~HE%ggo3jrH=2()A53EiLs8jP#9+ zbb%^#i!1X=5-W7`ij_e|K+JGSElw`VEGWs$&r<-InV6JcT4JlD1e8~R8eWo_hA=&^ z80vjsK_%|H%3 zp`_*kQ%4an87y{eddk4S*p%t)98etO?xf)9>TG6cX=bKtq-V&$pfRy@;(2YCK#}A2 zX&q-WmPJHF_#EQ;IkB_%Xve}068=B8&aC{L=61H{dbR(gwVUQHI&y41YdvFh>ROSB zlUk>mZcMB?_xXy)vpeT(=l|aOea<^uaT~9&vooj0PBP3(=O7JD@^J98~=Hyo?B%{#JRb^Q+YsZG<9SIlnxrM+TqdG)u==P$}?WPb{s zzF{+WZjs%F53Kbz=^y`@79RsL!v`uS14vZo}PZ!6KjK;T95ArrS z2)O>2$UQXU2D1 zPSFOQ1B?f*nVSpv3+@!Y*gG>ml=*an1#>~)=IiHkI$t=sFg=Je6)-AdVGz7&_*wZ_ znXl&QB7LQu9djNSZsK@xoxxxmCxgc%mH#$*HO7j{^+!{BTHbl6x(1re^S!*qJXiL! z?-TDAM_&2u>%Mk8MaWS3Rz|J+t-8~(TkW3BKC%A)_kCMR&zK8le>)MeIs0%=#^G79 z(%0VH+K_g=&2+cfnGUlxH@7C9UB{bUmJ3mkJSDCFV2yC+&2zCce3a5$q@PqjoHJQb iJd?{f{GH8AmwW3YGPZ6v^h*GS7=x#)pUXO@geCw4d>C^8 literal 0 HcmV?d00001 diff --git a/examples/charts/src/assets/icons8-building-50-core.png b/examples/charts/src/assets/icons8-building-50-core.png new file mode 100644 index 0000000000000000000000000000000000000000..2a3f34d427bf05f40e6e9a74e8ae3039e2222b55 GIT binary patch literal 1352 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 + {{title}} + +
+ {{description}} +
\ No newline at end of file diff --git a/examples/charts/src/common/header.component.scss b/examples/charts/src/common/header.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/examples/charts/src/common/header.component.ts b/examples/charts/src/common/header.component.ts new file mode 100644 index 00000000..7b6335bc --- /dev/null +++ b/examples/charts/src/common/header.component.ts @@ -0,0 +1,15 @@ +import { Component, HostBinding, Input, ViewChild, Output, OnInit, OnDestroy, EventEmitter, ElementRef, ChangeDetectorRef } from '@angular/core'; + +@Component({ + selector: 'sz-example-header', + templateUrl: './header.component.html', + styleUrls: ['./header.component.scss'] + }) + export class SzExamplesHeader { + @Input() public title: string = ''; + @Input() public description: string = ''; + + constructor() { + + } + } \ No newline at end of file diff --git a/examples/charts/src/environments/environment.prod.ts b/examples/charts/src/environments/environment.prod.ts new file mode 100644 index 00000000..0bf5343d --- /dev/null +++ b/examples/charts/src/environments/environment.prod.ts @@ -0,0 +1,11 @@ +import { SzRestConfigurationParameters } from '@senzing/sdk-components-ng'; + +export const environment = { + production: true +}; + +// api configuration parameters +export const apiConfig: SzRestConfigurationParameters = { + 'basePath': '/api', + 'withCredentials': true +}; diff --git a/examples/charts/src/environments/environment.ts b/examples/charts/src/environments/environment.ts new file mode 100644 index 00000000..fd3008c2 --- /dev/null +++ b/examples/charts/src/environments/environment.ts @@ -0,0 +1,24 @@ +import { SzRestConfigurationParameters } from '@senzing/sdk-components-ng'; + +// This file can be replaced during build by using the `fileReplacements` array. +// `ng build` replaces `environment.ts` with `environment.prod.ts`. +// The list of file replacements can be found in `angular.json`. + +export const environment = { + production: false +}; + +// api configuration parameters +export const apiConfig: SzRestConfigurationParameters = { + 'basePath': '/api', + 'withCredentials': true +}; + +/* + * For easier debugging in development mode, you can import the following file + * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. + * + * This import should be commented out in production mode because it will have a negative impact + * on performance if an error is thrown. + */ +import 'zone.js/plugins/zone-error'; // Included with Angular CLI. diff --git a/examples/charts/src/favicon.ico b/examples/charts/src/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..75164c91b32b5c30f26ecfd28795a5d10afa9c96 GIT binary patch literal 61136 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 literal 0 HcmV?d00001 diff --git a/examples/charts/src/index.html b/examples/charts/src/index.html new file mode 100644 index 00000000..ad10dbc3 --- /dev/null +++ b/examples/charts/src/index.html @@ -0,0 +1,17 @@ + + + + + Charts + + + + + + + + + + + diff --git a/examples/charts/src/main.ts b/examples/charts/src/main.ts new file mode 100644 index 00000000..c7b673cf --- /dev/null +++ b/examples/charts/src/main.ts @@ -0,0 +1,12 @@ +import { enableProdMode } from '@angular/core'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { AppModule } from './app/app.module'; +import { environment } from './environments/environment'; + +if (environment.production) { + enableProdMode(); +} + +platformBrowserDynamic().bootstrapModule(AppModule) + .catch(err => console.error(err)); diff --git a/examples/charts/src/polyfills.ts b/examples/charts/src/polyfills.ts new file mode 100644 index 00000000..429bb9ef --- /dev/null +++ b/examples/charts/src/polyfills.ts @@ -0,0 +1,53 @@ +/** + * This file includes polyfills needed by Angular and is loaded before the app. + * You can add your own extra polyfills to this file. + * + * This file is divided into 2 sections: + * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. + * 2. Application imports. Files imported after ZoneJS that should be loaded before your main + * file. + * + * The current setup is for so-called "evergreen" browsers; the last versions of browsers that + * automatically update themselves. This includes recent versions of Safari, Chrome (including + * Opera), Edge on the desktop, and iOS and Chrome on mobile. + * + * Learn more in https://angular.io/guide/browser-support + */ + +/*************************************************************************************************** + * BROWSER POLYFILLS + */ + +/** + * By default, zone.js will patch all possible macroTask and DomEvents + * user can disable parts of macroTask/DomEvents patch by setting following flags + * because those flags need to be set before `zone.js` being loaded, and webpack + * will put import in the top of bundle, so user need to create a separate file + * in this directory (for example: zone-flags.ts), and put the following flags + * into that file, and then add the following code before importing zone.js. + * import './zone-flags'; + * + * The flags allowed in zone-flags.ts are listed here. + * + * The following flags will work for all browsers. + * + * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame + * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick + * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames + * + * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js + * with the following flag, it will bypass `zone.js` patch for IE/Edge + * + * (window as any).__Zone_enable_cross_context_check = true; + * + */ + +/*************************************************************************************************** + * Zone JS is required by default for Angular itself. + */ +import 'zone.js'; // Included with Angular CLI. + + +/*************************************************************************************************** + * APPLICATION IMPORTS + */ diff --git a/examples/charts/src/styles.scss b/examples/charts/src/styles.scss new file mode 100644 index 00000000..daa0b3d5 --- /dev/null +++ b/examples/charts/src/styles.scss @@ -0,0 +1,67 @@ +/* You can add global styles to this file, and also import other style files */ + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + + --sz-search-button-clear-padding: 0 10px 0 10px; + --sz-search-button-submit-padding: 0 30px 0 30px; + --sz-search-button-margin: 0; + --sz-search-button-border-radius: 6px; + --sz-search-button-clear-font-size: 10px; + --sz-search-label-margin: 0 0 10px 4px; + --sz-search-input-border-radius: 6px; + --sz-entity-detail-pill-color: #FFF; +} + +@-webkit-keyframes sk-stretchdelay { + 0%, 40%, 100% { -webkit-transform: scaleY(0.4) } + 20% { -webkit-transform: scaleY(1.0) } +} + +@keyframes sk-stretchdelay { + 0%, 40%, 100% { + transform: scaleY(0.4); + -webkit-transform: scaleY(0.4); + } + 20% { + transform: scaleY(1.0); + -webkit-transform: scaleY(1.0); + } +} + +.graph-context-menu { + display: block; + background-color: rgb(104, 104, 104); + padding: 0; + margin: 0; + width: 140px; + font-size: 12px; + border-left: 1px solid #686868; + border-right: 1px solid #686868; + border-top: 0; + border-bottom: 0; + + li { + display: block; + background-color: #eaeaea; + text-align: left; + transition-duration: 0.2s; + color: rgb(58, 58, 58); + margin: 1px 0 1px 0; + padding: 4px 2px 2px 4px; + border-top: 1px solid #686868; + border-bottom: 1px solid #686868; + } + li:first-child { + border-top: 1px solid rgb(104, 104, 104); + margin-top: 0; + } + li:last-child { + margin-bottom: 0; + } + li:hover { + cursor: pointer; + background-color:rgb(58, 58, 58); + color:#c0c0c0; + } +} \ No newline at end of file diff --git a/examples/charts/tsconfig.app.json b/examples/charts/tsconfig.app.json new file mode 100644 index 00000000..fd37f74d --- /dev/null +++ b/examples/charts/tsconfig.app.json @@ -0,0 +1,15 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/app", + "types": [] + }, + "files": [ + "src/main.ts", + "src/polyfills.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/examples/graph/src/app/app.component.ts b/examples/graph/src/app/app.component.ts index d51acdf2..2183eed8 100644 --- a/examples/graph/src/app/app.component.ts +++ b/examples/graph/src/app/app.component.ts @@ -8,7 +8,7 @@ import { SzRelationshipNetworkComponent } from '@senzing/sdk-components-ng'; @Component({ selector: 'app-root', templateUrl: './app.component.html', - styles: [] + styleUrls: ['./app.component.scss'] }) export class AppComponent { title = 'graph'; diff --git a/package-lock.json b/package-lock.json index cf451da3..b57ae6ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@angular/platform-browser": "^15.2.0", "@angular/platform-browser-dynamic": "^15.2.0", "@angular/router": "^15.2.0", - "@senzing/rest-api-client-ng": "^6.0.1", + "@senzing/rest-api-client-ng": "^6.1.0-alpha.1", "d3": "^7.8.4", "document-register-element": "^1.14.10", "ngx-json-viewer": "^3.2.1", @@ -5401,9 +5401,9 @@ } }, "node_modules/@senzing/rest-api-client-ng": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@senzing/rest-api-client-ng/-/rest-api-client-ng-6.0.1.tgz", - "integrity": "sha512-Iy5FEVUhqFeHfYQw406u46gTaGlMUU2ZQp1Dxw0o9hAImvw4H7aKQjugDge0wGxYUkI/yeE2B+uAYfcQFwm1Jg==", + "version": "6.1.0-alpha.1", + "resolved": "https://registry.npmjs.org/@senzing/rest-api-client-ng/-/rest-api-client-ng-6.1.0-alpha.1.tgz", + "integrity": "sha512-u2PVaFfZitb7uGzg1D8nfC0KU1oibFR8LTZGC/02jrif+KtO5dSwL09uASmtwqvaJA2UKcJfmbe8iLFxhj6w2A==", "dependencies": { "tslib": "~2.5.0" }, @@ -21828,9 +21828,9 @@ } }, "@senzing/rest-api-client-ng": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@senzing/rest-api-client-ng/-/rest-api-client-ng-6.0.1.tgz", - "integrity": "sha512-Iy5FEVUhqFeHfYQw406u46gTaGlMUU2ZQp1Dxw0o9hAImvw4H7aKQjugDge0wGxYUkI/yeE2B+uAYfcQFwm1Jg==", + "version": "6.1.0-alpha.1", + "resolved": "https://registry.npmjs.org/@senzing/rest-api-client-ng/-/rest-api-client-ng-6.1.0-alpha.1.tgz", + "integrity": "sha512-u2PVaFfZitb7uGzg1D8nfC0KU1oibFR8LTZGC/02jrif+KtO5dSwL09uASmtwqvaJA2UKcJfmbe8iLFxhj6w2A==", "requires": { "tslib": "~2.5.0" }, diff --git a/package.json b/package.json index 95a26449..4f04bf1d 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "start:rest": "npm run start:server", "server": "npm run start:server", "lint": "ng lint @senzing/sdk-components-ng", + "example:charts": "ng serve @senzing/sdk-components-ng/examples/charts", "example:graph": "ng serve @senzing/sdk-components-ng/examples/graph", "example:search-by-id": "ng serve @senzing/sdk-components-ng/examples/search-by-id", "example:search-in-graph": "ng serve @senzing/sdk-components-ng/examples/search-in-graph --configuration=development", @@ -41,7 +42,8 @@ "example:search-with-spinner": "ng serve @senzing/sdk-components-ng/examples/search-with-spinner", "example:with-why-features": "ng serve @senzing/sdk-components-ng/examples/with-why-features", "example:with-how-features": "ng serve @senzing/sdk-components-ng/examples/with-how-features", - "watch:graph": "rm -fR dist/@senzing/sdk-components-ng && concurrently --kill-others \"wait-on file:dist/@senzing/sdk-components-ng/public-api.d.ts && ng serve @senzing/sdk-components-ng/examples/graph --configuration=development\" \"ng build @senzing/sdk-components-ng --watch\"", + "watch:charts": "rm -fR dist/@senzing/sdk-components-ng && concurrently --kill-others \"wait-on file:dist/@senzing/sdk-components-ng/public-api.d.ts && ng serve @senzing/sdk-components-ng/examples/charts --configuration=development --watch\" \"ng build @senzing/sdk-components-ng --watch\"", + "watch:graph": "rm -fR dist/@senzing/sdk-components-ng && concurrently --kill-others \"wait-on file:dist/@senzing/sdk-components-ng/public-api.d.ts && ng serve @senzing/sdk-components-ng/examples/graph --configuration=development --watch\" \"ng build @senzing/sdk-components-ng --watch\"", "watch:search-with-prefs": "rm -fR dist/@senzing/sdk-components-ng && concurrently --kill-others \"wait-on file:dist/@senzing/sdk-components-ng/public-api.d.ts && ng serve @senzing/sdk-components-ng/examples/search-with-prefs --configuration=development\" \"ng build @senzing/sdk-components-ng --watch\"", "watch:search-with-results-and-details": "rm -fR dist/@senzing/sdk-components-ng && concurrently --kill-others \"wait-on file:dist/@senzing/sdk-components-ng/public-api.d.ts && ng serve @senzing/sdk-components-ng/examples/search-with-results-and-details\" \"ng build @senzing/sdk-components-ng --watch\"", "watch:search-in-graph": "rm -fR dist/@senzing/sdk-components-ng && concurrently --kill-others \"wait-on file:dist/@senzing/sdk-components-ng/public-api.d.ts && ng serve @senzing/sdk-components-ng/examples/search-in-graph --configuration=debug\" \"ng build @senzing/sdk-components-ng --watch\"", @@ -62,7 +64,7 @@ "@angular/platform-browser": "^15.2.0", "@angular/platform-browser-dynamic": "^15.2.0", "@angular/router": "^15.2.0", - "@senzing/rest-api-client-ng": "^6.0.1", + "@senzing/rest-api-client-ng": "^6.1.0-alpha.1", "d3": "^7.8.4", "document-register-element": "^1.14.10", "ngx-json-viewer": "^3.2.1", diff --git a/src/lib/charts/records-by-datasources/sz-donut.component.html b/src/lib/charts/records-by-datasources/sz-donut.component.html new file mode 100644 index 00000000..256e2bca --- /dev/null +++ b/src/lib/charts/records-by-datasources/sz-donut.component.html @@ -0,0 +1,72 @@ +
+ +
+
{{ + totalRecordCount < 1000 + ? totalRecordCount + : (totalRecordCount | SzShortNumber ) + }} +
+
Total Records
+
+
+
+
{{totalDataSources}} Data Sources
+
+
    +
  • + +
  • +
  • +
    + + {{ + totalUnmatchedRecordCount < 1000? totalUnmatchedRecordCount: (totalUnmatchedRecordCount | SzShortNumber : '0.0a') + }} + Unmatched Records +
    +
  • +
  • +
    + + {{ + totalPendingRecordCount < 1000? totalPendingRecordCount: (totalPendingRecordCount | SzShortNumber : '0.0a') + }} + Pending Load +
    +
  • +
+
    +
  • + {{ + (summary.recordCount/this.totalRecordCount) | SzDecimalPercent: 1 + }} +
  • +
  • + {{ + (totalUnmatchedRecordCount/this.totalRecordCount) | SzDecimalPercent: 1 + }} +
  • +
  • + {{ + (totalPendingRecordCount/this.totalRecordCount) | SzDecimalPercent: 1 + }} +
  • +
+
+
\ No newline at end of file diff --git a/src/lib/charts/records-by-datasources/sz-donut.component.scss b/src/lib/charts/records-by-datasources/sz-donut.component.scss new file mode 100644 index 00000000..02a98b13 --- /dev/null +++ b/src/lib/charts/records-by-datasources/sz-donut.component.scss @@ -0,0 +1,833 @@ +@import "../../scss/theme.scss"; + +button[mat-menu-item] { + border-left: 1px solid transparent; + border-right: 1px solid transparent; +} +button[mat-menu-item].selected-to-data-source, +button[mat-menu-item].selected-to-data-source:hover { + background-color: dodgerblue; + color: $sz-white; + border-left: 1px solid $sz-white; + border-right: 1px solid $sz-white; +} + +:host .busy-mask { + display: flex; + justify-content: center; + align-items: center; + align-content: center; + flex-direction: column; + position: absolute; + top: 0px; + left: -30px; + right: 0px; + bottom: 0px; + background-color: rgba(130,130,130,0.6); + z-index: 10; +} + +:host .busy-mask mat-progress-spinner { + display: block; + width: 140px; + height: 140px; +} + +:host .busy-mask .progress-caption { + display: block; + margin: 20px; + font-size: 20px; +} + +:host { + display: flex; + /*flex-grow: 10;*/ + justify-content: flex-start; + flex-flow: row; + align-content: stretch; + + font-family: var(--sz-font-family); + color: var(--sz-font-color); + font-size: var(--sz-font-size); + + + .stats { + flex-grow: 10; + display: flex; + flex-direction: column; + justify-content: flex-start; + color: $sz-font-color-1; + padding-top: 20px; + font-family: Roboto, "Helvetica Neue", sans-serif; + + .total-data-sources__label { + display: flex; + justify-content: flex-start; + align-items: center; + padding-left: 30px; + span { + text-decoration: underline; + font-weight: 600; + } + } + + ul.from-data-source { + margin: 0px 20px 0px 0px; + width: auto; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + ul.to-data-source, ul.match-level { + margin: 0; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + } + .legend-wrapper { + display: block; + margin: 0; + overflow: hidden; + padding: 0; + height: 290px; + background-color: rgba(130,130,130,0.3); + border: 1px solid #081fad; + position: relative; + } + button.from-data-source, + button.match-level, + button.to-data-source, + button.back-from-match-levels { + padding: 10px; + width: 100%; + height: 100%; + text-align: left; + } + button.back-from-match-levels { + position: relative; + .legend__subtitle { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%,-50%); + } + } + + .legend__percentages { + margin-top: 0; + list-style: none; + margin-left: 15px; + font-weight: 600; + li { + } + } + + .records-label__wrapper{ + display: flex; + /*max-height: 180px;*/ + overflow: auto; + padding-right: 15px; + + .legend.from-data-source { + max-height: none; + overflow: visible; + } + } + } + + .graph { + display: flex; + flex-grow: 0; + align-items: var(--sz-donut-chart-graph-align-items); + justify-content: center; + max-width: 200px; + + .total-record-title__wrapper { + position: absolute; + display: flex; + align-items: center; + flex-direction: column; + justify-content: center; + height: 200px; + + .total-record-title { + font-size: 24px; + font-weight: 700; + color: $sz-highlight-grey; + } + .total-record-subtitle { + font-size: 0.8em; + } + } + } +} + +.legend__title { + text-align: center; + color: $sz-white; + white-space: nowrap; + font-size: 18px; +} + +.legend { + padding: 0; + + .legend-item { + padding: 10px; + margin: 0; + } + + &.from-data-source .legend-item, + &.to-data-source .legend-item, + &.match-level .legend-item { + width: 100%; + border-bottom: 1px solid $sz-light-grey; + border-top: 1px solid $sz-light-grey; + margin: 0; + padding: 0; + box-sizing: border-box; + } + + &.to-data-source .legend-item, + &.match-level .legend-item { + border: 0 none transparent; + border-bottom: 1px solid #081fad; + background-color: dodgerblue; + + button.to-data-source.no-details:disabled, + button.match-level.no-details:disabled { + color: white; + } + button.to-data-source span.legend__subtitle, + button.match-level span.legend__subtitle { + text-decoration: underline; + } + button.to-data-source span.material-icons, + button.match-level span.material-icons { + text-align: right; + } + button.to-data-source.no-details span.legend__subtitle, + button.match-level.no-details span.legend__subtitle { + text-decoration: none; + } + button.to-data-source.no-details span.material-icons, + button.match-level.no-details span.material-icons { + display: none; + } + } + + .legend-items { + list-style: none; + margin-bottom: 10px; + display: flex; + align-items: center; + height: 25px; + button.from-data-source { + color: $sz-font-color-1; + border: 0px none transparent; + padding: 0px; + margin: 0px; + background-color: transparent; + font-size: 16px; + cursor: pointer; + vertical-align: middle; + line-height: 20px; + height: 20px; + outline: none; + + span { + line-height: 20px; + vertical-align: middle; + } + span.legend__subtitle { + display: inline-block; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + } + } + button.from-data-source:hover span.legend__count, + button.from-data-source:hover span.legend__subtitle { + text-decoration: underline; + } + .legend__color-dot { + width: 20px; + height: 20px; + display: inline-block; + margin-right: 5px; + border-radius: 50%; + border: 1px solid rgba(51, 51, 51, 0.16862745098039217); + + /* + &.item-1 { + background-color: #081fad; + } + &.item-2 { + background-color: lightblue; + } + &.item-3 { + background-color: $sz-dark2; + } + &.item-4 { + background-color: #6b486b; + } + &.item-5 { + background-color: #a05d56; + } + &.item-6 { + background-color: #d0743c; + } + &.item-7 { + background-color: #ff8c00; + } + &.item-Pending { + background-color: #DDDDDD; + } + */ + } + .legend__title { + font-size: 18px; + font-weight: 700; + color: $sz-font-color-1; + margin-right: 5px; + } + } +} + +.legend.from-data-source { + max-height: 290px; + overflow-y: auto; + overflow-x: hidden; +} +.legend.to-data-source { + height: 290px; + overflow-y: auto; + overflow-x: hidden; +} +.legend.from-data-source .legend-item:first-child { + border-top: 1px solid $sz-light-grey; +} +.legend.match-level .legend-item:last-child { + border-bottom: 0 none transparent; +} +.legend.from-data-source .legend-item.selected { + background-color: dodgerblue; + color: $sz-white; +} + +.legend.from-data-source .legend-item.selected .legend__title { + color: $sz-white; +} + +/* --------------------------------------- old stuff below --------------------------------------- */ + +mat-card { + display: flex; + align-items: stretch; + margin-bottom: 30px; + padding: 0; + overflow: hidden; + min-width: 950px; + border-radius: 0px; +} + +:host .errant .material-icons { + font-size: 96px; + /*color: $senzing-config-warn-color;*/ +} +:host .expired .material-icons { + font-size: 96px; + color: black; +} + + .label-title { + text-transform: uppercase; + } + + .main-card__label { + min-height: 150px; + width: 200px; + min-width: 200px; + text-transform: uppercase; + font-weight: 700; + //margin-right: 20px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5em; + color: $sz-white; + &.search { + background-color: $sz-green; + } + &.insights { + background-color: $sz-orange; + } + &.suggestions { + background-color: $sz-brown; + } + &.data { + background-color: $sz-dark2; + } + &.health { + background-color: $sz-dark-blue; + } + } + + mat-card.data .content { + align-items: stretch; + overflow: hidden; + } + + mat-card.data.masked .data-card__left .data-card-content { + opacity: 0; + } + + .content { + position: relative; + align-items: center; + display: flex; + justify-content: space-between; + padding: 0 30px; + width: 100%; + overflow: hidden; + + .data-card__spacer { + max-width: 3px; + min-width: 3px; + width: 3px; + flex-grow: 0; + flex-shrink: 0; + display: flex; + flex-direction: row; + align-items: stretch; + justify-content: center; + padding: 20px 0px; + + .separator { + border-right: 1px solid $sz-light-grey; + } + } + .no-data-mask { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 10; + background-color: rgba(120,120,120,0.8); + + .no-data-message { + position: absolute; + top: 50%; + left: 210px; + width: auto; + height: auto; + transform: translateY(-50%); + font-size: 32px; + font-weight: 700; + cursor: pointer; + color: $sz-dark-blue; + border: 1px solid $sz-dark-blue; + border-radius: 5px; + padding: 10px; + background-color: rgba(225,225,225,0.6); + .material-icons { + font-weight: 700; + font-size: 32px; + line-height: 39px; + vertical-align: middle; + } + } + .no-data-spinner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%,-50%); + } + } + + .suggestions__list { + margin: 0; + padding-left: 20px; + list-style: none; + } + + .data-card__left { + display: flex; + flex-flow: column; + flex-grow: 1; + flex-shrink: 1; + width: auto; + position: relative; + .data-card-content.no-data DIV { + visibility: hidden; + } + + .data-card-content { + flex-grow: 10; + display: flex; + flex-direction: column; + justify-content: flex-start; + .data-card__title { + flex-grow: 0; + } + .stats-and-graph { + display: flex; + flex-grow: 10; + justify-content: flex-start; + flex-flow: row; + align-content: stretch; + + .stats { + flex-grow: 10; + display: flex; + flex-direction: column; + justify-content: flex-start; + color: $sz-font-color-1; + padding-top: 20px; + font-family: Roboto, "Helvetica Neue", sans-serif; + + .total-data-sources__label { + display: flex; + justify-content: flex-start; + align-items: center; + padding-left: 30px; + span { + text-decoration: underline; + font-weight: 600; + } + } + + .legend__percentages { + margin-top: 0; + list-style: none; + margin-left: 15px; + font-weight: 600; + li { + } + } + + .records-label__wrapper{ + display: flex; + max-height: 180px; + overflow: auto; + padding-right: 15px; + + .legend.from-data-source { + max-height: none; + overflow: visible; + } + } + } + .graph { + flex-grow: 0; + } + } + } + } + + .data-card__right { + flex-grow: 1; + flex-shrink: 1; + overflow: hidden; + } + + .data-card__left, + .data-card__right { + flex-grow: 1; + flex-shrink: 1; + display: flex; + justify-content: space-between; + padding: 20px 0px 20px 0px; + + ul.from-data-source { + margin: 0px 20px 0px 0px; + width: auto; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + ul.to-data-source, ul.match-level { + margin: 0; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + } + .legend-wrapper { + display: block; + margin: 0; + overflow: hidden; + padding: 0; + height: 290px; + background-color: rgba(130,130,130,0.3); + border: 1px solid #081fad; + position: relative; + } + button.from-data-source, + button.match-level, + button.to-data-source, + button.back-from-match-levels { + padding: 10px; + width: 100%; + height: 100%; + text-align: left; + } + button.back-from-match-levels { + position: relative; + .legend__subtitle { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%,-50%); + } + } + } + + .data-card__left { + justify-content: flex-start; + + .data-card__title { + display: inline-block; + font-size: 18px; + white-space: nowrap; + } + + .stat-flavor-button { + padding-left: 0; + padding-right: 0; + + label { + vertical-align: top; + text-decoration: underline; + } + } + + } + .data-card__right { + color: $sz-font-color-1; + padding: 20px 20px; + display: flex; + + //justify-content: space-around; + + .data-card-content { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-start; + overflow: hidden; + + ul.license-messages { + margin-top: 30px; + margin-left: 0px; + padding: 0px; + list-style-type: none; + li { + font-size: 16px; + font-weight: 500; + margin-left: 0px; + margin-bottom: 5px; + padding: 0px; + white-space: nowrap; + line-height: 24px; + vertical-align: middle; + + span { + line-height: 24px; + vertical-align: middle; + display: inline-block; + } + span.warning-prefix { + /*color: $senzing-config-warn-color;*/ + } + } + } + + .buttons { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + + button.upgrade, button.import { + background-color: $sz-blue; + color: #ffffff; + } + + button.import { + margin-left: 10px; + } + } + } + .to-data-source-button { + padding-left: 0; + padding-right: 0; + + label { + vertical-align: top; + text-decoration: underline; + } + } + + .data-card__title, + .legend__title { + text-align: center; + color: $sz-white; + white-space: nowrap; + font-size: 18px; + } + + .data-card__title { + line-height: 36px; + vertical-align: middle; + } + .stats{ + width: 100%; + } + + .data-card__summary { + width: 100%; + display: flex; + flex-wrap: nowrap; + justify-content: center; + flex-direction: row; + + .legend-wrapper { + flex-grow: 10; + margin-left: 10px; + margin-right: 10px; + } + } + } + + .insights-card__left, + .insights-card__right { + width: 50%; + justify-content: center; + display: flex; + flex-direction: column; + color: $sz-font-color-1; + } + + .insights-card__left .headline { + color: #e74c3c; + } + } + + .suggestions + .content { + flex-direction: column; + align-items: flex-start; + + h3 { + color: $sz-font-color-1; + font-weight: 500; + } + } + + .health + .content { + flex-direction: column; + align-items: flex-start; + justify-content: center; + } + + .data + .content { + padding-right: 0; + min-width: 840px; + overflow: hidden; + } + + .headline { + font-size: 4em; + color: green; + font-weight: 900; + + & + .subtitle { + font-size: 1.5em; + font-weight: 500; + color: $sz-light-grey; + margin-top: -16px; + } + } + + input { + margin: 0 20px; + width: 60%; + font-size: 1.5em; + color: inherit; + line-height: 1.5; + height: 1em; + padding: .25em 0; + + &:hover { + box-shadow: inset 0 -2px #ccc; + } + + &:focus { + outline: none; + } + } + + .button__search-go { + background-color: $sz-red; + color: $sz-white; + } + .button__advanced-search { + background-color: $sz-white; + border: 1px solid $sz-red; + color: $sz-red; + min-width: 145px; + margin: 0 100px 0 20px; + } + + i.label_icon { + font-size: 5em; + position: absolute; + opacity: 0.1; + z-index: 2; + } + + .data-card__title { + font-size: 18pt; + font-weight: 700; + color: $sz-font-color-1; + } + + .data-card__right .legend .legend-item { + white-space: nowrap; + } + + .overlay { + height: 100%; + width: 100%; + position: absolute; + background-color: black; + opacity: 0.6; + z-index: 2; + color: $sz-white; + display: flex; + align-items: center; + justify-content: center; + font-size: 2em; + } + +.no-data-loaded-msg { + font-style: italic; + color: $sz-dark-grey; + opacity: 0.8; + font-variant: small-caps; + font-size: 24px; + font-weight: 600; + display: block; + white-space: nowrap; + position: absolute; + top: 50%; + transform: translateY(-50%); +} + +span.legend__count { + margin-right: 5px; +} diff --git a/src/lib/charts/records-by-datasources/sz-donut.component.spec.ts b/src/lib/charts/records-by-datasources/sz-donut.component.spec.ts new file mode 100644 index 00000000..c8cc9fef --- /dev/null +++ b/src/lib/charts/records-by-datasources/sz-donut.component.spec.ts @@ -0,0 +1,30 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SzRecordStatsDonutChart } from './sz-donut.component'; +import { SenzingSdkModule } from 'src/lib/sdk.module'; + +describe('SzRecordStatsDonutChart', () => { + let component: SzRecordStatsDonutChart; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [SenzingSdkModule.forRoot()] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SzRecordStatsDonutChart); + component = fixture.componentInstance; + fixture.detectChanges(); + })); + + afterEach(() => { + fixture.destroy(); + }); + + it('should create', () => { + // test fails on CI only (issue #75) + // temporarily removing until more is known + expect(component).toBeTruthy(); + }); +}); diff --git a/src/lib/charts/records-by-datasources/sz-donut.component.ts b/src/lib/charts/records-by-datasources/sz-donut.component.ts new file mode 100644 index 00000000..f981274c --- /dev/null +++ b/src/lib/charts/records-by-datasources/sz-donut.component.ts @@ -0,0 +1,579 @@ +import { Component, Input, Output, OnInit, OnDestroy, EventEmitter, ChangeDetectorRef } from '@angular/core'; +import { SzPrefsService } from '../../services/sz-prefs.service'; +import { map, take, takeUntil } from 'rxjs/operators'; +import { Observable, Subject } from 'rxjs'; +import * as d3 from 'd3-selection'; +import * as d3Shape from 'd3-shape'; + +import { SzDataSourcesResponseData } from '@senzing/rest-api-client-ng'; +import { isValueTypeOfArray, parseBool, parseNumber, parseSzIdentifier, sortDataSourcesByIndex } from '../../common/utils'; +import { SzRecordCountDataSource, SzStatCountsForDataSources } from '../../models/stats'; +import { SzDataMartService } from '../../services/sz-datamart.service'; +import { SzDataSourcesService } from '../../services/sz-datasources.service'; + +/** + * Embeddable Donut Graph showing how many + * records belong to which datasources for the repository in a visual way. + * + * @internal + * @example + * + * + * @example + * + * + * @example + * + * + */ +@Component({ + selector: 'sz-record-counts-donut', + templateUrl: './sz-donut.component.html', + styleUrls: ['./sz-donut.component.scss'] +}) +export class SzRecordStatsDonutChart implements OnInit, OnDestroy { + /** subscription to notify subscribers to unbind */ + public unsubscribe$ = new Subject(); + + private _dataSourceCounts: SzRecordCountDataSource[]; + private _dataSourceCountsByCode: Map; + private _dataSources: SzDataSourcesResponseData; + private _totalEntityCount: number; + private _totalPendingRecordCount: number; + private _totalRecordCount: number; + private _totalUnmatchedRecordCount: number; + private _unlistedDataSources: string[]; + private _limitToNumber: number; + /* @internal used to override auto-generated colors with user value */ + private _colors: string[]; + /** @internal possible values for sort order of list are 'alphadesc' | 'alphaasc' | 'countdesc' | 'countasc' */ + private _orderBy: 'alphadesc' | 'alphaasc' | 'countdesc' | 'countasc'; + /* donut chart properties */ + /** @internal width of svg */ + private donutWidth: number; + /** @internal height of svg */ + private donutHeight: number; + /** @internal d3 ref to svg */ + private donutSvg: any; // TODO replace all `any` by the right type + /** @internal radius of svg */ + private donutRadius: number; + /** @internal d3 ref to arc */ + private arc: any; + /** @internal d3 ref to pie */ + private pie: any; + /** @internald3 tooltip selection */ + private _tooltip: d3.Selection; + + /** ------------------------------------ event emitters/inputs ------------------------------------ */ + /** sets the colors used in chart */ + @Input() public set colors(value: string[]) { + this._colors = value; + // if already have data need to update colors + + }; + /** + * emmitted when the entity data to display has been changed. + */ + @Output('dataChanged') + dataChanged: Subject = new Subject(); + /** + * emitted when the user clicks a datasource arc node. + * @returns object with various datasource and ui properties. + */ + @Output() dataSourceClick: EventEmitter = new EventEmitter(); + /** values for these datasources are hidden from view */ + @Input() public set ignore(value: string[] | string) { + if(isValueTypeOfArray(value)) { + // string array + this._unlistedDataSources = value as string[]; + } else if((value as string).indexOf(',') > -1) { + // multiple string values + this._unlistedDataSources = (value as string).split(',') + } else { + // assume single string?? + this._unlistedDataSources = [(value as string)]; + } + if(this._unlistedDataSources && this._unlistedDataSources.length > 0) { + // remove any whitespace and uppercase everything + this._unlistedDataSources = this._unlistedDataSources.map((dsCode) => { + return dsCode.replaceAll(' ','').trim().toUpperCase(); + }) + } + } + @Input() public set limitToNumber(value: number | string) { + this._limitToNumber = parseNumber(value); + } + /** sort the vertical list. possible values for sort order of list are 'alphadesc' | 'alphaasc' | 'countdesc' | 'countasc' */ + @Input() public set orderBy(value: 'alphadesc' | 'alphaasc' | 'countdesc' | 'countasc') { + this._orderBy = value; + } + /** + * emitted when the component begins a request for data. + * @returns entityId of the request being made. + */ + @Output() requestStart: EventEmitter = new EventEmitter(); + /** + * emitted when a search is done being performed. + * @returns the result of the entity request OR an Error object if something went wrong. + */ + @Output() requestEnd: EventEmitter = new EventEmitter(); + /** + * emitted when a search encounters an exception + * @returns error object. + */ + @Output() exception: EventEmitter = new EventEmitter(); + + /** -------------------------------------- getters and setters -------------------------------------- */ + + get totalEntityCount(): number { + return this._totalEntityCount; + } + get totalRecordCount(): number { + return this._totalRecordCount; + } + get totalUnmatchedRecordCount(): number { + return this._totalUnmatchedRecordCount; + } + get totalPendingRecordCount(): number { + return this._totalPendingRecordCount; + } + get totalDataSources(): number { + let retVal = (this._dataSourceCountsByCode && this._dataSourceCountsByCode.size) ? this._dataSourceCountsByCode.size : 0; + return retVal; + } + /* + get pendingLoadColor(): string { + // if we previously set this or the user passed it in pull that value + if(this._pendingLoadColor !== undefined && this._pendingLoadColor !== null) { + return this._pendingLoadColor; + } + // otherwise pull from last color value in pallete + if(this._colorPalette && this._colorPalette.length >= 200) { + this._pendingLoadColor = this._colorPalette[ 200 ]; + return `hsl('${ this._unnamedRecordsColor }', 100%, 50%)`; + } + return "'blue'"; + } + get unnamedRecordsColor(): string { + // if we previously set this or the user passed it in pull that value + if(this._unnamedRecordsColor !== undefined && this._unnamedRecordsColor !== null) { + return this._unnamedRecordsColor; + } + // otherwise pull from last color value in pallete + if(this._colorPalette && this._colorPalette.length > 0) { + this._unnamedRecordsColor = this._colorPalette[ this._colorPalette.length - 1]; + return `hsl('${ this._unnamedRecordsColor }', 100%, 50%)`; + } + return "'pink'"; + }*/ + + get dataSourceCounts(): SzRecordCountDataSource[] { + let retVal = this._dataSourceCounts; + if(this._unlistedDataSources && this._unlistedDataSources.length > 0 && this._dataSourceCounts) { + // hand back datasource counts minus the unlisted items + retVal = this._dataSourceCounts.filter((ds) => { + return this._unlistedDataSources.indexOf(ds.dataSourceCode) === -1; + }); + } + // sort + if(this._orderBy) { + retVal = this.sortBy(retVal, this._orderBy); + } + // limit top results + if(this._limitToNumber > 0) { + retVal = retVal.slice(0, this._limitToNumber); + } + return retVal; + } + set dataSourceCounts(value: SzRecordCountDataSource[]) { + this._dataSourceCounts = value; + if(value && value.length) { + // create map by code + let _rCountByCode = new Map(value.map((obj)=>[obj.dataSourceCode, obj])); + this._dataSourceCountsByCode = _rCountByCode; + } + } + + get dataSourceCountsByCode(): Map { + if(this._unlistedDataSources && this._unlistedDataSources.length > 0) { + // hand back datasource counts minus the unlisted items + let retVal = new Map(this._dataSourceCountsByCode); + this._unlistedDataSources.forEach((dsCode)=>{ + if(retVal.has(dsCode)) { retVal.delete(dsCode); } + }) + return retVal; + } + return this._dataSourceCountsByCode; + } + + constructor( + public prefs: SzPrefsService, + private cd: ChangeDetectorRef, + private dataMartService: SzDataMartService, + private dataSourcesService: SzDataSourcesService + ) {} + + /** + * unsubscribe when component is destroyed + */ + ngOnDestroy() { + this.unsubscribe$.next(); + this.unsubscribe$.complete(); + } + + ngOnInit() { + // get data source counts + this.getDataSourceRecordCounts().pipe( + takeUntil(this.unsubscribe$) + ).subscribe({ + next: (recordCounts: SzRecordCountDataSource[])=>{ + if(this._dataSourceCounts && this._dataSources) { + this.dataChanged.next(this._dataSourceCounts); + } + }, + error: (err) => { + this.exception.next(err); + } + }); + // get data sources + this.getDataSources().pipe( + takeUntil(this.unsubscribe$), + take(1) + ).subscribe({ + next: (dataSources: SzDataSourcesResponseData)=>{ + this._dataSources = dataSources; + if(this._dataSourceCounts && this._dataSources) { + this.dataChanged.next(this._dataSourceCounts); + } + }, + error: (err) => { + this.exception.next(err); + } + }); + + // only execute draw once we have the data + this.dataChanged.pipe( + takeUntil(this.unsubscribe$) + ).subscribe({ + next: (data) => { + //console.log('counted totals', this.getTotalsFromCounts(data)); + this.initDonut() + this.renderDonut(data); + }, + error: (err) => { + this.exception.next(err); + } + }); + // listend for datasource clicks + this.dataSourceClick.pipe( + takeUntil(this.unsubscribe$) + ).subscribe((d)=>{ + console.log('data source clicked', d); + }) + + } + + /** --------------------------------------- methods and subs -------------------------------------- */ + /** -------------------------------------- drawing methods -------------------------------------- */ + + static arcTooltipText(d: any) { + let retVal = `${d.data.dataSourceCode}: ${d.data.recordCount} record`+(d.data.recordCount !== 1 ? 's':''); ; + return retVal; + } + + private initDonut() { + this.donutSvg = d3.select('svg.donut-chart'); + if (!this.donutSvg) { console.warn('no donut svg'); return; } + if (this.donutSvg.empty && this.donutSvg.empty()) { console.warn('donut already rendered'); return; } + + this.donutSvg.selectAll('*').remove(); + this.donutWidth = +this.donutSvg.attr('width'); + this.donutHeight = +this.donutSvg.attr('height'); + this.donutRadius = Math.min(this.donutWidth, this.donutHeight) / 2; + + // pretty pixies + const defs = d3.select('svg').append("defs"); + const filter = defs.append("filter") + .attr("id", "dropshadow") + .attr("color-interpolation-filters", "sRGB"); + + // Glow + const glow = filter.append("feComponentTransfer") + .attr("in", "SourceAlpha"); + // Alpha + glow.append("feFuncA") + .attr("type", "linear") + .attr("slope", "0.5"); + // Shadow + filter.append("feGaussianBlur") + //.attr("in", "SourceAlpha") + .attr("stdDeviation", 4) + .attr("result", "blur"); + filter.append("feOffset") + .attr("in", "blur") + .attr("dx", 2) + .attr("dy", 5) + .attr("result", "offsetBlur"); + + const feMerge = filter.append("feMerge"); + feMerge.append("feMergeNode") + .attr("in", "offsetBlur"); + feMerge.append("feMergeNode") + .attr("in", "SourceGraphic"); + + // Normally a scale maps from a domain to a range. You would specify the domain here with the range. + // scaleOrdinal maps from discrete value to discrete value. This allows the domain to be generated from the data as it's processed. + + /*this.color = d3Scale.scaleOrdinal() + .range(this._colorPalette); + // .range(this.getColors());*/ + + this.arc = d3Shape.arc() + .outerRadius(this.donutRadius - 10) + .innerRadius(this.donutRadius - 40); + + // Make the tooltip visible when mousing over nodes. + this._tooltip = d3.select("body") + .append("div") + .attr("class", "sz-donut-chart-tooltip") + .style("opacity", 0); + + this.pie = d3Shape.pie() + .padAngle(.015) + .value((d: any) => d.recordCount); + + // for some reason donutWidth comes back as NaN when in background on new ds add + if ( !Number.isNaN(this.donutWidth / 2) && !Number.isNaN(this.donutHeight / 2) ) { + this.donutSvg = d3.select('svg.donut-chart') + .append('g') + .attr('transform', 'translate(' + this.donutWidth / 2 + ',' + this.donutHeight / 2 + ')') + .attr("filter", "url(#dropshadow)"); + } + } + + private renderDonut(data?: SzRecordCountDataSource[]) { + console.log(`render donut: `, data); + let removedItems = (this._limitToNumber > 0) ? data.splice(this._limitToNumber): []; + if(this._limitToNumber > 0) { + // show total unlisted as single item + removedItems = data.splice(this._limitToNumber); + } + const dataSourcesToDisplay = data.slice(0); + // unmatched/singletons + if (this._totalUnmatchedRecordCount) { + const unMatchedSummary: SzRecordCountDataSource = { + dataSourceCode: 'Unmatched', + entityCount: 0, + recordCount: this._totalUnmatchedRecordCount + } + dataSourcesToDisplay.push(unMatchedSummary); + } + // pending load + if (this._totalPendingRecordCount > 0) { + const unMatchedSummary: SzRecordCountDataSource = { + dataSourceCode: 'Pending Load', + entityCount: 0, + recordCount: this._totalPendingRecordCount + } + dataSourcesToDisplay.push(unMatchedSummary); + } + + if(!this.pie){ + // this.initDonutSvg has not run yet + return; + } + // sub for attaching event handlers to node(s) + let attachEventListenersToNodes = (_nodes, _tooltip, _scope?: any) => { + _scope = _scope ? _scope : this; + // Make the tooltip visible when mousing over nodes. + if(_nodes && _nodes.on) { + _nodes.on('mouseover.tooltip', function (event, d, j) { + _tooltip.transition() + .duration(300) + .style("opacity", 1) + _tooltip.html(SzRecordStatsDonutChart.arcTooltipText(d)) + .style("left", (event.pageX) + "px") + .style("top", (event.pageY + 10) + "px"); + }) + .on("mouseout.tooltip", function (event, d) { + _tooltip.transition() + .duration(100) + .style("opacity", 0); + }) + .on('click', this.onArcClick.bind(_scope)) + } + } + let detachEventListeners = (_nodes) => { + if(_nodes && _nodes.on) { + _nodes.on('mouseover.tooltip', null) + .on("mouseout.tooltip", null) + .on('click', null) + } + } + + const g = this.donutSvg.selectAll('.arc') + .data(this.pie(dataSourcesToDisplay)) + .enter().append('g') + .attr('class', 'arc'); + let arcPaths = g.append('path') + .attr('d', this.arc) + .attr('class', (d) => { + if(d.data.dataSourceCode === 'unmatched'){ + return 'item-unmatched'; + } + if(d.data.dataSourceCode === 'pending load'){ + return 'item-pending'; + } + return 'item-'+ (d.data && d.data.dataSourceCode && d.data.dataSourceCode.toLowerCase ? d.data.dataSourceCode.toLowerCase() : 'unknown'); + }) + .style('fill', (d) => d.data.color ); + + // attach event listeners to arc elements + attachEventListenersToNodes(arcPaths, this._tooltip, this); + } + + /** ---------------------------------- data transform methods ----------------------------------- */ + + addColorsToData(data: SzRecordCountDataSource[]) { + let numOfColors = data.length; + const initialColor = 1; + const increment = 360 / numOfColors; + const colors = []; + + for (let i = 0; i < numOfColors; i++) { + let _colorNum = Math.round((initialColor + (i * increment)) % 360); + let _colorStyle = (this._colors && this._colors.length >= i && this._colors[i]) ? this._colors[i] : 'hsl('+ _colorNum +', 100%, 50%, 56%)'; + colors.push(_colorStyle); + } + // we want the colors to respect the sort order + if(this._orderBy) { + let sortedData = this.sortBy(data, this._orderBy); + sortedData.forEach((sdata, ind) => { + sdata.color = sdata.color ? sdata.color : colors[ind]; + }); + } else { + data = data.map((ds, ind)=>{ + ds.color = ds.color ? ds.color : colors[ind]; + return ds; + }); + } + return data + } + extendData(data: SzStatCountsForDataSources) { + if(data) { + this.addColorsToData(data.dataSourceCounts); + } + } + + public getDataSourceName(dsCode: string) { + if(this._dataSources) { + if(this._dataSources[dsCode] && this._dataSources[dsCode].dataSourceName) { + return this._dataSources[dsCode].dataSourceName + } + } + return dsCode + } + private getDataSourceRecordCounts(): Observable { + return this.dataMartService.getLoadedStatistics().pipe( + map((response)=> { + console.info(`SzRecordStatsDonutChart.getDataSources(): response: `, response); + if(response && response.data) { + this.extendData(response.data); + + if(response.data.totalEntityCount) { + this._totalEntityCount = response.data.totalEntityCount; + } + if(response.data.totalRecordCount) { + this._totalRecordCount = response.data.totalRecordCount; + } + if(response.data.totalUnmatchedRecordCount) { + this._totalUnmatchedRecordCount = response.data.totalUnmatchedRecordCount + } + if(response.data.dataSourceCounts && response.data.dataSourceCounts.length > 0){ + this.dataSourceCounts = response.data.dataSourceCounts; + } + } + return this.dataSourceCounts; + }) + ); + } + private getDataSources(): Observable { + return this.dataSourcesService.listDataSourcesDetails() + } + getTotalsFromCounts(data: SzRecordCountDataSource[]): { totalEntityCount: number, totalRecordCount: number, totalUnmatchedRecordCount: number} + { + let retVal = 0; + let recordTotals = 0; + let entityTotals = 0; + let unmatchedTotals = 0; + if(data && data.forEach) { + data.forEach((element) => { + recordTotals = recordTotals + element.recordCount; + entityTotals = entityTotals + element.entityCount; + unmatchedTotals = unmatchedTotals + element.unmatchedRecordCount; + }); + } + return { + totalEntityCount: entityTotals, + totalRecordCount: recordTotals, + totalUnmatchedRecordCount: unmatchedTotals + }; + } + + sortBy(value: SzRecordCountDataSource[], by: 'alphadesc' | 'alphaasc' | 'countdesc' | 'countasc') { + let retVal = value; + if(value && value.sort && by) { + switch(by) { + case 'alphaasc': + retVal = retVal.sort((a,b)=>{ + return a.dataSourceCode.toUpperCase() > b.dataSourceCode.toUpperCase() ? 1 : -1; + }); + break; + case 'alphadesc': + retVal = retVal.sort((a,b)=>{ + return a.dataSourceCode.toUpperCase() < b.dataSourceCode.toUpperCase() ? 1 : -1; + }); + break; + case 'countasc': + retVal = retVal.sort((a,b)=>{ + return a.recordCount < b.recordCount ? -1 : 1; + }); + break; + case 'countdesc': + retVal = retVal.sort((a,b)=>{ + return a.recordCount < b.recordCount ? 1 : -1; + }); + break; + } + } + return retVal; + } + + // ----------------------------------------- event handlers ----------------------------------------- + + /** + * handler for when a arc node is clicked. + * proxies to synthetic event "dataSourceClick" + * @param event + */ + onArcClick(ptrEvent: PointerEvent, evtData: any) { + if(evtData && ptrEvent.pageX && ptrEvent.pageY) { + evtData.eventPageX = (ptrEvent.pageX); + evtData.eventPageY = (ptrEvent.pageY); + } + console.log('Arc clicked for datasource: ', evtData); + this.dataSourceClick.emit(evtData.data as SzRecordCountDataSource); + } + /** + * handler for when a datasource name is clicked. + * proxies to synthetic event "dataSourceClick" + * @param event + */ + public onDataSourceDetailClick(data: SzRecordCountDataSource) { + // emit event that can be listed for + this.dataSourceClick.emit(data); + } +} \ No newline at end of file diff --git a/src/lib/common/utils.ts b/src/lib/common/utils.ts index c88a62cc..b32fe1b9 100644 --- a/src/lib/common/utils.ts +++ b/src/lib/common/utils.ts @@ -17,7 +17,7 @@ export function JSONScrubber(value: any): any { return JSON.parse(JSON.stringify(value, _repl)); } } - +/** convert value of any type who's value can be converted to boolean */ export function parseBool(value: any): boolean { if (!value || value === undefined) { return false; @@ -26,7 +26,36 @@ export function parseBool(value: any): boolean { } else if (value > 0) { return true; } return false; }; - +/** convert value of any type who's value can be converted to number */ +export function parseNumber(value: any) { + if (!value || value === undefined) { + return -1; // not a number + } else if (typeof value === 'string') { + return parseInt(value.trim()); + } else if (typeof value === 'number') { + return value as number; + } + return value as number; +} +/** convert value of any type who's value can be converted to Date object */ +export function parseDate(value: any) { + if (!value || value === undefined) { + return undefined; + } else if(value && value.getTime) { + // is already valid date object + return value; + } else if(value && !value.getTime) { + // not a date object + // check to see if it can be parsed + if(Date && !isNaN( Date.parse(value))) { + // valid date, cast to object + return new Date(value); + } + // not parseable datetime + return undefined + } +} +/** convert value of any type who's value can be converted to SzIdentifier */ export function parseSzIdentifier(value: any): number { let retVal = 0; if (value && value !== undefined) { @@ -38,7 +67,6 @@ export function parseSzIdentifier(value: any): number { } return retVal; } - /** check whether a value is boolean */ export function isBoolean(value: any) { let retVal = false; @@ -49,12 +77,12 @@ export function isBoolean(value: any) { } return retVal; } - +/** trim empty values */ export function nonTextTrim(value: string): string { let retVal = value; return retVal; } - +/** is a value null */ export function isNotNull(value?: string | any) { let retVal = false; if((value as string) && (value as string) !== undefined) { @@ -171,7 +199,7 @@ export function sortMatchKeyTokensByIndex(value: SzMatchKeyTokenComposite[]): Sz } return retVal; } - +/** is value a type of array object */ export function isValueTypeOfArray(value: any) { let retVal = false; if(value) { diff --git a/src/lib/configuration/sz-license/sz-license.component.html b/src/lib/configuration/sz-license/sz-license.component.html new file mode 100644 index 00000000..3e8865bc --- /dev/null +++ b/src/lib/configuration/sz-license/sz-license.component.html @@ -0,0 +1,66 @@ +
    +
  • + + Using {{licenseLimitRatio | percent:'1.0-0'}} of your {{recordLimit | SzShortNumber}} evaluation license. + + + Using {{licenseLimitRatio | percent:'1.0-0'}} of your {{recordLimit | SzShortNumber}} record license. + +
  • +
  • warningWARNING: Your subscription record limit is invalid.
  • +
  • + + Your evaluation license is valid through {{expirationDate | date: 'longDate'}} for non production use + + + Your subscription is valid through {{expirationDate | date: 'longDate'}} + +
  • +
  • warningWARNING: Your subscription expiration date is invalid.
  • +
  • warningWARNING: You are near your licensed record limit.
  • +
  • warningWARNING: + + + Your free trial has expired! + + + Your subscription has expired! + + + + + Your subscription will expire in {{licenseDays}} days. + + + Your subscription will expire in {{licenseDays}} day. + + + >
  • +
+
+
+ + + + +
+
\ No newline at end of file diff --git a/src/lib/configuration/sz-license/sz-license.component.scss b/src/lib/configuration/sz-license/sz-license.component.scss new file mode 100644 index 00000000..15777f48 --- /dev/null +++ b/src/lib/configuration/sz-license/sz-license.component.scss @@ -0,0 +1,25 @@ +ul.license-messages { + margin-top: 30px; + margin-left: 0px; + padding: 0px; + list-style-type: none; + li { + font-size: 16px; + font-weight: 500; + margin-left: 0px; + margin-bottom: 5px; + padding: 0px; + white-space: nowrap; + line-height: 24px; + vertical-align: middle; + + span { + line-height: 24px; + vertical-align: middle; + display: inline-block; + } + span.warning-prefix { + color: var(--sz-color-config-warning); + } + } +} \ No newline at end of file diff --git a/src/lib/configuration/sz-license/sz-license.component.ts b/src/lib/configuration/sz-license/sz-license.component.ts new file mode 100644 index 00000000..cd233a38 --- /dev/null +++ b/src/lib/configuration/sz-license/sz-license.component.ts @@ -0,0 +1,198 @@ +import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core'; +import { Router } from '@angular/router'; +import { Subject, filter, takeUntil } from 'rxjs'; +import { SzCountStats, SzLicenseInfo } from '@senzing/rest-api-client-ng'; + +import { parseBool, parseNumber } from '../../common/utils'; +import { SzAdminService } from '../../services/sz-admin.service'; +import { SzDataMartService } from '../../services/sz-datamart.service'; +import { SzLicenseUpgradeType } from '../../models/data-license'; +import { SzLicenseUpgradeMouseEvent } from '../../models/event-license'; +/** + * A simple "license info" component. + * Used for displaying the current senzing license info. + * + * @example + * + * + * + * @example + * + * + */ +@Component({ + selector: 'sz-license', + templateUrl: './sz-license.component.html', + styleUrls: ['./sz-license.component.scss'] +}) +export class SzLicenseInfoComponent implements OnInit { + /** subscription to notify subscribers to unbind */ + public unsubscribe$ = new Subject(); + /** this brings in the enum to local scrope for html template access */ + readonly SzLicenseUpgradeType = SzLicenseUpgradeType; + + private _licenseInfo: SzLicenseInfo = {}; + private _countStats: SzCountStats; + private _recordCount: number; + private _showUpgradeButton: boolean = true; + + @Input() public set recordCount(value: string | number) { + this._recordCount = parseNumber(value); + } + @Input() public set showUpgradeButton(value: string | boolean) { + this._showUpgradeButton = parseBool(value); + } + private _openUpgradeButtonLink = true; + @Input() public set openUpgradeButtonLink(value: string | boolean) { + this._openUpgradeButtonLink = parseBool(value); + } + public get openUpgradeButtonLink(): boolean { + return this._openUpgradeButtonLink; + } + + public get showUpgradeButton() { + return this._showUpgradeButton; + } + public get percentUsed(): number { + return this.licenseLimitRatio; + } + public get expirationDate(): Date { + return this._licenseInfo.expirationDate; + } + public get recordLimit() { + return this.licenseInfo.recordLimit; + } + + public get licenseInfo() : SzLicenseInfo { + return this._licenseInfo; + } + + public get trialLicense() : boolean { + if (!this.licenseInfo) return false; + return (this.licenseInfo.licenseType === "EVAL" || (this.licenseInfo.licenseType && this.licenseInfo.licenseType.indexOf && this.licenseInfo.licenseType.indexOf('EVAL') > -1)); + } + + public get limitInvalid() : boolean { + if (!this.licenseInfo) return false; + const limit = this.licenseInfo.recordLimit; + if (limit === null || limit === undefined) return true; + if (limit <= 0) return true; + return false; + } + + public get expirationInvalid() : boolean { + if (!this.licenseInfo) return false; + const expDate = this.licenseInfo.expirationDate; + if (expDate === null || expDate === undefined) return true; + return false; + } + + public get approachingLimit() : boolean { + if (!this.licenseInfo) return false; + const limit = this.licenseInfo.recordLimit; + if (limit === null || limit === undefined) return false; + if (limit === 0) return true; + const ratio = this.licenseLimitRatio; + return (ratio > 0.95) ? true : false; + } + + public get licenseLimitRatio() : number { + if (!this.licenseInfo) return 0; + const limit = this.licenseInfo.recordLimit; + if (limit === null || limit === undefined) return 0; + if (limit === 0) return 1; + return (this._recordCount / limit); + } + + public get expiringSoon() : boolean { + const days = this.licenseDays; + if (days == null || days === undefined) return false; + return (days <= 30 ? true : false); + } + + public get licenseDays() : number | null { + if (!this.licenseInfo) return 0; + const expDate = this.licenseInfo.expirationDate; + if (!expDate) return null; + const exp = expDate.getTime() - (1000 * 60 * 60 * 24); + const now = (new Date()).getTime(); + return Math.ceil((exp - now) / (1000 * 60 * 60 * 24)); + } + + public get licenseType() { + if (!this.licenseInfo) return false; + return this._licenseInfo.licenseType; + } + + public get expired() : boolean { + if (!this.licenseInfo) return false; + const expDate = this.licenseInfo.expirationDate; + if (!expDate) return false; + const expYear = expDate.getFullYear(); + const expMonth = expDate.getMonth(); + const expDay = expDate.getDate(); + const now = new Date(); + const nowYear = now.getFullYear(); + const nowMonth = now.getMonth(); + const nowDay = now.getDate(); + if (nowYear > expYear) return true; + if (nowYear === expYear && nowMonth > expMonth) return true; + if (nowYear === expYear && nowMonth === expMonth && nowDay >= expDay) return true; + return false; + } + + public get licenseButtonLabelKey() : string { + if (this.trialLicense) return 'subscribe-now-label'; + if (this.approachingLimit) return 'upgrade-license-label'; + if (this.expiringSoon || this.expired) return 'renew-license-label'; + return 'view-subscription-label'; + } + + /** when a user clicks the info link inside of a step card this event is emitted*/ + @Output() public upgradeLicense = new EventEmitter(); + + //@Input() format = 'small'; + constructor( + private adminService: SzAdminService, + private dmService: SzDataMartService, + private router: Router) {} + + ngOnInit() { + this.dmService.onCountStats.pipe(filter( (val) => val !== undefined)).subscribe( (resp: SzCountStats) => { + this._countStats = resp; + if(this._countStats.totalRecordCount) { + this._recordCount = this._countStats.totalRecordCount; + } + }); + this.adminService.onLicenseInfo.subscribe( (resp: SzLicenseInfo) => { + this._licenseInfo = resp; + }); + // if "openUpgradeButtonLink" is true then redirect to senzing.com on click + this.upgradeLicense.pipe( + takeUntil(this.unsubscribe$), + filter(() => this._openUpgradeButtonLink) + ).subscribe(this.handleUpgradeLicenseClick) + } + + public handleUpgradeButtonClicked(event: Event) { + let payload = (event as SzLicenseUpgradeMouseEvent); + payload.upgradeType = this.upgradeType; + this.upgradeLicense.emit(event as SzLicenseUpgradeMouseEvent); + } + + private handleUpgradeLicenseClick(event: SzLicenseUpgradeMouseEvent) { + const url = (event && event.upgradeType === SzLicenseUpgradeType.SUBSCRIBE) + ? 'https://senzing.com/app-upgrade/' + : 'https://senzing.com/subscription-login/'; + + window.location.href = url; + } + + public get upgradeType(): SzLicenseUpgradeType { + if (this.trialLicense) return SzLicenseUpgradeType.SUBSCRIBE; + if (this.approachingLimit) return SzLicenseUpgradeType.UPGRADE; + if (this.expiringSoon || this.expired) return SzLicenseUpgradeType.RENEW; + return SzLicenseUpgradeType.VIEW; + } +} + diff --git a/src/lib/models/data-license.ts b/src/lib/models/data-license.ts new file mode 100644 index 00000000..eddf8951 --- /dev/null +++ b/src/lib/models/data-license.ts @@ -0,0 +1,9 @@ +/** the custom type of `SzResolutionStepListItemType` */ +export type SzLicenseUpgradeType = 'SUBSCRIBE' | 'UPGRADE' | 'RENEW' | 'VIEW'; +/** the possible values of a `SzResolutionStepListItemType` is */ +export const SzLicenseUpgradeType = { + SUBSCRIBE: 'SUBSCRIBE' as SzLicenseUpgradeType, + UPGRADE: 'UPGRADE' as SzLicenseUpgradeType, + RENEW: 'RENEW' as SzLicenseUpgradeType, + VIEW: 'VIEW' as SzLicenseUpgradeType, +}; \ No newline at end of file diff --git a/src/lib/models/event-basic-event.ts b/src/lib/models/event-basic-event.ts index 63beb939..3dd10708 100644 --- a/src/lib/models/event-basic-event.ts +++ b/src/lib/models/event-basic-event.ts @@ -6,4 +6,4 @@ import { SzEntityIdentifier } from '@senzing/rest-api-client-ng'; */ export interface SzEntityMouseEvent extends MouseEvent { entityId: SzEntityIdentifier -} +} \ No newline at end of file diff --git a/src/lib/models/event-license.ts b/src/lib/models/event-license.ts new file mode 100644 index 00000000..8290d3bd --- /dev/null +++ b/src/lib/models/event-license.ts @@ -0,0 +1,9 @@ +import { SzLicenseUpgradeType } from "./data-license"; + +/** + * when a user clicks on a "upgrade" button. + * @internal + */ +export interface SzLicenseUpgradeMouseEvent extends MouseEvent { + upgradeType: SzLicenseUpgradeType +} \ No newline at end of file diff --git a/src/lib/models/stats.ts b/src/lib/models/stats.ts new file mode 100644 index 00000000..86789245 --- /dev/null +++ b/src/lib/models/stats.ts @@ -0,0 +1,16 @@ +import { SzCountStats, SzSourceCountStats, SzCountStatsResponse } from "@senzing/rest-api-client-ng"; + +export interface SzCountStatsForDataSourcesResponse extends SzCountStatsResponse { + /** override with extended */ + data?: SzStatCountsForDataSources; +} +export interface SzStatCountsForDataSources extends SzCountStats { + /** we add pending count so app can optionally inject values */ + totalPendingCount?: number, + /** we change this to the extended model which includes color and pending count */ + dataSourceCounts: SzRecordCountDataSource[] +} +export interface SzRecordCountDataSource extends SzSourceCountStats { + pendingCount?: number, + color?: string + } \ No newline at end of file diff --git a/src/lib/pipes/decimalpercent.pipe.ts b/src/lib/pipes/decimalpercent.pipe.ts new file mode 100644 index 00000000..7f4a0b16 --- /dev/null +++ b/src/lib/pipes/decimalpercent.pipe.ts @@ -0,0 +1,18 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'SzDecimalPercent' + }) + export class SzDecimalPercentPipe implements PipeTransform { + transform(percent: number, precision: number = 1): any { + if (isNaN(percent)) return null; // will only work value is a number + if (percent === null) return null; + if (percent === 0) return null; + if((percent * 100) < 1){ + // add floating point so user can see something other than "0%" + return (percent * 100).toFixed(precision) +'%'; + } + // we don't care about the ".32492%" part if > 1 + return (percent * 100).toFixed(0)+'%'; + } + } \ No newline at end of file diff --git a/src/lib/pipes/shortnumber.pipe.ts b/src/lib/pipes/shortnumber.pipe.ts new file mode 100644 index 00000000..21b675ed --- /dev/null +++ b/src/lib/pipes/shortnumber.pipe.ts @@ -0,0 +1,36 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'SzShortNumber' +}) +export class SzShortNumberPipe implements PipeTransform { + transform(number: number, args?: any): any { + if (isNaN(number)) return null; // will only work value is a number + if (number === null) return null; + if (number === 0) return null; + + let abs = Math.abs(number); + const rounder = Math.pow(10, 1); + const isNegative = number < 0; // will also work for Negative numbers + let key = ''; + + const powers = [ + {key: 'Q', value: Math.pow(10, 15)}, + {key: 'T', value: Math.pow(10, 12)}, + {key: 'B', value: Math.pow(10, 9)}, + {key: 'M', value: Math.pow(10, 6)}, + {key: 'K', value: 1000} + ]; + for (let i = 0; i < powers.length; i++) { + let reduced = abs / powers[i].value; + reduced = Math.round(reduced * rounder) / rounder; + + if (reduced >= 1) { + abs = reduced; + key = powers[i].key; + break; + } + } + return (isNegative ? '-' : '') + abs + key; + } +} \ No newline at end of file diff --git a/src/lib/scss/_variables.scss b/src/lib/scss/_variables.scss index d3d2bbb6..17edcdbc 100644 --- a/src/lib/scss/_variables.scss +++ b/src/lib/scss/_variables.scss @@ -31,6 +31,7 @@ $sz-input-placeholder-color: #95989A; $sz-input-border-color: $sz-grey; $sz-input-active-border-color: $sz-dark-grey; $sz-input-background-color: $sz-white; +$sz-config-warn-color: $sz-magenta; //Navigation $sz-nav-parent__font-stack: Helvetica, 11px; diff --git a/src/lib/scss/charts.scss b/src/lib/scss/charts.scss new file mode 100644 index 00000000..8c39be5a --- /dev/null +++ b/src/lib/scss/charts.scss @@ -0,0 +1,40 @@ +sz-record-counts-donut { + .donut-chart { + .arc path { + stroke: var(--sz-donut-chart-graph-arc-stroke); + stroke-width: var(--sz-donut-chart-graph-arc-stroke-width); + stroke-opacity: var(--sz-donut-chart-graph-arc-stroke-opacity); + + &.item-unmatched { + fill: var(--sz-donut-chart-unmatched-color); + } + &.item-pending { + fill: var(--sz-donut-chart-pending-color); + } + } + } + .legend { + .legend__color-dot { + &.item-unmatched { + background-color: var(--sz-donut-chart-unmatched-color); + } + &.item-pending { + background-color: var(--sz-donut-chart-pending-color); + } + } + } +} + +.sz-donut-chart-tooltip { + position: absolute; + background-color: white; + font-size: 10px; + min-width: 100px; + max-width: 50vw; + height: auto; + padding: 2px 4px; + border-radius: 4px; + border: 1px solid; + box-shadow: 3px 3px 10px rgba(0, 0, 0, .5); + pointer-events: none; + } \ No newline at end of file diff --git a/src/lib/scss/styles.scss b/src/lib/scss/styles.scss index 652268a5..a8a10d41 100644 --- a/src/lib/scss/styles.scss +++ b/src/lib/scss/styles.scss @@ -1,4 +1,5 @@ @import "./theme"; +@import "./charts"; @import "./graph"; @import "./why"; @import "./how-rc"; diff --git a/src/lib/scss/theme.scss b/src/lib/scss/theme.scss index b01d2750..b6b32442 100644 --- a/src/lib/scss/theme.scss +++ b/src/lib/scss/theme.scss @@ -83,6 +83,7 @@ body { --sz-color-graph-relationships: #{$sz-magenta}; --sz-color-how-report: #{$sz-lime}; --sz-color-why-report: #{$sz-teal}; + --sz-color-config-warning: #{$sz-config-warn-color}; /* search box vars */ --sz-search-button-submit-color: #{$sz-white}; @@ -467,5 +468,14 @@ body { --sz-entity-detail-section-how-step-card-data-table-border: var(--sz-how-step-card-data-table-border); --sz-entity-detail-section-how-step-card-data-table-cell-border: var(--sz-how-step-card-data-table-cell-border); --sz-entity-detail-section-how-step-card-title-text-margin-top: 3px; + + /* charts */ + /* record stats donut */ + --sz-donut-chart-graph-align-items: start; + --sz-donut-chart-graph-arc-stroke: rgba(0, 0, 0, 0.7); + --sz-donut-chart-graph-arc-stroke-width: 1px; + --sz-donut-chart-graph-arc-stroke-opacity: 0.5; + --sz-donut-chart-unmatched-color: rgb(255 150 125); + --sz-donut-chart-pending-color: rgb(235 255 125); } diff --git a/src/lib/scss/themes/senzing.css b/src/lib/scss/themes/senzing.css index 18bff167..b7588a48 100644 --- a/src/lib/scss/themes/senzing.css +++ b/src/lib/scss/themes/senzing.css @@ -189,4 +189,8 @@ body { --sz-entity-graph-control-item-margin: 0; /* how related */ --sz-how-step-card-base-z-index: 500; + + /* charts */ + /* record stats donut */ + --sz-donut-chart-graph-align-items: center; } diff --git a/src/lib/sdk.module.ts b/src/lib/sdk.module.ts index 9175c448..7b51e595 100644 --- a/src/lib/sdk.module.ts +++ b/src/lib/sdk.module.ts @@ -18,6 +18,7 @@ import { SzSdkMaterialModule } from './sdk.material.module'; import { SzMessageBundleService } from './services/sz-message-bundle.service'; import { SzSearchService } from './services/sz-search.service'; import { SzConfigurationService } from './services/sz-configuration.service'; +import { SzDataMartService } from './services/sz-datamart.service'; import { SzFoliosService } from './services/sz-folios.service'; import { SzUIEventService } from './services/sz-ui.service'; import { SzPrefsService } from './services/sz-prefs.service'; @@ -26,11 +27,20 @@ import { SzAdminService } from './services/sz-admin.service'; import { SzBulkDataService } from './services/sz-bulk-data.service'; import { SzCSSClassService } from './services/sz-css-class.service'; import { SzConfigDataService } from './services/sz-config-data.service'; - +/** pipes */ +import { SzShortNumberPipe } from './pipes/shortnumber.pipe' +import { SzDecimalPercentPipe } from './pipes/decimalpercent.pipe'; +/** charts */ +import { SzRecordStatsDonutChart } from './charts/records-by-datasources/sz-donut.component' /** components */ -import { SzMultiSelectButtonComponent } from './shared/multi-select-button/multi-select-button.component'; import { SzAlertMessageDialog } from './shared/alert-dialog/sz-alert-dialog.component'; - +import { SzConfigurationAboutComponent } from './configuration/sz-configuration-about/sz-configuration-about.component'; +import { SzConfigurationComponent } from './configuration/sz-configuration/sz-configuration.component'; +import { SzLicenseInfoComponent } from './configuration/sz-license/sz-license.component'; +import { SzMultiSelectButtonComponent } from './shared/multi-select-button/multi-select-button.component'; +import { SzPoweredByComponent } from './sz-powered-by/sz-powered-by.component'; +import { SzPreferencesComponent } from './configuration/sz-preferences/sz-preferences.component'; +import { SzPrefDictComponent } from './configuration/sz-preferences/sz-pref-dict/sz-pref-dict.component'; /** entity resume related */ import { SzEntityDetailComponent } from './entity/detail/sz-entity-detail.component'; import { SzEntityDetailHeaderComponent } from './entity/detail/sz-entity-detail-header/header.component'; @@ -45,7 +55,6 @@ import { SzEntityMatchPillComponent } from './entity/sz-entity-match-pill/sz-ent import { SzEntityRecordCardComponent } from './entity/sz-entity-record-card/sz-entity-record-card.component'; import { SzEntityRecordCardHeaderComponent } from './entity/sz-entity-record-card/sz-entity-record-card-header/sz-entity-record-card-header.component'; import { SzEntityRecordCardContentComponent } from './entity/sz-entity-record-card/sz-entity-record-card-content/sz-entity-record-card-content.component'; - // graph components import { SzRelationshipNetworkComponent } from './graph/sz-relationship-network/sz-relationship-network.component'; import { SzRelationshipNetworkInputComponent } from './graph/sz-relationship-network-input/sz-relationship-network-input.component'; @@ -66,11 +75,6 @@ import { SzSearchResultsComponent } from './search/sz-search-results/sz-search-r import { SzSearchResultCardComponent } from './search/sz-search-result-card/sz-search-result-card.component'; import { SzSearchResultCardContentComponent } from './search/sz-search-result-card/sz-search-result-card-content/sz-search-result-card-content.component'; import { SzSearchResultCardHeaderComponent } from './search/sz-search-result-card/sz-search-result-card-header/sz-search-result-card-header.component'; -import { SzConfigurationAboutComponent } from './configuration/sz-configuration-about/sz-configuration-about.component'; -import { SzConfigurationComponent } from './configuration/sz-configuration/sz-configuration.component'; -import { SzPoweredByComponent } from './sz-powered-by/sz-powered-by.component'; -import { SzPreferencesComponent } from './configuration/sz-preferences/sz-preferences.component'; -import { SzPrefDictComponent } from './configuration/sz-preferences/sz-pref-dict/sz-pref-dict.component'; // why related import { SzWhyEntityComponent } from './why/sz-why-entity.component'; import { SzWhyEntitiesComparisonComponent } from './why/sz-why-entities.component'; @@ -118,6 +122,7 @@ const SzRestConfigurationInjector = new InjectionToken("SzR SzAlertMessageDialog, SzConfigurationAboutComponent, SzConfigurationComponent, + SzDecimalPercentPipe, SzEntityDetailComponent, SzEntityDetailGraphControlComponent, SzEntityDetailGraphComponent, @@ -146,10 +151,12 @@ const SzRestConfigurationInjector = new InjectionToken("SzR SzHowStepStackComponent, SzHowVirtualEntityCardComponent, SzHowVirtualEntityDialog, + SzLicenseInfoComponent, SzMultiSelectButtonComponent, SzPoweredByComponent, SzPreferencesComponent, SzPrefDictComponent, + SzRecordStatsDonutChart, SzRelationshipNetworkComponent, SzRelationshipNetworkInputComponent, SzRelationshipNetworkLookupComponent, @@ -162,6 +169,7 @@ const SzRestConfigurationInjector = new InjectionToken("SzR SzSearchResultCardComponent, SzSearchResultCardContentComponent, SzSearchResultCardHeaderComponent, + SzShortNumberPipe, SzStandaloneGraphComponent, SzWhyEntitiesComparisonComponent, SzWhyEntityComponent, @@ -183,6 +191,7 @@ const SzRestConfigurationInjector = new InjectionToken("SzR exports: [ SzConfigurationComponent, SzConfigurationAboutComponent, + SzDecimalPercentPipe, SzEntityDetailGraphComponent, SzEntityDetailComponent, SzEntityDetailHowReportComponent, @@ -199,12 +208,15 @@ const SzRestConfigurationInjector = new InjectionToken("SzR SzHowStepStackComponent, SzHowVirtualEntityCardComponent, SzHowVirtualEntityDialog, + SzLicenseInfoComponent, SzPoweredByComponent, SzPreferencesComponent, + SzRecordStatsDonutChart, SzRelationshipNetworkComponent, SzRelationshipNetworkInputComponent, SzRelationshipNetworkLookupComponent, SzRelationshipPathComponent, + SzShortNumberPipe, SzSearchComponent, SzSearchByIdComponent, SzSearchResultsComponent, @@ -223,6 +235,7 @@ const SzRestConfigurationInjector = new InjectionToken("SzR SzConfigDataService, SzConfigurationService, SzCSSClassService, + SzDataMartService, SzDataSourcesService, SzFoliosService, SzHowUIService, diff --git a/src/lib/services/sz-admin.service.ts b/src/lib/services/sz-admin.service.ts index 65873dbf..88613069 100644 --- a/src/lib/services/sz-admin.service.ts +++ b/src/lib/services/sz-admin.service.ts @@ -13,6 +13,7 @@ import { BulkDataService, SzBulkDataAnalysisResponse, SzServerInfo, SzServerInfoResponse, SzDataSourcesResponseData } from '@senzing/rest-api-client-ng'; import { map, tap } from 'rxjs/operators'; +import { parseDate } from '../common/utils'; /** * Service to provide methods and properties from the @@ -101,7 +102,15 @@ export class SzAdminService { public getLicenseInfo(): Observable { return this.adminService.license() .pipe( - map( (resp: SzLicenseResponse) => resp.data.license ), + map( (resp: SzLicenseResponse) => { + // cast Date strings to actual Date Object(s) if available + let retVal = resp.data.license; + let expiryDate = parseDate(retVal.expirationDate); + let issuanceDate = parseDate(retVal.issuanceDate); + if(expiryDate) { retVal.expirationDate = expiryDate} + if(issuanceDate) { retVal.issuanceDate = issuanceDate} + return retVal; + }), tap( (licInfo: SzLicenseInfo ) => { this.licenseInfo = licInfo; }) ); } diff --git a/src/lib/services/sz-datamart.service.ts b/src/lib/services/sz-datamart.service.ts new file mode 100644 index 00000000..2faa9a8e --- /dev/null +++ b/src/lib/services/sz-datamart.service.ts @@ -0,0 +1,60 @@ +import { Injectable, OnInit } from '@angular/core'; +import { BehaviorSubject, Observable, of, Subject, throwError } from 'rxjs'; + +import { + ConfigService as SzConfigService, SzConfigResponse, + StatisticsService as SzStatisticsService, + SzCountStats +} from '@senzing/rest-api-client-ng'; + +import { take, tap, map } from 'rxjs/operators'; +import { HttpClient } from '@angular/common/http'; +import { SzCountStatsForDataSourcesResponse } from '../models/stats'; + +// use mock data +//import * as recordStatsStubData from '../../stubs/statistics/loaded/1.json'; + + +/** + * methods used to get data from the poc server using the + * datamart api(s) + * + * @export + */ +@Injectable({ + providedIn: 'root' +}) +export class SzDataMartService { + //private _recordStatsStubData = recordStatsStubData; + + public onCountStats: Subject = new BehaviorSubject(undefined); + + constructor(private http: HttpClient, private statsService: SzStatisticsService) {} + + public getLoadedStatistics(): Observable { + return this.statsService.getLoadedStatistics().pipe( + tap((response) => { + if(response && response.data) { + this.onCountStats.next(response.data); + } + }) + ) + /* + let retVal = new Observable(); + // for now just return stub data + return of(this._recordStatsStubData as unknown as SzCountStatsForDataSourcesResponse).pipe( + tap((response) => { + if(response && response.data) { + this.onCountStats.next(response.data); + } + }) + )*/ + } + + /*public getRecordCounts(): Observable { + let retVal = new Observable(); + // for now just return stub data + return of(this._recordStatsStubData); + //return retVal; + }*/ +} \ No newline at end of file diff --git a/src/package.json b/src/package.json index 8effa9d0..dc9fc090 100644 --- a/src/package.json +++ b/src/package.json @@ -20,6 +20,6 @@ "@angular/common": "^15.0.0", "@angular/core": "^15.0.0", "@angular/material": "^15.0.0", - "@senzing/rest-api-client-ng": "^6.0.0" + "@senzing/rest-api-client-ng": "^6.1.0" } } \ No newline at end of file diff --git a/src/public-api.ts b/src/public-api.ts index b3dd02b8..5f73b9f4 100644 --- a/src/public-api.ts +++ b/src/public-api.ts @@ -12,6 +12,7 @@ export * from './lib/entity/entity-utils'; /** services */ export * from './lib/services/sz-message-bundle.service'; export * from './lib/services/sz-configuration.service'; +export * from './lib/services/sz-datamart.service'; export { SzAdminService } from './lib/services/sz-admin.service'; export { SzBulkDataService } from './lib/services/sz-bulk-data.service'; @@ -56,10 +57,14 @@ export { SzEntityDetailGraphComponent } from './lib/entity/detail/sz-entity-deta export { SzStandaloneGraphComponent } from './lib/entity/detail/sz-entity-detail-graph/sz-standalone-graph.component'; export * from './lib/entity/detail/sz-entity-detail-graph/sz-entity-detail-graph-control.component'; export * from './lib/entity/detail/sz-entity-detail-graph/sz-entity-detail-graph-filter.component'; +/* charts */ +export { SzRecordStatsDonutChart } from './lib/charts/records-by-datasources/sz-donut.component' +/** components */ export * from './lib/sz-powered-by/sz-powered-by.component'; export * from './lib/configuration/sz-configuration/sz-configuration.component'; export * from './lib/configuration/sz-configuration-about/sz-configuration-about.component'; +export { SzLicenseInfoComponent } from './lib/configuration/sz-license/sz-license.component'; export { SzPreferencesComponent } from './lib/configuration/sz-preferences/sz-preferences.component'; /** models */ @@ -74,6 +79,9 @@ export { SzDataSourceRecordAnalysis, SzDataSourceComposite } from './lib/models/ export { SzGraphTooltipEntityModel, SzGraphTooltipLinkModel, SzGraphNodeFilterPair, SzMatchKeyComposite, SzMatchKeyTokenComposite, SzEntityNetworkMatchKeyTokens, SzNetworkGraphInputs, SzMatchKeyTokenFilterScope } from './lib/models/graph'; export { SzDataSourceRecordsSelection, SzDataSourceRecordSelection, SzWhySelectionModeBehavior, SzWhySelectionMode } from './lib/models/data-source-record-selection'; export * from './lib/models/data-how'; +/** pipes */ +export { SzShortNumberPipe } from './lib/pipes/shortnumber.pipe' +export { SzDecimalPercentPipe } from './lib/pipes/decimalpercent.pipe' /** why */ export { SzWhyEntityComponent } from './lib/why/sz-why-entity.component'; diff --git a/src/tsconfig.lib.json b/src/tsconfig.lib.json index b062dddd..ade94bbe 100644 --- a/src/tsconfig.lib.json +++ b/src/tsconfig.lib.json @@ -6,6 +6,7 @@ "declaration": true, "declarationMap": true, "inlineSources": true, + "resolveJsonModule": true, "types": [] }, "exclude": [ diff --git a/tsconfig.json b/tsconfig.json index 338a9686..5b1b7de4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ "downlevelIteration": true, "experimentalDecorators": true, "moduleResolution": "node", + "resolveJsonModule": true, "importHelpers": true, "paths": { "@senzing/sdk-components-ng": [