1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-02 09:43:29 +00:00

added risk over time chart with mock data

This commit is contained in:
maxkpower
2025-11-03 23:30:30 +01:00
parent cd56d01894
commit 9a9067e2f9
7 changed files with 920 additions and 60 deletions

View File

@@ -20,6 +20,45 @@
"riskInsights": {
"message": "Risk Insights"
},
"riskOverTime": {
"message": "Risk over time"
},
"applicationsAtRiskOverTime": {
"message": "Applications at-risk over time"
},
"itemsAtRiskOverTime": {
"message": "Items at-risk over time"
},
"membersAtRiskOverTime": {
"message": "Members at-risk over time"
},
"currentPeriod": {
"message": "Current year"
},
"previousPeriod": {
"message": "Previous year"
},
"last3Months": {
"message": "Last 3 months"
},
"last6Months": {
"message": "Last 6 months"
},
"last12Months": {
"message": "Last 12 months"
},
"loadingRiskData": {
"message": "Loading risk data..."
},
"downloadChart": {
"message": "Download chart as SVG"
},
"downloadAsSVG": {
"message": "Download as SVG"
},
"errorLoadingData": {
"message": "Error loading data"
},
"passwordRisk": {
"message": "Password Risk"
},

View File

@@ -1,67 +1,79 @@
@if ((dataService.reportStatus$ | async) == ReportStatusEnum.Loading) {
<dirt-risk-insights-loading></dirt-risk-insights-loading>
} @else {
<ul
class="tw-inline-grid tw-grid-cols-3 tw-gap-6 tw-m-0 tw-p-0 tw-w-full tw-auto-cols-auto tw-list-none"
>
<li class="tw-col-span-1" [ngClass]="{ 'tw-col-span-2': passwordChangeMetricHasProgressBar }">
<dirt-password-change-metric></dirt-password-change-metric>
</li>
<div class="tw-flex tw-flex-col tw-gap-6">
<!-- First Row: Risk Over Time Chart (left) and Applications Needing Review (right) -->
<div class="tw-grid tw-grid-cols-2 tw-gap-6">
<!-- Risk Over Time Chart - Half Width -->
<div class="tw-col-span-1">
<dirt-risk-over-time></dirt-risk-over-time>
</div>
<li class="tw-col-span-1">
<dirt-activity-card
[title]="'atRiskMembers' | i18n"
[cardMetrics]="'membersAtRiskCount' | i18n: totalCriticalAppsAtRiskMemberCount"
[metricDescription]="'membersWithAccessToAtRiskItemsForCriticalApps' | i18n"
actionText="{{ 'viewAtRiskMembers' | i18n }}"
[showActionLink]="totalCriticalAppsAtRiskMemberCount > 0"
(actionClick)="onViewAtRiskMembers()"
>
</dirt-activity-card>
</li>
<!-- Applications Needing Review Card - Half Width -->
<div class="tw-col-span-1">
<dirt-activity-card
[title]="'applicationsNeedingReview' | i18n"
[cardMetrics]="
isAllCaughtUp
? ('allCaughtUp' | i18n)
: ('newApplicationsWithCount' | i18n: newApplicationsCount)
"
[metricDescription]="
isAllCaughtUp
? ('noNewApplicationsToReviewAtThisTime' | i18n)
: ('newApplicationsDescription' | i18n)
"
[iconClass]="isAllCaughtUp ? 'bwi-check-circle' : 'bwi-exclamation-triangle'"
[iconColorClass]="isAllCaughtUp ? 'tw-text-success' : 'tw-text-muted'"
[buttonText]="isAllCaughtUp ? '' : ('reviewNow' | i18n)"
[buttonType]="'primary'"
(buttonClick)="onReviewNewApplications()"
>
</dirt-activity-card>
</div>
</div>
<li class="tw-col-span-1">
<dirt-activity-card
#allAppsOrgAtRiskApplications
[title]="'criticalApplications' | i18n"
[cardMetrics]="
totalCriticalAppsCount === 0
? ('countOfCriticalApplications' | i18n: totalCriticalAppsCount)
: ('countOfApplicationsAtRisk' | i18n: totalCriticalAppsAtRiskCount)
"
[metricDescription]="
totalCriticalAppsCount === 0
? ('onceYouMarkApplicationsCriticalTheyWillDisplayHere' | i18n)
: ('criticalApplicationsAreAtRisk'
| i18n: totalCriticalAppsAtRiskCount : totalCriticalAppsCount)
"
actionText="{{ 'viewAtRiskApplications' | i18n }}"
[showActionLink]="totalCriticalAppsAtRiskCount > 0"
(actionClick)="onViewAtRiskApplications()"
>
</dirt-activity-card>
</li>
<!-- Second Row: Activity Cards Section -->
<ul
class="tw-inline-grid tw-grid-cols-3 tw-gap-6 tw-m-0 tw-p-0 tw-w-full tw-auto-cols-auto tw-list-none"
>
<li class="tw-col-span-1" [ngClass]="{ 'tw-col-span-2': passwordChangeMetricHasProgressBar }">
<dirt-password-change-metric></dirt-password-change-metric>
</li>
<li class="tw-col-span-1">
<dirt-activity-card
[title]="'applicationsNeedingReview' | i18n"
[cardMetrics]="
isAllCaughtUp
? ('allCaughtUp' | i18n)
: ('newApplicationsWithCount' | i18n: newApplicationsCount)
"
[metricDescription]="
isAllCaughtUp
? ('noNewApplicationsToReviewAtThisTime' | i18n)
: ('newApplicationsDescription' | i18n)
"
[iconClass]="isAllCaughtUp ? 'bwi-check-circle' : 'bwi-exclamation-triangle'"
[iconColorClass]="isAllCaughtUp ? 'tw-text-success' : 'tw-text-muted'"
[buttonText]="isAllCaughtUp ? '' : ('reviewNow' | i18n)"
[buttonType]="'primary'"
(buttonClick)="onReviewNewApplications()"
>
</dirt-activity-card>
</li>
</ul>
<li class="tw-col-span-1">
<dirt-activity-card
[title]="'atRiskMembers' | i18n"
[cardMetrics]="'membersAtRiskCount' | i18n: totalCriticalAppsAtRiskMemberCount"
[metricDescription]="'membersWithAccessToAtRiskItemsForCriticalApps' | i18n"
actionText="{{ 'viewAtRiskMembers' | i18n }}"
[showActionLink]="totalCriticalAppsAtRiskMemberCount > 0"
(actionClick)="onViewAtRiskMembers()"
>
</dirt-activity-card>
</li>
<li class="tw-col-span-1">
<dirt-activity-card
#allAppsOrgAtRiskApplications
[title]="'criticalApplications' | i18n"
[cardMetrics]="
totalCriticalAppsCount === 0
? ('countOfCriticalApplications' | i18n: totalCriticalAppsCount)
: ('countOfApplicationsAtRisk' | i18n: totalCriticalAppsAtRiskCount)
"
[metricDescription]="
totalCriticalAppsCount === 0
? ('onceYouMarkApplicationsCriticalTheyWillDisplayHere' | i18n)
: ('criticalApplicationsAreAtRisk'
| i18n: totalCriticalAppsAtRiskCount : totalCriticalAppsCount)
"
actionText="{{ 'viewAtRiskApplications' | i18n }}"
[showActionLink]="totalCriticalAppsAtRiskCount > 0"
(actionClick)="onViewAtRiskApplications()"
>
</dirt-activity-card>
</li>
</ul>
</div>
}

View File

@@ -16,6 +16,7 @@ import { getById } from "@bitwarden/common/platform/misc";
import { DialogService } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { RiskOverTimeComponent } from "../risk-over-time/risk-over-time.component";
import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.component";
import { ActivityCardComponent } from "./activity-card.component";
@@ -31,6 +32,7 @@ import { NewApplicationsDialogComponent } from "./new-applications-dialog.compon
SharedModule,
ActivityCardComponent,
PasswordChangeMetricComponent,
RiskOverTimeComponent,
],
templateUrl: "./all-activity.component.html",
})

View File

@@ -0,0 +1,118 @@
import { Injectable } from "@angular/core";
import { Observable, of } from "rxjs";
import { RiskMetricType, TimePeriod, RiskOverTimeData } from "./risk-over-time.models";
/**
* Service for managing risk over time data
* This service provides data for the risk over time chart component
*/
@Injectable()
export class RiskOverTimeDataService {
/**
* Get risk over time data for a specific metric and time period
* TODO: Replace with actual API call once backend endpoint is ready
*/
getRiskOverTimeData(metric: RiskMetricType, period: TimePeriod): Observable<RiskOverTimeData> {
// Mock data for demonstration
// In production, this should call an API endpoint
return of(this.getMockData(metric, period));
}
/**
* Generate mock data for development/testing
* This should be replaced with actual API calls once the backend is ready
*/
private getMockData(metric: RiskMetricType, period: TimePeriod): RiskOverTimeData {
const data: RiskOverTimeData = {
labels: [],
currentPeriod: [],
previousPeriod: [],
metricType: metric,
timePeriod: period,
};
switch (period) {
case TimePeriod.ThreeMonths:
data.labels = ["Sep", "Oct", "Nov"];
break;
case TimePeriod.SixMonths:
data.labels = ["Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
break;
case TimePeriod.TwelveMonths:
data.labels = ["Jan", "Mar", "May", "Jul", "Sep", "Nov"];
break;
}
// Generate mock data points based on metric type
const dataPointCount = data.labels.length;
switch (metric) {
case RiskMetricType.Applications:
// Mock data showing improvement (downward trend)
data.currentPeriod = this.generateTrendData(dataPointCount, 45, 30, true);
data.previousPeriod = this.generateTrendData(dataPointCount, 50, 35, true);
break;
case RiskMetricType.Items:
// Mock data showing worsening (upward trend)
data.currentPeriod = this.generateTrendData(dataPointCount, 30, 50, false);
data.previousPeriod = this.generateTrendData(dataPointCount, 25, 40, false);
break;
case RiskMetricType.Members:
// Mock data showing slight improvement
data.currentPeriod = this.generateTrendData(dataPointCount, 60, 50, true);
data.previousPeriod = this.generateTrendData(dataPointCount, 65, 55, true);
break;
}
return data;
}
/**
* Generate trend data with some variation
* @param count Number of data points
* @param start Starting value
* @param end Ending value
* @param improving Whether trend is improving (downward) or worsening (upward)
*/
private generateTrendData(
count: number,
start: number,
end: number,
improving: boolean,
): number[] {
const data: number[] = [];
const step = (end - start) / (count - 1);
for (let i = 0; i < count; i++) {
// Add some random variation to make it look more realistic
const baseValue = start + step * i;
const variation = (Math.random() - 0.5) * 5; // +/- 2.5
data.push(Math.max(0, Math.round(baseValue + variation)));
}
return data;
}
/**
* Calculate if a trend is improving based on first and last values
*/
isImproving(data: number[]): boolean {
if (data.length < 2) {
return true;
}
return data[data.length - 1] < data[0];
}
/**
* Get the percentage change between two values
*/
getPercentageChange(oldValue: number, newValue: number): number {
if (oldValue === 0) {
return 0;
}
return Math.round(((newValue - oldValue) / oldValue) * 100);
}
}

View File

@@ -0,0 +1,258 @@
<div
class="tw-box-border tw-bg-background tw-text-main tw-border-solid tw-border tw-border-secondary-300 tw-rounded-xl tw-p-6 tw-flex tw-flex-col tw-gap-6 tw-h-full"
>
<!-- Header Section -->
<div class="tw-flex tw-flex-col">
<!-- Title and Download Button Row -->
<div class="tw-flex tw-justify-between tw-items-start">
<div class="tw-text-main tw-text-sm tw-font-semibold">{{ "riskOverTime" | i18n }}</div>
<button
type="button"
bitIconButton="bwi-download"
buttonType="main"
size="small"
label="{{ 'downloadChart' | i18n }}"
(click)="downloadChartAsSVG()"
></button>
</div>
<!-- Chart Title and Period Selector -->
<div class="tw-flex tw-justify-between tw-items-end">
<div class="tw-text-main tw-text-2xl tw-font-semibold">{{ chartTitle() }}</div>
<!-- Period Dropdown -->
<div class="tw-relative">
<select
class="tw-px-3 tw-py-1 tw-rounded-full tw-border tw-border-gray-400 dark:tw-border-gray-600 tw-flex tw-items-center tw-gap-2 hover:tw-bg-gray-50 dark:hover:tw-bg-gray-700 tw-bg-white dark:tw-bg-gray-800 tw-text-gray-600 dark:tw-text-gray-400 tw-text-sm tw-leading-tight tw-appearance-none tw-pr-8 tw-cursor-pointer focus:tw-outline-none focus:tw-ring-2 focus:tw-ring-primary-600"
[value]="selectedPeriod"
(change)="onPeriodChange($event)"
>
<option *ngFor="let option of periodOptions" [value]="option.value">
{{ option.label }}
</option>
</select>
<svg
stroke="currentColor"
fill="currentColor"
stroke-width="0"
viewBox="0 0 20 20"
aria-hidden="true"
class="tw-absolute tw-right-2 tw-top-1/2 tw-transform tw--translate-y-1/2 tw-w-3.5 tw-h-3.5 tw-text-gray-600 dark:tw-text-gray-400 tw-pointer-events-none"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>
</div>
</div>
</div>
<!-- Tab Buttons and Legend -->
<div class="tw-flex tw-justify-between tw-items-center">
<!-- Toggle Group -->
<bit-toggle-group [(selected)]="selectedMetric" (selectedChange)="onMetricChange($event)">
<bit-toggle [value]="RiskMetricType.Applications">
{{ "application" | i18n }}
</bit-toggle>
<bit-toggle [value]="RiskMetricType.Items">
{{ "item" | i18n }}
</bit-toggle>
<bit-toggle [value]="RiskMetricType.Members">
{{ "member" | i18n }}
</bit-toggle>
</bit-toggle-group>
<!-- Legend -->
<div class="tw-flex tw-gap-3 tw-items-center">
<div class="tw-flex tw-items-center tw-gap-1.5 tw-pl-1 tw-pr-2 tw-py-0.5 tw-rounded-lg">
<div
class="tw-w-2.5 tw-h-2.5 tw-rounded-full"
[ngClass]="currentPeriodColor()"
[style.background-color]="currentPeriodStrokeColor()"
></div>
<span class="tw-text-main tw-text-xs">{{ "currentPeriod" | i18n }}</span>
</div>
<div class="tw-flex tw-items-center tw-gap-1.5 tw-pl-1 tw-pr-2 tw-py-0.5 tw-rounded-lg">
<div class="tw-w-2.5 tw-h-2.5 tw-rounded-full tw-bg-secondary-300"></div>
<span class="tw-text-main tw-text-xs">{{ "previousPeriod" | i18n }}</span>
</div>
</div>
</div>
<!-- Chart -->
<div class="tw-relative tw-w-full" *ngIf="chartData() && !isLoading() && !hasError()">
<svg
#chartSvg
[attr.width]="chartWidth"
[attr.height]="chartHeight"
class="tw-max-w-full"
(mousemove)="onChartMouseMove($event)"
(mouseleave)="onChartMouseLeave()"
>
<defs>
<!-- Gradient for current period area -->
<linearGradient id="currentGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" [attr.stop-color]="currentPeriodStrokeColor()" stop-opacity="0.08" />
<stop offset="40%" [attr.stop-color]="currentPeriodStrokeColor()" stop-opacity="0.02" />
<stop offset="100%" [attr.stop-color]="currentPeriodStrokeColor()" stop-opacity="0" />
</linearGradient>
</defs>
<!-- Y-axis line -->
<line
[attr.x1]="yAxisX"
[attr.y1]="chartPadding.top"
[attr.x2]="yAxisX"
[attr.y2]="chartHeight - chartPadding.bottom"
stroke="#D1D5DB"
stroke-width="1"
/>
<!-- Y-axis labels -->
<g>
<text
*ngFor="let label of yAxisLabels"
[attr.x]="yAxisX - 10"
[attr.y]="label.y + 4"
text-anchor="end"
class="tw-text-xs tw-fill-muted"
>
{{ label.value }}
</text>
</g>
<!-- X-axis line -->
<line
[attr.x1]="yAxisX"
[attr.y1]="chartHeight - chartPadding.bottom"
[attr.x2]="chartWidth - chartPadding.right"
[attr.y2]="chartHeight - chartPadding.bottom"
stroke="#D1D5DB"
stroke-width="1"
/>
<!-- Previous period line -->
<path
[attr.d]="getLinePath(chartData()!.previousPeriod)"
fill="none"
stroke="#9AA5B1"
stroke-width="2"
stroke-dasharray="2 2"
class="tw-transition-all"
/>
<!-- Current period area -->
<path
[attr.d]="getAreaPath(chartData()!.currentPeriod)"
fill="url(#currentGradient)"
class="tw-transition-all"
/>
<!-- Current period line -->
<path
[attr.d]="getLinePath(chartData()!.currentPeriod)"
fill="none"
[attr.stroke]="currentPeriodStrokeColor()"
stroke-width="3"
class="tw-transition-all"
/>
<!-- Hover indicator line -->
<line
*ngIf="hoveredDataPoint()"
[attr.x1]="hoveredDataPoint()!.x"
[attr.y1]="chartPadding.top"
[attr.x2]="hoveredDataPoint()!.x"
[attr.y2]="chartHeight - chartPadding.bottom"
class="tw-stroke-secondary-300"
stroke-width="1"
/>
<!-- Hover circles on data points -->
<ng-container *ngIf="hoveredDataPoint()">
<circle
*ngFor="let point of getDataPoints(chartData()!.currentPeriod); let i = index"
[class.tw-hidden]="i !== hoveredDataPoint()!.index"
[attr.cx]="point.x"
[attr.cy]="point.y"
r="4"
[attr.fill]="currentPeriodStrokeColor()"
class="tw-transition-all"
/>
<circle
*ngFor="let point of getDataPoints(chartData()!.previousPeriod); let i = index"
[class.tw-hidden]="i !== hoveredDataPoint()!.index"
[attr.cx]="point.x"
[attr.cy]="point.y"
r="4"
fill="#9AA5B1"
class="tw-transition-all"
/>
</ng-container>
<!-- X-axis labels -->
<g>
<text
*ngFor="let label of xAxisLabels; let i = index"
[attr.x]="
yAxisX + i * ((chartWidth - yAxisX - chartPadding.right) / (xAxisLabels.length - 1))
"
[attr.y]="chartHeight - chartPadding.bottom + 20"
text-anchor="middle"
class="tw-text-xs tw-fill-main tw-select-none"
>
{{ label }}
</text>
</g>
</svg>
<!-- Tooltip -->
<div
*ngIf="hoveredDataPoint() && getTooltipData() as tooltip"
class="tw-absolute tw-bg-background tw-border tw-border-secondary-300 tw-rounded-lg tw-shadow-lg tw-p-3 tw-pointer-events-none tw-z-10"
[style.left.px]="getTooltipPosition()!.x + 10"
[style.top.px]="getTooltipPosition()!.y"
>
<div class="tw-text-xs tw-font-semibold tw-text-main tw-mb-2">{{ tooltip.label }}</div>
<div class="tw-flex tw-flex-col tw-gap-1">
<div class="tw-flex tw-items-center tw-gap-2">
<div
class="tw-w-2.5 tw-h-2.5 tw-rounded-full"
[style.background-color]="currentPeriodStrokeColor()"
></div>
<span class="tw-text-xs tw-text-muted">{{ "currentPeriod" | i18n }}:</span>
<span class="tw-text-xs tw-font-semibold tw-text-main">{{ tooltip.currentValue }}</span>
</div>
<div class="tw-flex tw-items-center tw-gap-2">
<div class="tw-w-2.5 tw-h-2.5 tw-rounded-full tw-bg-secondary-300"></div>
<span class="tw-text-xs tw-text-muted">{{ "previousPeriod" | i18n }}:</span>
<span class="tw-text-xs tw-font-semibold tw-text-main">{{ tooltip.previousValue }}</span>
</div>
</div>
</div>
</div>
<!-- Loading State -->
<div *ngIf="isLoading()" class="tw-flex tw-justify-center tw-items-center tw-h-64">
<i
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
[attr.aria-label]="'loading' | i18n"
></i>
</div>
<!-- Error State -->
<div
*ngIf="hasError() && !isLoading()"
class="tw-flex tw-flex-col tw-justify-center tw-items-center tw-h-64 tw-text-muted tw-gap-2"
>
<i class="bwi bwi-error bwi-2x tw-text-danger"></i>
<span>{{ "errorLoadingData" | i18n }}</span>
</div>
</div>

View File

@@ -0,0 +1,381 @@
import { CommonModule } from "@angular/common";
import {
Component,
OnInit,
DestroyRef,
inject,
ElementRef,
ChangeDetectionStrategy,
signal,
computed,
viewChild,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
ButtonModule,
IconButtonModule,
SelectModule,
TypographyModule,
MenuModule,
BadgeModule,
ToggleGroupModule,
} from "@bitwarden/components";
import { RiskOverTimeDataService } from "./risk-over-time-data.service";
import { RiskMetricType, TimePeriod, RiskOverTimeData } from "./risk-over-time.models";
@Component({
selector: "dirt-risk-over-time",
templateUrl: "./risk-over-time.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
JslibModule,
ButtonModule,
IconButtonModule,
SelectModule,
TypographyModule,
MenuModule,
BadgeModule,
ToggleGroupModule,
],
providers: [RiskOverTimeDataService],
})
export class RiskOverTimeComponent implements OnInit {
private destroyRef = inject(DestroyRef);
protected readonly chartSvgRef = viewChild<ElementRef<SVGElement>>("chartSvg");
// Data properties using signals
protected readonly chartData = signal<RiskOverTimeData | null>(null);
protected readonly isLoading = signal<boolean>(true);
protected readonly hasError = signal<boolean>(false);
protected readonly selectedMetric = signal<RiskMetricType>(RiskMetricType.Applications);
protected readonly selectedPeriod = signal<TimePeriod>(TimePeriod.ThreeMonths);
protected readonly hoveredDataPoint = signal<{ index: number; x: number } | null>(null);
// Chart dimensions
chartWidth = 700;
chartHeight = 280;
chartPadding = { top: 20, right: 20, bottom: 35, left: 20 }; // Minimal left padding
// Dynamic Y-axis position based on label widths
private maxYLabelWidth = 0;
protected get yAxisX(): number {
// Y-axis line position: left padding + max label width + gap
return this.chartPadding.left + this.maxYLabelWidth + 10;
}
// Computed values
protected readonly chartTitle = computed(() => {
switch (this.selectedMetric()) {
case RiskMetricType.Applications:
return this.i18nService.t("applicationsAtRiskOverTime");
case RiskMetricType.Items:
return this.i18nService.t("itemsAtRiskOverTime");
case RiskMetricType.Members:
return this.i18nService.t("membersAtRiskOverTime");
default:
return this.i18nService.t("riskOverTime");
}
});
protected readonly isImproving = computed(() => {
const data = this.chartData();
if (!data) {
return true;
}
const firstValue = data.currentPeriod[0];
const lastValue = data.currentPeriod[data.currentPeriod.length - 1];
return lastValue < firstValue;
});
protected readonly currentPeriodStrokeColor = computed(() => {
return this.isImproving() ? "#175DDC" : "#C83B3B";
});
protected readonly currentPeriodColor = computed(() => {
return this.isImproving() ? "tw-text-primary-600" : "tw-text-danger";
});
// Enums for template
readonly RiskMetricType = RiskMetricType;
readonly TimePeriod = TimePeriod;
// Period options for dropdown
periodOptions: Array<{ value: TimePeriod; label: string }> = [];
constructor(
private dataService: RiskOverTimeDataService,
private i18nService: I18nService,
) {
// Initialize period options with i18n strings
this.periodOptions = [
{ value: TimePeriod.ThreeMonths, label: this.i18nService.t("last3Months") },
{ value: TimePeriod.SixMonths, label: this.i18nService.t("last6Months") },
{ value: TimePeriod.TwelveMonths, label: this.i18nService.t("last12Months") },
];
}
ngOnInit(): void {
this.loadData();
}
private loadData(): void {
this.isLoading.set(true);
this.hasError.set(false);
this.dataService
.getRiskOverTimeData(this.selectedMetric(), this.selectedPeriod())
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (data) => {
this.chartData.set(data);
this.isLoading.set(false);
// Calculate max Y-axis label width after data loads
this.calculateMaxYLabelWidth();
},
error: (_error: unknown) => {
this.hasError.set(true);
this.isLoading.set(false);
},
});
}
private calculateMaxYLabelWidth(): void {
const data = this.chartData();
if (!data) {
this.maxYLabelWidth = 30; // Default width
return;
}
// Get all Y-axis label values
const labels = this.yAxisLabels.map((l) => l.value);
// Create a temporary canvas to measure text width
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (!context) {
this.maxYLabelWidth = 30;
return;
}
// Set font to match the SVG text element (12px)
context.font = "12px system-ui, -apple-system, sans-serif";
// Find the widest label
this.maxYLabelWidth = Math.max(...labels.map((label) => context.measureText(label).width));
}
protected onMetricChange(metric: RiskMetricType | string): void {
this.selectedMetric.set(metric as RiskMetricType);
this.loadData();
}
protected onPeriodChange(event: Event): void {
const target = event.target as HTMLSelectElement;
this.selectedPeriod.set(target.value as TimePeriod);
this.loadData();
}
protected get yAxisLabels(): Array<{ value: string; y: number }> {
const data = this.chartData();
if (!data) {
return [
{ value: "100%", y: this.chartPadding.top },
{
value: "75%",
y:
this.chartPadding.top +
(this.chartHeight - this.chartPadding.top - this.chartPadding.bottom) * 0.25,
},
{
value: "50%",
y:
this.chartPadding.top +
(this.chartHeight - this.chartPadding.top - this.chartPadding.bottom) * 0.5,
},
{
value: "25%",
y:
this.chartPadding.top +
(this.chartHeight - this.chartPadding.top - this.chartPadding.bottom) * 0.75,
},
{ value: "0", y: this.chartHeight - this.chartPadding.bottom },
];
}
const maxValue = Math.max(...data.currentPeriod, ...data.previousPeriod);
const step = Math.ceil(maxValue / 4);
const chartAreaHeight = this.chartHeight - this.chartPadding.top - this.chartPadding.bottom;
return [
{ value: (step * 4).toString(), y: this.chartPadding.top },
{ value: (step * 3).toString(), y: this.chartPadding.top + chartAreaHeight * 0.25 },
{ value: (step * 2).toString(), y: this.chartPadding.top + chartAreaHeight * 0.5 },
{ value: step.toString(), y: this.chartPadding.top + chartAreaHeight * 0.75 },
{ value: "0", y: this.chartHeight - this.chartPadding.bottom },
];
}
protected get xAxisLabels(): string[] {
return this.chartData()?.labels || [];
}
// Calculate SVG path for area chart
protected getAreaPath(data: number[], isPreviousPeriod = false): string {
const chartData = this.chartData();
if (!chartData || data.length === 0) {
return "";
}
const maxValue = Math.max(...chartData.currentPeriod, ...chartData.previousPeriod);
const chartAreaWidth = this.chartWidth - this.yAxisX - this.chartPadding.right;
const chartAreaHeight = this.chartHeight - this.chartPadding.top - this.chartPadding.bottom;
const step = chartAreaWidth / (data.length - 1);
// Create path
let path = `M ${this.yAxisX},${this.chartHeight - this.chartPadding.bottom}`;
// Add points
data.forEach((value, index) => {
const x = this.yAxisX + index * step;
const y = this.chartPadding.top + chartAreaHeight - (value / maxValue) * chartAreaHeight;
path += ` L ${x},${y}`;
});
// Close the path
path += ` L ${this.yAxisX + (data.length - 1) * step},${this.chartHeight - this.chartPadding.bottom}`;
path += " Z";
return path;
}
// Calculate SVG path for line
protected getLinePath(data: number[]): string {
const chartData = this.chartData();
if (!chartData || data.length === 0) {
return "";
}
const maxValue = Math.max(...chartData.currentPeriod, ...chartData.previousPeriod);
const chartAreaWidth = this.chartWidth - this.yAxisX - this.chartPadding.right;
const chartAreaHeight = this.chartHeight - this.chartPadding.top - this.chartPadding.bottom;
const step = chartAreaWidth / (data.length - 1);
// Create path
let path = "";
data.forEach((value, index) => {
const x = this.yAxisX + index * step;
const y = this.chartPadding.top + chartAreaHeight - (value / maxValue) * chartAreaHeight;
if (index === 0) {
path += `M ${x},${y}`;
} else {
path += ` L ${x},${y}`;
}
});
return path;
}
// Get points for hover circles
protected getDataPoints(data: number[]): Array<{ x: number; y: number; value: number }> {
const chartData = this.chartData();
if (!chartData || data.length === 0) {
return [];
}
const maxValue = Math.max(...chartData.currentPeriod, ...chartData.previousPeriod);
const chartAreaWidth = this.chartWidth - this.yAxisX - this.chartPadding.right;
const chartAreaHeight = this.chartHeight - this.chartPadding.top - this.chartPadding.bottom;
const step = chartAreaWidth / (data.length - 1);
return data.map((value, index) => ({
x: this.yAxisX + index * step,
y: this.chartPadding.top + chartAreaHeight - (value / maxValue) * chartAreaHeight,
value,
}));
}
protected onChartMouseMove(event: MouseEvent): void {
const data = this.chartData();
if (!data) {
return;
}
const svg = event.currentTarget as SVGSVGElement;
const rect = svg.getBoundingClientRect();
const x = event.clientX - rect.left;
// Find closest data point
const chartAreaWidth = this.chartWidth - this.yAxisX - this.chartPadding.right;
const step = chartAreaWidth / (data.currentPeriod.length - 1);
const relativeX = x - this.yAxisX;
const index = Math.round(relativeX / step);
if (index >= 0 && index < data.currentPeriod.length) {
this.hoveredDataPoint.set({
index,
x: this.yAxisX + index * step,
});
}
}
protected onChartMouseLeave(): void {
this.hoveredDataPoint.set(null);
}
protected getTooltipPosition(): { x: number; y: number } | null {
const hovered = this.hoveredDataPoint();
if (!hovered) {
return null;
}
return {
x: hovered.x,
y: this.chartPadding.top + 10,
};
}
protected getTooltipData(): {
label: string;
currentValue: number;
previousValue: number;
} | null {
const hovered = this.hoveredDataPoint();
const data = this.chartData();
if (!hovered || !data) {
return null;
}
return {
label: data.labels[hovered.index],
currentValue: data.currentPeriod[hovered.index],
previousValue: data.previousPeriod[hovered.index],
};
}
protected downloadChartAsSVG(): void {
const svgRef = this.chartSvgRef();
if (!svgRef) {
return;
}
try {
const svgElement = svgRef.nativeElement;
const serializer = new XMLSerializer();
const svgString = serializer.serializeToString(svgElement);
const blob = new Blob([svgString], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${this.chartTitle().replace(/\s+/g, "-").toLowerCase()}.svg`;
link.click();
URL.revokeObjectURL(url);
} catch {
// Error downloading chart - silently fail
}
}
}

View File

@@ -0,0 +1,50 @@
/**
* Risk metric types
*/
export const RiskMetricType = {
Applications: "applications",
Items: "items",
Members: "members",
} as const;
export type RiskMetricType = (typeof RiskMetricType)[keyof typeof RiskMetricType];
/**
* Time period selection
*/
export const TimePeriod = {
ThreeMonths: "3months",
SixMonths: "6months",
TwelveMonths: "12months",
} as const;
export type TimePeriod = (typeof TimePeriod)[keyof typeof TimePeriod];
/**
* Interface for risk over time data
*/
export interface RiskOverTimeData {
/** Labels for x-axis (e.g., month names) */
labels: string[];
/** Data points for current period */
currentPeriod: number[];
/** Data points for previous period (last year) */
previousPeriod: number[];
/** The metric type this data represents */
metricType: RiskMetricType;
/** The time period this data represents */
timePeriod: TimePeriod;
}
/**
* Interface for API response containing risk over time data
*/
export interface RiskOverTimeApiResponse {
applications?: RiskOverTimeData;
items?: RiskOverTimeData;
members?: RiskOverTimeData;
}