@cosla/sensemaking-web-ui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +35 -0
  2. package/angular.json +116 -0
  3. package/health_check.js +68 -0
  4. package/karma.conf.js +43 -0
  5. package/package.json +93 -0
  6. package/public/favicon.ico +0 -0
  7. package/server.ts +60 -0
  8. package/single-html-build.js +100 -0
  9. package/site-build.ts +54 -0
  10. package/src/app/app.component.html +1 -0
  11. package/src/app/app.component.scss +0 -0
  12. package/src/app/app.component.spec.ts +32 -0
  13. package/src/app/app.component.ts +21 -0
  14. package/src/app/app.config.server.ts +9 -0
  15. package/src/app/app.config.ts +19 -0
  16. package/src/app/app.routes.ts +8 -0
  17. package/src/app/components/dialog/dialog.component.html +15 -0
  18. package/src/app/components/dialog/dialog.component.scss +42 -0
  19. package/src/app/components/dialog/dialog.component.spec.ts +27 -0
  20. package/src/app/components/dialog/dialog.component.ts +40 -0
  21. package/src/app/components/sensemaking-chart-wrapper/sensemaking-chart-wrapper.component.ts +66 -0
  22. package/src/app/components/statement-card/statement-card.component.html +51 -0
  23. package/src/app/components/statement-card/statement-card.component.scss +134 -0
  24. package/src/app/components/statement-card/statement-card.component.spec.ts +23 -0
  25. package/src/app/components/statement-card/statement-card.component.ts +50 -0
  26. package/src/app/directives/custom-tooltip/custom-tooltip.directive.spec.ts +39 -0
  27. package/src/app/directives/custom-tooltip/custom-tooltip.directive.ts +89 -0
  28. package/src/app/models/report.model.ts +48 -0
  29. package/src/app/pages/report/report.component.html +363 -0
  30. package/src/app/pages/report/report.component.scss +600 -0
  31. package/src/app/pages/report/report.component.spec.ts +29 -0
  32. package/src/app/pages/report/report.component.ts +276 -0
  33. package/src/environments/environment.ts +5 -0
  34. package/src/index.html +17 -0
  35. package/src/main.server.ts +7 -0
  36. package/src/main.ts +5 -0
  37. package/src/style-vars.scss +40 -0
  38. package/src/styles.scss +23 -0
  39. package/tsconfig.app.json +19 -0
  40. package/tsconfig.json +32 -0
  41. package/tsconfig.spec.json +15 -0
@@ -0,0 +1,15 @@
1
+ <div class="root">
2
+ <h2 mat-dialog-title>{{ data.title }}</h2>
3
+ <mat-dialog-content>
4
+ <div>{{ data.text }}</div>
5
+ <div class="copy-container">
6
+ <div class="copy-text">{{ data.link }}</div>
7
+ <button mat-icon-button (click)="copyLink()" aria-label="Copy link to clipboard">
8
+ <mat-icon>content_copy</mat-icon>
9
+ </button>
10
+ </div>
11
+ </mat-dialog-content>
12
+ <mat-dialog-actions>
13
+ <button mat-button (click)="close()">Close</button>
14
+ </mat-dialog-actions>
15
+ </div>
@@ -0,0 +1,42 @@
1
+ @import "../../../style-vars";
2
+
3
+ .root {
4
+ max-width: 450px;
5
+
6
+ button[mat-button] {
7
+ color: $science-blue;
8
+ font-family: $main-font;
9
+ }
10
+
11
+ h2[mat-dialog-title] {
12
+ color: $mine-shaft;
13
+ font-family: $main-font;
14
+ }
15
+
16
+ mat-dialog-content {
17
+ color: $cape-cod;
18
+ font-family: $main-font;
19
+ }
20
+ }
21
+
22
+ .copy-container {
23
+ border: 1px solid $gray-nurse;
24
+ border-radius: 8px;
25
+ display: flex;
26
+ align-items: center;
27
+ justify-content: space-between;
28
+ column-gap: 10px;
29
+ margin-top: 20px;
30
+ padding: 4px 4px 4px 12px;
31
+
32
+ .copy-text {
33
+ overflow: hidden;
34
+ text-overflow: ellipsis;
35
+ white-space: nowrap;
36
+ }
37
+
38
+ mat-icon {
39
+ color: $cape-cod;
40
+ flex-shrink: 0;
41
+ }
42
+ }
@@ -0,0 +1,27 @@
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
3
+ import { DialogComponent } from './dialog.component';
4
+
5
+ describe('DialogComponent', () => {
6
+ let component: DialogComponent;
7
+ let fixture: ComponentFixture<DialogComponent>;
8
+
9
+ beforeEach(async () => {
10
+ await TestBed.configureTestingModule({
11
+ imports: [DialogComponent],
12
+ providers: [
13
+ { provide: MAT_DIALOG_DATA, useValue: {} },
14
+ { provide: MatDialogRef, useValue: {} },
15
+ ]
16
+ })
17
+ .compileComponents();
18
+
19
+ fixture = TestBed.createComponent(DialogComponent);
20
+ component = fixture.componentInstance;
21
+ fixture.detectChanges();
22
+ });
23
+
24
+ it('should create', () => {
25
+ expect(component).toBeTruthy();
26
+ });
27
+ });
@@ -0,0 +1,40 @@
1
+ import { Clipboard } from '@angular/cdk/clipboard';
2
+ import { Component, Inject } from "@angular/core";
3
+ import { CommonModule } from "@angular/common";
4
+ import { MatButtonModule } from "@angular/material/button";
5
+ import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from "@angular/material/dialog";
6
+ import { MatIconModule } from '@angular/material/icon';
7
+
8
+ type DialogData = {
9
+ link: string,
10
+ text: string,
11
+ title: string,
12
+ };
13
+
14
+ @Component({
15
+ selector: 'app-dialog',
16
+ standalone: true,
17
+ imports: [
18
+ CommonModule,
19
+ MatButtonModule,
20
+ MatDialogModule,
21
+ MatIconModule,
22
+ ],
23
+ templateUrl: './dialog.component.html',
24
+ styleUrl: './dialog.component.scss'
25
+ })
26
+ export class DialogComponent {
27
+ constructor(
28
+ private clipboard: Clipboard,
29
+ public dialogRef: MatDialogRef<DialogComponent>,
30
+ @Inject(MAT_DIALOG_DATA) public data: DialogData
31
+ ) {}
32
+
33
+ close() {
34
+ this.dialogRef.close();
35
+ }
36
+
37
+ copyLink() {
38
+ this.clipboard.copy(this.data.link);
39
+ }
40
+ }
@@ -0,0 +1,66 @@
1
+ import { CommonModule } from '@angular/common';
2
+ import {
3
+ Component,
4
+ CUSTOM_ELEMENTS_SCHEMA,
5
+ Input,
6
+ ViewChild,
7
+ ElementRef,
8
+ AfterViewInit,
9
+ } from '@angular/core';
10
+ import '@conversationai/sensemaker-visualizations';
11
+
12
+ @Component({
13
+ selector: 'app-sensemaking-chart-wrapper',
14
+ standalone: true,
15
+ imports: [CommonModule],
16
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
17
+ template: `
18
+ <div class="chart-container">
19
+ <sensemaker-chart
20
+ #sensemakingChartEl
21
+ [attr.id]="chartId"
22
+ [attr.chart-type]="chartType"
23
+ [attr.view]="view"
24
+ [attr.topic-filter]="topicFilter"
25
+ [attr.colors]="colors?.length ? (colors | json) : null"
26
+ ></sensemaker-chart>
27
+ </div>
28
+ `,
29
+ styles: [
30
+ `
31
+ .chart-container {
32
+ width: 100%;
33
+ height: 100%;
34
+ }
35
+ `,
36
+ ],
37
+ })
38
+ export class SensemakingChartWrapperComponent implements AfterViewInit {
39
+ @ViewChild('sensemakingChartEl') chartElementRef!: ElementRef<
40
+ HTMLElement & {
41
+ data?: any;
42
+ summaryData?: any;
43
+ }
44
+ >;
45
+
46
+ @Input() chartId: string = '';
47
+ @Input() chartType: string = 'topics-distribution';
48
+ @Input() view: string = 'cluster';
49
+ @Input() topicFilter: string = '';
50
+ @Input() colors: string[] = [];
51
+ @Input() data: any;
52
+ @Input() summaryData: any;
53
+
54
+ ngAfterViewInit() {
55
+ // Set the data directly on the web component
56
+ if (this.chartElementRef?.nativeElement) {
57
+ const chartElement = this.chartElementRef.nativeElement;
58
+
59
+ // Set the main data
60
+ chartElement.data = this.data;
61
+
62
+ // Set the summary data
63
+ chartElement.summaryData = this.summaryData;
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,51 @@
1
+ <ng-template #mainStatement let-truncated="truncated">
2
+ <div class="pills" [ngSwitch]="type">
3
+ <ng-container *ngSwitchCase="'high-alignment'">
4
+ <div class="pill pos" *ngIf="isOverallAgree">{{ agreePercent }}% voted agree</div>
5
+ <div class="pill neg" *ngIf="!isOverallAgree">{{ disagreePercent }}% voted disagree</div>
6
+ </ng-container>
7
+ <ng-container *ngSwitchCase="'low-alignment'">
8
+ <div class="pill neutral">{{ agreePercent }}% voted agree</div>
9
+ <div class="pill neutral">{{ disagreePercent }}% voted disagree</div>
10
+ </ng-container>
11
+ <ng-container *ngSwitchCase="'high-uncertainty'">
12
+ <div class="pill blank">{{ passPercent }}% voted "unsure/pass"</div>
13
+ </ng-container>
14
+ <ng-container *ngSwitchCase="'uncategorized'">
15
+ <div class="pill blank">{{ agreePercent }}% voted agree</div>
16
+ <div class="pill blank">{{ disagreePercent }}% voted disagree</div>
17
+ </ng-container>
18
+ </div>
19
+ <p [class.truncated]="truncated">{{ data?.text }}</p>
20
+ </ng-template>
21
+ <article class="inline-card" [customTooltip]="tooltipTemplate">
22
+ <ng-container *ngTemplateOutlet="mainStatement; context: { truncated: truncate }"></ng-container>
23
+ </article>
24
+ <ng-template #tooltipTemplate>
25
+ <div class="popup-card">
26
+ <div class="popup-top">
27
+ <ng-container *ngTemplateOutlet="mainStatement"></ng-container>
28
+ <div class="topic-breakdown">Topic(s): {{ topics }}</div>
29
+ </div>
30
+ <div class="popup-bottom">
31
+ <div class="subheading">{{ voteTotal }} total votes</div>
32
+ <div class="vote-breakdown">
33
+ <div class="vote-type">
34
+ <div class="vote-dot agree"></div>
35
+ <div>Agree</div>
36
+ </div>
37
+ <div>{{ agreeTotal }}</div>
38
+ <div class="vote-type">
39
+ <div class="vote-dot disagree"></div>
40
+ <div>Disagree</div>
41
+ </div>
42
+ <div>{{ disagreeTotal }}</div>
43
+ <div class="vote-type">
44
+ <div class="vote-dot pass"></div>
45
+ <div>"Unsure/Pass"</div>
46
+ </div>
47
+ <div>{{ passTotal }}</div>
48
+ </div>
49
+ </div>
50
+ </div>
51
+ </ng-template>
@@ -0,0 +1,134 @@
1
+ @import "../../../style-vars";
2
+
3
+ @mixin card-shape {
4
+ border-radius: 16px 16px 16px 0;
5
+ }
6
+
7
+ .inline-card {
8
+ @include card-shape;
9
+ background-color: $white;
10
+ border: 1px solid $mystic;
11
+ height: 100%; // needed for usage of element in grid to ensure it takes up any empty space in the grid row
12
+ padding: 12px;
13
+
14
+ &:hover {
15
+ background-color: $mystic-2;
16
+ }
17
+ }
18
+
19
+ p {
20
+ font-size: 12px;
21
+ line-height: 1.33;
22
+ letter-spacing: 0.1px;
23
+
24
+ &.truncated {
25
+ @include truncateText(3);
26
+ }
27
+ }
28
+
29
+ .pill {
30
+ border-radius: 500px;
31
+ color: $mine-shaft;
32
+ font-size: 10px;
33
+ letter-spacing: 0.1px;
34
+ line-height: 1.2;
35
+ padding: 3px 8px;
36
+ width: fit-content;
37
+
38
+ &.blank {
39
+ background-color: $white;
40
+ border: 1px solid $silver;
41
+ padding: 2px 7px;
42
+ }
43
+
44
+ &.neg {
45
+ background-color: $your-pink;
46
+ }
47
+
48
+ &.neutral {
49
+ background-color: $alto;
50
+ }
51
+
52
+ &.pos {
53
+ background-color: $magic-mint;
54
+ }
55
+ }
56
+
57
+ .pills {
58
+ display: flex;
59
+ flex-wrap: wrap;
60
+ column-gap: 5px;
61
+ row-gap: 5px;
62
+ margin-bottom: 8px;
63
+ }
64
+
65
+ .popup-card {
66
+ @include card-shape;
67
+ background-color: $white;
68
+ box-shadow: 0px 2px 6px rgba(0, 0, 0, 0.15), 0px 1px 2px rgba(0, 0, 0, 0.30);
69
+ max-width: 250px;
70
+
71
+ .popup-bottom {
72
+ padding: 12px;
73
+ }
74
+
75
+ .popup-top {
76
+ border-bottom: 1px solid $mystic;
77
+ padding: 12px;
78
+ }
79
+ }
80
+
81
+ .subheading {
82
+ color: $cape-cod-2;
83
+ font-size: 12px;
84
+ font-weight: 500;
85
+ letter-spacing: 0.1px;
86
+ line-height: 1.33;
87
+ }
88
+
89
+ .topic-breakdown {
90
+ font-size: 10px;
91
+ font-weight: 600;
92
+ letter-spacing: 0.1px;
93
+ line-height: 1.6;
94
+ margin-top: 10px;
95
+ }
96
+
97
+ .vote-breakdown {
98
+ color: $cape-cod-2;
99
+ font-size: 10px;
100
+ font-weight: 500;
101
+ letter-spacing: 0.1px;
102
+ line-height: 1.33;
103
+ display: grid;
104
+ grid-template-columns: max-content auto;
105
+ gap: 7px 25px;
106
+ margin-top: 5px;
107
+ }
108
+
109
+ .vote-dot {
110
+ border-radius: 50%;
111
+ display: inline-block;
112
+ flex-shrink: 0;
113
+ height: 4px;
114
+ width: 4px;
115
+
116
+ &.agree {
117
+ background-color: $magic-mint;
118
+ }
119
+
120
+ &.disagree {
121
+ background-color: $your-pink;
122
+ }
123
+
124
+ &.pass {
125
+ background-color: $white;
126
+ border: 0.5px solid $boulder;
127
+ }
128
+ }
129
+
130
+ .vote-type {
131
+ display: flex;
132
+ align-items: center;
133
+ column-gap: 5px;
134
+ }
@@ -0,0 +1,23 @@
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+
3
+ import { StatementCardComponent } from './statement-card.component';
4
+
5
+ describe('StatementCardComponent', () => {
6
+ let component: StatementCardComponent;
7
+ let fixture: ComponentFixture<StatementCardComponent>;
8
+
9
+ beforeEach(async () => {
10
+ await TestBed.configureTestingModule({
11
+ imports: [StatementCardComponent]
12
+ })
13
+ .compileComponents();
14
+
15
+ fixture = TestBed.createComponent(StatementCardComponent);
16
+ component = fixture.componentInstance;
17
+ fixture.detectChanges();
18
+ });
19
+
20
+ it('should create', () => {
21
+ expect(component).toBeTruthy();
22
+ });
23
+ });
@@ -0,0 +1,50 @@
1
+ import { OverlayModule } from '@angular/cdk/overlay';
2
+ import { CommonModule } from '@angular/common';
3
+ import { Component, Input, OnInit } from '@angular/core';
4
+ import { CustomTooltipDirective } from "../../directives/custom-tooltip/custom-tooltip.directive";
5
+
6
+ import { VoteGroup, Statement } from "../../models/report.model";
7
+
8
+ @Component({
9
+ selector: 'app-statement-card',
10
+ standalone: true,
11
+ imports: [
12
+ CommonModule,
13
+ CustomTooltipDirective,
14
+ OverlayModule,
15
+ ],
16
+ templateUrl: './statement-card.component.html',
17
+ styleUrl: './statement-card.component.scss'
18
+ })
19
+ export class StatementCardComponent implements OnInit {
20
+ @Input() data?: Statement;
21
+ @Input() truncate = false;
22
+ @Input() type = "";
23
+ isOverallAgree?: boolean;
24
+ agreePercent?: number;
25
+ disagreePercent?: number;
26
+ passPercent?: number;
27
+ agreeTotal = 0;
28
+ disagreeTotal = 0;
29
+ passTotal = 0;
30
+ voteTotal = 0;
31
+ topics = "";
32
+
33
+ ngOnInit() {
34
+ if(!this.data) return;
35
+ this.isOverallAgree = this.data.agreeRate >= this.data.disagreeRate;
36
+ this.agreePercent = Math.round(this.data.agreeRate * 100);
37
+ this.disagreePercent = Math.round(this.data.disagreeRate * 100);
38
+ this.passPercent = Math.round(this.data.passRate * 100);
39
+ Object.values(this.data.votes).forEach((voterGroup: VoteGroup) => {
40
+ const { agreeCount, disagreeCount, passCount } = voterGroup;
41
+ this.agreeTotal += agreeCount;
42
+ this.disagreeTotal += disagreeCount;
43
+ this.passTotal += passCount;
44
+ this.voteTotal += agreeCount + disagreeCount + passCount;
45
+ });
46
+ if(this.data.topics) {
47
+ this.topics = this.data.topics.replaceAll(";", ", ").replaceAll(":", " > ");
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,39 @@
1
+ import { Component, TemplateRef, ViewChild } from '@angular/core';
2
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
3
+ import { Overlay } from '@angular/cdk/overlay';
4
+ import { By } from '@angular/platform-browser';
5
+ import { CustomTooltipDirective } from './custom-tooltip.directive';
6
+
7
+ // test host component
8
+ @Component({
9
+ template: `
10
+ <div [customTooltip]="tooltipTemplate">Host element</div>
11
+ <ng-template #tooltipTemplate>Tooltip content</ng-template>
12
+ `,
13
+ })
14
+ class TestComponent {
15
+ @ViewChild('tooltipTemplate') tooltipTemplate!: TemplateRef<any>;
16
+ }
17
+
18
+ describe('CustomTooltipDirective', () => {
19
+ let fixture: ComponentFixture<TestComponent>;
20
+
21
+ beforeEach(async () => {
22
+ await TestBed.configureTestingModule({
23
+ imports: [CustomTooltipDirective],
24
+ declarations: [TestComponent],
25
+ providers: [Overlay],
26
+ }).compileComponents();
27
+
28
+ fixture = TestBed.createComponent(TestComponent);
29
+ fixture.detectChanges();
30
+ });
31
+
32
+ it('should create an instance of the directive with TemplateRef input', () => {
33
+ const directiveEl = fixture.debugElement.query(By.directive(CustomTooltipDirective));
34
+ expect(directiveEl).not.toBeNull(); // check that the directive is applied
35
+ const directiveInstance = directiveEl.injector.get(CustomTooltipDirective);
36
+ expect(directiveInstance).toBeTruthy(); // verify the instance exists
37
+ expect(directiveInstance.content).toBeDefined(); // verify TemplateRef input is set
38
+ });
39
+ });
@@ -0,0 +1,89 @@
1
+ import { Directive, Input, HostListener, TemplateRef, ViewContainerRef } from "@angular/core";
2
+ import { GlobalPositionStrategy, Overlay, OverlayRef } from "@angular/cdk/overlay";
3
+ import { TemplatePortal } from "@angular/cdk/portal";
4
+
5
+ @Directive({
6
+ selector: "[customTooltip]",
7
+ standalone: true
8
+ })
9
+ export class CustomTooltipDirective {
10
+ @Input("customTooltip") content!: TemplateRef<any>;
11
+ private overlayRef!: OverlayRef;
12
+ private portal: TemplatePortal<any> | null = null;
13
+
14
+ constructor(
15
+ private overlay: Overlay,
16
+ private viewContainerRef: ViewContainerRef
17
+ ) {}
18
+
19
+ @HostListener("mousemove", ["$event"])
20
+ updatePosition(event: MouseEvent) {
21
+ if(!this.overlayRef) {
22
+ return;
23
+ }
24
+
25
+ const strategy = this.overlayRef
26
+ .getConfig()
27
+ .positionStrategy as GlobalPositionStrategy;
28
+
29
+ // measure rendered tooltip
30
+ const tooltipEl = this.overlayRef.overlayElement;
31
+ const { width, height } = tooltipEl.getBoundingClientRect();
32
+ const viewportWidth = window.innerWidth;
33
+
34
+ // set distance for tooltip offset from mouse point
35
+ const offset = 10;
36
+
37
+ // horizontal position
38
+ let xPos = event.clientX + offset;
39
+ if(event.clientX + offset + width > viewportWidth) {
40
+ // overflows screen's right edge → flip to left of cursor
41
+ xPos = event.clientX - width - offset;
42
+ }
43
+
44
+ // vertical position
45
+ let yPos = event.clientY - height - offset;
46
+ if(yPos < 0) {
47
+ // overflows screen's top edge → flip to below cursor
48
+ yPos = event.clientY + offset;
49
+ }
50
+
51
+ strategy.left(`${xPos}px`);
52
+ strategy.top(`${yPos}px`);
53
+ this.overlayRef.updatePosition();
54
+ }
55
+
56
+ @HostListener("mouseenter", ["$event"])
57
+ show(event: MouseEvent) {
58
+ if(!this.overlayRef) {
59
+ const positionStrategy = this.overlay
60
+ .position()
61
+ .global()
62
+ // start somewhere offscreen; reposition immediately
63
+ .left("0px")
64
+ .top("0px");
65
+
66
+ this.overlayRef = this.overlay.create({
67
+ positionStrategy,
68
+ scrollStrategy: this.overlay.scrollStrategies.reposition()
69
+ });
70
+ }
71
+
72
+ this.portal = new TemplatePortal(
73
+ this.content,
74
+ this.viewContainerRef
75
+ );
76
+ this.overlayRef.attach(this.portal);
77
+
78
+ // initial positioning
79
+ this.updatePosition(event);
80
+ }
81
+
82
+ @HostListener("mouseleave")
83
+ hide() {
84
+ if(this.overlayRef) {
85
+ // remove the overlay
86
+ this.overlayRef.detach();
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,48 @@
1
+ type VoteGroup = {
2
+ agreeCount: number,
3
+ disagreeCount: number,
4
+ passCount: number,
5
+ };
6
+
7
+ type Statement = {
8
+ id: string,
9
+ text: string,
10
+ votes: object,
11
+ topics: string,
12
+ passRate: number,
13
+ agreeRate: number,
14
+ disagreeRate: number,
15
+ isHighAlignment: boolean,
16
+ highAlignmentScore: number,
17
+ isLowAlignment: boolean,
18
+ lowAlignmentScore: number,
19
+ isHighUncertainty: boolean,
20
+ highUncertaintyScore: number,
21
+ isFilteredOut: boolean,
22
+ };
23
+
24
+ type Subtopic = {
25
+ name: string,
26
+ commentCount: number,
27
+ voteCount: number,
28
+ relativeAlignment: string,
29
+ relativeEngagement: string,
30
+ comments?: Statement[],
31
+ id?: string,
32
+ };
33
+
34
+ type Topic = {
35
+ name: string,
36
+ commentCount: number,
37
+ voteCount: number,
38
+ relativeAlignment: string,
39
+ relativeEngagement: string,
40
+ subtopicStats: Subtopic[],
41
+ };
42
+
43
+ export {
44
+ VoteGroup,
45
+ Statement,
46
+ Subtopic,
47
+ Topic,
48
+ };