@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.
- package/README.md +35 -0
- package/angular.json +116 -0
- package/health_check.js +68 -0
- package/karma.conf.js +43 -0
- package/package.json +93 -0
- package/public/favicon.ico +0 -0
- package/server.ts +60 -0
- package/single-html-build.js +100 -0
- package/site-build.ts +54 -0
- package/src/app/app.component.html +1 -0
- package/src/app/app.component.scss +0 -0
- package/src/app/app.component.spec.ts +32 -0
- package/src/app/app.component.ts +21 -0
- package/src/app/app.config.server.ts +9 -0
- package/src/app/app.config.ts +19 -0
- package/src/app/app.routes.ts +8 -0
- package/src/app/components/dialog/dialog.component.html +15 -0
- package/src/app/components/dialog/dialog.component.scss +42 -0
- package/src/app/components/dialog/dialog.component.spec.ts +27 -0
- package/src/app/components/dialog/dialog.component.ts +40 -0
- package/src/app/components/sensemaking-chart-wrapper/sensemaking-chart-wrapper.component.ts +66 -0
- package/src/app/components/statement-card/statement-card.component.html +51 -0
- package/src/app/components/statement-card/statement-card.component.scss +134 -0
- package/src/app/components/statement-card/statement-card.component.spec.ts +23 -0
- package/src/app/components/statement-card/statement-card.component.ts +50 -0
- package/src/app/directives/custom-tooltip/custom-tooltip.directive.spec.ts +39 -0
- package/src/app/directives/custom-tooltip/custom-tooltip.directive.ts +89 -0
- package/src/app/models/report.model.ts +48 -0
- package/src/app/pages/report/report.component.html +363 -0
- package/src/app/pages/report/report.component.scss +600 -0
- package/src/app/pages/report/report.component.spec.ts +29 -0
- package/src/app/pages/report/report.component.ts +276 -0
- package/src/environments/environment.ts +5 -0
- package/src/index.html +17 -0
- package/src/main.server.ts +7 -0
- package/src/main.ts +5 -0
- package/src/style-vars.scss +40 -0
- package/src/styles.scss +23 -0
- package/tsconfig.app.json +19 -0
- package/tsconfig.json +32 -0
- 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
|
+
};
|