update to analytics dashboard (EoD May 8th 2026)

This commit is contained in:
Devin Major 2026-05-08 16:13:22 -04:00
parent f5670712f6
commit e41ea6c8b3
14 changed files with 399 additions and 52 deletions

View File

@ -3,7 +3,7 @@
<h3 class="card-title">{{ areaLabel }}</h3> <h3 class="card-title">{{ areaLabel }}</h3>
</div> </div>
<div class="chart-wrap" *ngIf="!isLoading && !hasError"> <div class="chart-wrap" *ngIf="!isLoading && !hasError">
<p-chart id="hectaresBarChart" type="bar" [data]="chartData" [options]="chartOptions" height="180"></p-chart> <p-chart id="hectaresBarChart" type="bar" [data]="chartData" [options]="chartOptions" height="180" (onDataSelect)="onDataSelect($event)"></p-chart>
<div class="no-data-overlay" *ngIf="!hasData"> <div class="no-data-overlay" *ngIf="!hasData">
<i class="pi pi-calendar-times"></i> <i class="pi pi-calendar-times"></i>
<span i18n="Hectares chart no data@@pilotHectaresChartNoData">No spray activity for this period</span> <span i18n="Hectares chart no data@@pilotHectaresChartNoData">No spray activity for this period</span>

View File

@ -1,10 +1,34 @@
import { Component, Input, OnChanges, AfterViewInit, ChangeDetectionStrategy } from '@angular/core'; import { AfterViewInit, ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
import { TrendDataPoint, ChartBuilderUtils } from '../../utils/chart-builders'; import { ChartBuilderUtils, TrendDataPoint } from '../../utils/chart-builders';
import { UnitUtils } from '../../../shared/utils'; import { UnitUtils } from '../../../shared/utils';
export { TrendDataPoint as HectaresTrendDataPoint }; export { TrendDataPoint as HectaresTrendDataPoint };
let roundedBarsPatched = false; let roundedBarsPatched = false;
let tooltipPositionerPatched = false;
/**
* Registers a custom Chart.js 2.x tooltip positioner that clamps the tooltip
* inside the chart area. The default 'average' positioner anchors the tooltip
* above the bar top for truncated bars (value > axis max) this falls outside
* the canvas and becomes invisible. This positioner ensures it always stays inside.
*/
function patchTooltipPositioner(): void {
if (tooltipPositionerPatched) { return; }
const C: any = (window as any).Chart;
if (!C?.Tooltip?.positioners) { return; }
tooltipPositionerPatched = true;
C.Tooltip.positioners.clampedTop = function(elements: any[], eventPosition: any) {
const pos = C.Tooltip.positioners.average.call(this, elements, eventPosition);
if (!pos) { return false; }
const chartArea = this._chart?.chartArea;
if (!chartArea) { return pos; }
// Keep the tooltip anchor at least 8px below the chart top so the tooltip
// box always renders inside the canvas instead of above it.
return { x: pos.x, y: Math.max(chartArea.top + 8, pos.y) };
};
}
function patchRoundedBars(): void { function patchRoundedBars(): void {
if (roundedBarsPatched) { return; } if (roundedBarsPatched) { return; }
@ -63,6 +87,10 @@ export class HectaresChartComponent implements OnChanges, AfterViewInit {
@Input() isLoading = false; @Input() isLoading = false;
@Input() hasError = false; @Input() hasError = false;
@Input() isUS = false; @Input() isUS = false;
/** The data-point index currently selected by a drill-down click. null = no selection. */
@Input() selectedIndex: number | null = null;
@Output() daySelected = new EventEmitter<{ label: string; index: number }>();
chartData: any; chartData: any;
chartOptions: any; chartOptions: any;
@ -76,6 +104,7 @@ export class HectaresChartComponent implements OnChanges, AfterViewInit {
ngAfterViewInit(): void { ngAfterViewInit(): void {
patchRoundedBars(); patchRoundedBars();
patchTooltipPositioner();
} }
ngOnChanges(): void { ngOnChanges(): void {
@ -87,8 +116,25 @@ export class HectaresChartComponent implements OnChanges, AfterViewInit {
})); }));
this.hasData = convertedData.length > 0 && convertedData.some(d => d.value > 0); this.hasData = convertedData.length > 0 && convertedData.some(d => d.value > 0);
const config = ChartBuilderUtils.hectaresChart(convertedData, areaUnit); const config = ChartBuilderUtils.hectaresChart(convertedData, areaUnit);
if (this.selectedIndex !== null && config.chartData?.datasets?.[0]) {
const count: number = config.chartData.datasets[0].data.length;
config.chartData = {
...config.chartData,
datasets: [{
...config.chartData.datasets[0],
backgroundColor: ChartBuilderUtils.buildSelectionColors(count, this.selectedIndex, '#2E7D32', '#c8e6c9'),
}]
};
}
this.chartData = config.chartData; this.chartData = config.chartData;
this.chartOptions = config.chartOptions; this.chartOptions = config.chartOptions;
} }
} }
/** Fired by p-chart (onDataSelect) when the user clicks a data point. */
onDataSelect(event: any): void {
const index: number = event?.element?._index;
if (index == null || isNaN(index)) { return; }
this.daySelected.emit({ label: this.trendData[index]?.day ?? '', index });
}
} }

View File

@ -3,7 +3,7 @@
<h3 class="card-title" i18n="Hours flown chart title@@pilotHoursChartTitle">Hours Flown (Week History)</h3> <h3 class="card-title" i18n="Hours flown chart title@@pilotHoursChartTitle">Hours Flown (Week History)</h3>
</div> </div>
<div class="chart-wrap" *ngIf="!isLoading && !hasError"> <div class="chart-wrap" *ngIf="!isLoading && !hasError">
<p-chart type="line" [data]="chartData" [options]="chartOptions" height="180"></p-chart> <p-chart type="line" [data]="chartData" [options]="chartOptions" height="180" (onDataSelect)="onDataSelect($event)"></p-chart>
<div class="no-data-overlay" *ngIf="!hasData"> <div class="no-data-overlay" *ngIf="!hasData">
<i class="pi pi-calendar-times"></i> <i class="pi pi-calendar-times"></i>
<span i18n="Hours chart no data@@pilotHoursChartNoData">No flight activity for this period</span> <span i18n="Hours chart no data@@pilotHoursChartNoData">No flight activity for this period</span>

View File

@ -1,5 +1,5 @@
import { Component, Input, OnChanges, ChangeDetectionStrategy } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
import { TrendDataPoint, ChartBuilderUtils } from '../../utils/chart-builders'; import { ChartBuilderUtils, TrendDataPoint } from '../../utils/chart-builders';
export { TrendDataPoint }; export { TrendDataPoint };
@ -13,6 +13,10 @@ export class HoursChartComponent implements OnChanges {
@Input() trendData: TrendDataPoint[] = []; @Input() trendData: TrendDataPoint[] = [];
@Input() isLoading = false; @Input() isLoading = false;
@Input() hasError = false; @Input() hasError = false;
/** The data-point index currently selected by a drill-down click. null = no selection. */
@Input() selectedIndex: number | null = null;
@Output() daySelected = new EventEmitter<{ label: string; index: number }>();
chartData: any; chartData: any;
chartOptions: any; chartOptions: any;
@ -22,8 +26,26 @@ export class HoursChartComponent implements OnChanges {
if (!this.isLoading && !this.hasError) { if (!this.isLoading && !this.hasError) {
this.hasData = this.trendData.length > 0 && this.trendData.some(d => d.value > 0); this.hasData = this.trendData.length > 0 && this.trendData.some(d => d.value > 0);
const config = ChartBuilderUtils.hoursChart(this.trendData); const config = ChartBuilderUtils.hoursChart(this.trendData);
if (this.selectedIndex !== null && config.chartData?.datasets?.[0]) {
const count: number = config.chartData.datasets[0].data.length;
config.chartData = {
...config.chartData,
datasets: [{
...config.chartData.datasets[0],
pointBackgroundColor: ChartBuilderUtils.buildSelectionColors(count, this.selectedIndex, '#2E7D32', '#c8e6c9'),
pointRadius: Array.from({ length: count }, (_, i) => i === this.selectedIndex ? 7 : 4),
}]
};
}
this.chartData = config.chartData; this.chartData = config.chartData;
this.chartOptions = config.chartOptions; this.chartOptions = config.chartOptions;
} }
} }
/** Fired by p-chart (onDataSelect) when the user clicks a data point. */
onDataSelect(event: any): void {
const index: number = event?.element?._index;
if (index == null || isNaN(index)) { return; }
this.daySelected.emit({ label: this.trendData[index]?.day ?? '', index });
}
} }

View File

@ -11,6 +11,17 @@
<label for="dashUnitToggle" class="unit-toggle-label" i18n="@@unitToggleUSLabel">US / Imperial</label> <label for="dashUnitToggle" class="unit-toggle-label" i18n="@@unitToggleUSLabel">US / Imperial</label>
</div> </div>
</div> </div>
<!-- Drill-down filter pill: visible when a chart bar/point is selected -->
<div class="drill-down-filter" *ngIf="selectedDayLabel">
<i class="pi pi-filter-fill"></i>
<span i18n="Drill-down filter label@@drillDownFilterLabel">Viewing data for:</span>
<strong class="drill-down-day">{{ selectedDayLabel }}</strong>
<button class="drill-down-clear" (click)="clearDaySelection()"
pTooltip="Clear day filter" i18n-pTooltip="@@drillDownClearTooltip">
<i class="pi pi-times"></i>
</button>
</div>
<div class="kpi-row"> <div class="kpi-row">
<agm-kpi-card <agm-kpi-card
*ngFor="let kpi of kpiData; let i = index; trackBy: trackByIndex" *ngFor="let kpi of kpiData; let i = index; trackBy: trackByIndex"
@ -29,8 +40,21 @@
</div> </div>
<div class="right-col"> <div class="right-col">
<agm-date-range-control (rangeChange)="onTrendRangeChange($event)"></agm-date-range-control> <agm-date-range-control (rangeChange)="onTrendRangeChange($event)"></agm-date-range-control>
<agm-hours-chart [trendData]="trendData" [isLoading]="trendLoading" [hasError]="hasTrendError"></agm-hours-chart> <agm-hours-chart
<agm-hectares-chart [trendData]="hectaresTrendData" [isLoading]="trendLoading" [hasError]="hasTrendError" [isUS]="isUS"></agm-hectares-chart> [trendData]="trendData"
[isLoading]="trendLoading"
[hasError]="hasTrendError"
[selectedIndex]="selectedIndex"
(daySelected)="onDaySelected($event)">
</agm-hours-chart>
<agm-hectares-chart
[trendData]="hectaresTrendData"
[isLoading]="trendLoading"
[hasError]="hasTrendError"
[isUS]="isUS"
[selectedIndex]="selectedIndex"
(daySelected)="onDaySelected($event)">
</agm-hectares-chart>
<agm-xt-error-indicator <agm-xt-error-indicator
[value]="xtErrorValue" [value]="xtErrorValue"
[threshold]="xtThreshold" [threshold]="xtThreshold"

View File

@ -42,6 +42,41 @@
white-space: nowrap; white-space: nowrap;
} }
// Drill-down filter pill shown when a chart day is selected
.drill-down-filter {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: #e8f5e9;
border: 1px solid #a5d6a7;
border-radius: 2rem;
padding: 0.35rem 0.75rem 0.35rem 0.65rem;
font-size: 0.875rem;
color: #1b5e20;
width: fit-content;
.pi-filter-fill { font-size: 0.85rem; color: #2e7d32; }
}
.drill-down-day {
font-weight: 600;
}
.drill-down-clear {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
color: #2e7d32;
padding: 0.1rem 0.2rem;
border-radius: 50%;
transition: background 0.15s;
&:hover { background: #c8e6c9; }
.pi-times { font-size: 0.75rem; }
}
.kpi-row { .kpi-row {
width: 100%; width: 100%;
display: flex; display: flex;

View File

@ -55,6 +55,15 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
trendLoading = false; trendLoading = false;
hasTrendError = false; hasTrendError = false;
/** Index of the currently drill-down-selected chart bar/point. null = no selection. */
selectedIndex: number | null = null;
/** Human-readable label of the selected day (e.g. "Mon Apr 7"), shown in the filter pill. */
selectedDayLabel: string | null = null;
/** ISO date string of the selected day, passed as ?selectedDate= query param to API calls. */
private selectedDayDate: string | undefined = undefined;
/** Start date of the current trend range, used to map bar index → calendar date. */
private trendRangeStart: Date | null = null;
private readonly destroy$ = new Subject<void>(); private readonly destroy$ = new Subject<void>();
@ -81,8 +90,49 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
this.settings = this.appConf.settings; // keep BaseComp clone in sync this.settings = this.appConf.settings; // keep BaseComp clone in sync
this.appConf.save(null, true); this.appConf.save(null, true);
this.cdRef?.markForCheck(); // re-evaluate [isUS]="isUS" bindings on child components this.cdRef?.markForCheck(); // re-evaluate [isUS]="isUS" bindings on child components
// Re-fetch with current drill-down filter (if any) so unit-toggled labels are consistent.
this.fetchKpiData(this.selectedDayDate);
this.fetchSummaryData(this.selectedDayDate);
}
/**
* PowerBI-style drill-down: clicking a chart bar/point filters all dashboard sections
* to show data for that specific day. Clicking the same day again clears the filter.
*
* All four data sections (KPI, Summary, Active Jobs, Performance) are re-fetched
* from the API with ?selectedDate= so every metric reflects the chosen day.
* detectChanges() is called immediately after updating the selection state so the
* filter pill and chart highlights render before the API responses arrive.
*/
onDaySelected(event: { label: string; index: number }): void {
if (this.selectedIndex === event.index) {
this.clearDaySelection();
return;
}
if (!this.trendRangeStart) { return; }
this.selectedDayDate = DateUtils.toIsoDate(DateUtils.addDays(this.trendRangeStart, event.index));
this.selectedDayLabel = event.label;
this.selectedIndex = event.index;
// Immediately propagate state to OnPush children (filter pill + chart highlights)
// before the API responses arrive.
this.cdRef?.detectChanges();
this.fetchKpiData(this.selectedDayDate);
this.fetchSummaryData(this.selectedDayDate);
this.fetchActiveJobsData(this.selectedDayDate);
this.fetchPerformanceData(this.selectedDayDate);
}
/** Clears the active drill-down filter and restores the full-range data. */
clearDaySelection(): void {
this.selectedIndex = null;
this.selectedDayLabel = null;
this.selectedDayDate = undefined;
this.cdRef?.detectChanges();
// Restore full-range values from the API for all sections.
this.fetchKpiData(); this.fetchKpiData();
this.fetchSummaryData(); this.fetchSummaryData();
this.fetchActiveJobsData();
this.fetchPerformanceData();
} }
ngOnDestroy(): void { ngOnDestroy(): void {
@ -91,18 +141,22 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
super.ngOnDestroy(); super.ngOnDestroy();
} }
private fetchKpiData(): void { /** Converts a historical metric's area values from hectares to the current unit system. */
this.pilotDashboardService.getKpi().pipe( private convertHaHistory(h: PilotHistoricalMetric): PilotHistoricalMetric {
return {
day: UnitUtils.haToArea(h.day, this.isUS),
week: UnitUtils.haToArea(h.week, this.isUS),
month: UnitUtils.haToArea(h.month, this.isUS),
year: UnitUtils.haToArea(h.year, this.isUS),
};
}
private fetchKpiData(date?: string): void {
this.pilotDashboardService.getKpi(date).pipe(
takeUntil(this.destroy$) takeUntil(this.destroy$)
).subscribe({ ).subscribe({
next: (res: PilotKpiResponse) => { next: (res: PilotKpiResponse) => {
const areaUnit = UnitUtils.areaUnitLabel(this.isUS); const areaUnit = UnitUtils.areaUnitLabel(this.isUS);
const convertHaHistory = (h: PilotHistoricalMetric): PilotHistoricalMetric => ({
day: UnitUtils.haToArea(h.day, this.isUS),
week: UnitUtils.haToArea(h.week, this.isUS),
month: UnitUtils.haToArea(h.month, this.isUS),
year: UnitUtils.haToArea(h.year, this.isUS),
});
const assignedAreaLabel = this.isUS const assignedAreaLabel = this.isUS
? $localize`:KPI label US@@kpiAssignedAcres:Assigned Acres` ? $localize`:KPI label US@@kpiAssignedAcres:Assigned Acres`
: $localize`:KPI label@@kpiAssignedHectares:Assigned Hectares`; : $localize`:KPI label@@kpiAssignedHectares:Assigned Hectares`;
@ -120,13 +174,13 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
label: assignedAreaLabel, label: assignedAreaLabel,
value: UnitUtils.haToArea(res.assignedHectares, this.isUS), value: UnitUtils.haToArea(res.assignedHectares, this.isUS),
unit: areaUnit, unit: areaUnit,
historical: convertHaHistory(res.historical.hectares) historical: this.convertHaHistory(res.historical.hectares)
}, },
{ {
label: sprayedAreaLabel, label: sprayedAreaLabel,
value: UnitUtils.haToArea(res.sprayedToday, this.isUS), value: UnitUtils.haToArea(res.sprayedToday, this.isUS),
unit: areaUnit, unit: areaUnit,
historical: convertHaHistory(res.historical.hectares) historical: this.convertHaHistory(res.historical.hectares)
}, },
{ {
label: $localize`:KPI label@@kpiFlightHours:Flight Hours Today`, label: $localize`:KPI label@@kpiFlightHours:Flight Hours Today`,
@ -146,8 +200,8 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
}); });
} }
private fetchSummaryData(): void { private fetchSummaryData(date?: string): void {
this.pilotDashboardService.getSummary().pipe( this.pilotDashboardService.getSummary(date).pipe(
takeUntil(this.destroy$) takeUntil(this.destroy$)
).subscribe({ ).subscribe({
next: (res: PilotSummaryResponse) => { next: (res: PilotSummaryResponse) => {
@ -197,8 +251,8 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
}); });
} }
private fetchActiveJobsData(): void { private fetchActiveJobsData(date?: string): void {
this.pilotDashboardService.getActiveJobs().pipe( this.pilotDashboardService.getActiveJobs(date).pipe(
takeUntil(this.destroy$) takeUntil(this.destroy$)
).subscribe({ ).subscribe({
next: (res: PilotActiveJobsResponse) => { next: (res: PilotActiveJobsResponse) => {
@ -214,9 +268,9 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
}); });
} }
private fetchPerformanceData(): void { private fetchPerformanceData(date?: string): void {
this.performanceLoading = true; this.performanceLoading = true;
this.pilotDashboardService.getPerformance().pipe( this.pilotDashboardService.getPerformance(date).pipe(
takeUntil(this.destroy$) takeUntil(this.destroy$)
).subscribe({ ).subscribe({
next: (res: PilotPerformanceResponse) => { next: (res: PilotPerformanceResponse) => {
@ -262,13 +316,20 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
const endDate = DateUtils.startOfDay(selection.endDate); const endDate = DateUtils.startOfDay(selection.endDate);
const [normalizedStart, normalizedEnd] = startDate <= endDate ? [startDate, endDate] : [endDate, startDate]; const [normalizedStart, normalizedEnd] = startDate <= endDate ? [startDate, endDate] : [endDate, startDate];
// Store range start so drill-down can map bar index → calendar date.
this.trendRangeStart = normalizedStart;
// Changing the date range resets any active single-day filter.
this.selectedIndex = null;
this.selectedDayLabel = null;
this.selectedDayDate = undefined;
this.trendLoading = true; this.trendLoading = true;
this.hasTrendError = false; this.hasTrendError = false;
this.cdRef?.markForCheck(); this.cdRef?.markForCheck();
this.pilotDashboardService.getTrend( this.pilotDashboardService.getTrend(
this.toIsoDate(normalizedStart), DateUtils.toIsoDate(normalizedStart),
this.toIsoDate(normalizedEnd) DateUtils.toIsoDate(normalizedEnd)
).pipe(takeUntil(this.destroy$)).subscribe({ ).pipe(takeUntil(this.destroy$)).subscribe({
next: (res: PilotTrendResponse) => { next: (res: PilotTrendResponse) => {
this.trendData = res.labels.map((label, i) => ({ day: label, value: res.hoursFlown[i] })); this.trendData = res.labels.map((label, i) => ({ day: label, value: res.hoursFlown[i] }));
@ -284,13 +345,6 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
}); });
} }
private toIsoDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
trackByIndex(index: number): number { trackByIndex(index: number): number {
return index; return index;
} }

View File

@ -12,6 +12,8 @@ interface DatasetOverrides {
label: string; label: string;
backgroundColor: string | string[]; backgroundColor: string | string[];
datalabels?: object; datalabels?: object;
/** Decimal places shown in the tooltip. Defaults to 0 (integer). Use 1 for continuous values like hours. */
tooltipDecimals?: number;
} }
interface YAxisTicks { interface YAxisTicks {
@ -34,13 +36,44 @@ export class ChartBuilderUtils {
return [{ gridLines: { color: ChartBuilderUtils.GRID_LINE }, ticks: { fontColor: ChartBuilderUtils.AXIS_LABEL, beginAtZero: true, ...ticks } }]; return [{ gridLines: { color: ChartBuilderUtils.GRID_LINE }, ticks: { fontColor: ChartBuilderUtils.AXIS_LABEL, beginAtZero: true, ...ticks } }];
} }
/**
* Returns true when the dataset has a high-variance distribution: the maximum
* value is more than 5× the median of non-zero values. Used to detect when
* a single outlier would compress the remaining bars into near-invisibility.
*/
private static isHighVariance(values: number[]): boolean {
const nonZero = values.filter(v => v > 0);
if (nonZero.length < 2) { return false; }
const sorted = [...nonZero].sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)];
return Math.max(...nonZero) > 5 * median;
}
/**
* Returns every value that is NOT an outlier, using the same 5× median threshold
* as isHighVariance. Unlike a simple slice(0,-1), this correctly handles multiple
* outliers so the Y-axis scale accommodates every non-outlier bar.
*/
private static inlierValues(values: number[]): number[] {
const nonZero = values.filter(v => v > 0);
if (nonZero.length < 2) { return values; }
const sorted = [...nonZero].sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)];
const threshold = 5 * median;
return values.filter(v => v <= threshold);
}
/** /**
* Computes a nice Y-axis scale from the actual data values. * Computes a nice Y-axis scale from the actual data values.
* `preferredMax` acts as both the zero-data fallback and a minimum floor * `preferredMax` acts as both the zero-data fallback and a minimum floor
* the computed max will never be less than `preferredMax`. * the computed max will never be less than `preferredMax`.
* `scaleValues` overrides which values are used for the scale calculation;
* pass a subset (e.g. values without the outlier) to prevent a single spike
* from collapsing the rest of the bars.
*/ */
private static niceYTicks(values: number[], preferredMax: number): YAxisTicks { private static niceYTicks(values: number[], preferredMax: number, scaleValues?: number[]): YAxisTicks {
const dataMax = values.length > 0 ? Math.max(...values) : 0; const ref = scaleValues ?? values;
const dataMax = ref.length > 0 ? Math.max(...ref) : 0;
// Use whichever is larger: data-driven headroom or the preferred floor // Use whichever is larger: data-driven headroom or the preferred floor
const raw = Math.max(dataMax * 1.2, preferredMax); const raw = Math.max(dataMax * 1.2, preferredMax);
const magnitude = Math.pow(10, Math.floor(Math.log10(raw / 5))); const magnitude = Math.pow(10, Math.floor(Math.log10(raw / 5)));
@ -82,7 +115,15 @@ export class ChartBuilderUtils {
yAxes: ChartBuilderUtils.buildYAxis(yAxisTicks) yAxes: ChartBuilderUtils.buildYAxis(yAxisTicks)
}, },
tooltips: { tooltips: {
callbacks: { label: (item: any) => ` ${item.yLabel} ${tooltipUnit}` } // Use the raw dataset value instead of item.yLabel — yLabel reflects the
// bar's rendered position which is clamped to the axis max when a bar is
// truncated. dataset.data[index] always holds the original value.
callbacks: {
label: (item: any, data: any) => {
const raw = data?.datasets?.[item.datasetIndex]?.data?.[item.index] ?? item.yLabel;
return ` ${Number(raw).toFixed(dataset.tooltipDecimals ?? 0)} ${tooltipUnit}`;
}
}
} }
} }
}; };
@ -99,11 +140,24 @@ export class ChartBuilderUtils {
}); });
} }
/**
* Returns a per-index color array for drill-down selection highlighting.
* The bar/point at `selectedIndex` receives `selectedColor`; all others get `dimmedColor`.
*/
static buildSelectionColors(
count: number,
selectedIndex: number,
selectedColor: string,
dimmedColor: string
): string[] {
return Array.from({ length: count }, (_, i) => i === selectedIndex ? selectedColor : dimmedColor);
}
static hoursChart(data: TrendDataPoint[]): ChartConfig { static hoursChart(data: TrendDataPoint[]): ChartConfig {
const values = data.map(d => d.value); const values = data.map(d => d.value);
return ChartBuilderUtils.buildBaseChartConfig( return ChartBuilderUtils.buildBaseChartConfig(
data, data,
{ label: $localize`:Hours flown chart dataset label@@hoursChartLabel:Hours Flown`, backgroundColor: ChartBuilderUtils.GREEN_DARK }, { label: $localize`:Hours flown chart dataset label@@hoursChartLabel:Hours Flown`, backgroundColor: ChartBuilderUtils.GREEN_DARK, tooltipDecimals: 1 },
ChartBuilderUtils.niceYTicks(values, 5), ChartBuilderUtils.niceYTicks(values, 5),
$localize`:Hours unit abbreviation@@unitHrs:hrs` $localize`:Hours unit abbreviation@@unitHrs:hrs`
); );
@ -111,17 +165,29 @@ export class ChartBuilderUtils {
static hectaresChart(data: TrendDataPoint[], areaUnit = 'ha'): ChartConfig { static hectaresChart(data: TrendDataPoint[], areaUnit = 'ha'): ChartConfig {
const values = data.map(d => d.value); const values = data.map(d => d.value);
const preferredMax = areaUnit === 'ac' ? 2000 : 500; // 500 ha ≈ 1235 ac; keep both unit floors proportional so the empty-headroom
const datalabels = { anchor: 'end', align: 'end', color: ChartBuilderUtils.DATA_LABEL, font: { weight: 'bold' }, formatter: (v: number) => v }; // is the same fraction of typical values regardless of unit selection.
const preferredMax = areaUnit === 'ac' ? 1200 : 500;
const datalabels = { anchor: 'end', align: 'end', color: ChartBuilderUtils.DATA_LABEL, font: { weight: 'bold' }, formatter: (v: number) => Math.round(v) };
const datasetLabel = areaUnit === 'ac' const datasetLabel = areaUnit === 'ac'
? $localize`:Acres sprayed chart dataset label@@acresChartLabel:Acres Sprayed` ? $localize`:Acres sprayed chart dataset label@@acresChartLabel:Acres Sprayed`
: $localize`:Hectares sprayed chart dataset label@@hectaresChartLabel:Hectares Sprayed`; : $localize`:Hectares sprayed chart dataset label@@hectaresChartLabel:Hectares Sprayed`;
return ChartBuilderUtils.buildBaseChartConfig( // When one day dwarfs the rest, compute the Y-axis scale from the inlier
// values only (≤ 5× median). Unlike slice(0,-1), this handles multiple
// outliers so every non-outlier bar fits cleanly within the axis.
const scaleValues = ChartBuilderUtils.isHighVariance(values)
? ChartBuilderUtils.inlierValues(values)
: undefined;
const config = ChartBuilderUtils.buildBaseChartConfig(
data, data,
{ label: datasetLabel, backgroundColor: ChartBuilderUtils.interpolateGreenByValue(values), datalabels }, { label: datasetLabel, backgroundColor: ChartBuilderUtils.interpolateGreenByValue(values), datalabels },
ChartBuilderUtils.niceYTicks(values, preferredMax), ChartBuilderUtils.niceYTicks(values, preferredMax, scaleValues),
areaUnit, areaUnit,
{ datalabels } { datalabels }
); );
// clampedTop is registered by HectaresChartComponent.ngAfterViewInit() — apply it
// here only, not in buildBaseChartConfig, so HoursChartComponent is unaffected.
config.chartOptions.tooltips.position = 'clampedTop';
return config;
} }
} }

View File

@ -26,20 +26,20 @@ export class PilotDashboardService {
return params; return params;
} }
getKpi(): Observable<PilotKpiResponse> { getKpi(selectedDate?: string): Observable<PilotKpiResponse> {
return this.http.get<PilotKpiResponse>(`${this.baseUrl}/kpi`, { params: this.tzParams() }).pipe( return this.http.get<PilotKpiResponse>(`${this.baseUrl}/kpi`, { params: this.tzParams(selectedDate ? { selectedDate } : undefined) }).pipe(
catchError(this.handleError) catchError(this.handleError)
); );
} }
getSummary(): Observable<PilotSummaryResponse> { getSummary(selectedDate?: string): Observable<PilotSummaryResponse> {
return this.http.get<PilotSummaryResponse>(`${this.baseUrl}/summary`, { params: this.tzParams() }).pipe( return this.http.get<PilotSummaryResponse>(`${this.baseUrl}/summary`, { params: this.tzParams(selectedDate ? { selectedDate } : undefined) }).pipe(
catchError(this.handleError) catchError(this.handleError)
); );
} }
getActiveJobs(): Observable<PilotActiveJobsResponse> { getActiveJobs(selectedDate?: string): Observable<PilotActiveJobsResponse> {
return this.http.get<PilotActiveJobsResponse>(`${this.baseUrl}/activeJobs`, { params: this.tzParams() }).pipe( return this.http.get<PilotActiveJobsResponse>(`${this.baseUrl}/activeJobs`, { params: this.tzParams(selectedDate ? { selectedDate } : undefined) }).pipe(
catchError(this.handleError) catchError(this.handleError)
); );
} }
@ -50,8 +50,8 @@ export class PilotDashboardService {
); );
} }
getPerformance(): Observable<PilotPerformanceResponse> { getPerformance(selectedDate?: string): Observable<PilotPerformanceResponse> {
return this.http.get<PilotPerformanceResponse>(`${this.baseUrl}/performance`, { params: this.tzParams() }).pipe( return this.http.get<PilotPerformanceResponse>(`${this.baseUrl}/performance`, { params: this.tzParams(selectedDate ? { selectedDate } : undefined) }).pipe(
catchError(this.handleError) catchError(this.handleError)
); );
} }

View File

@ -14,5 +14,6 @@
panelStyleClass="week-picker" panelStyleClass="week-picker"
appendTo="body" appendTo="body"
(onSelect)="onRangeSelected(dateRangeControl.value)" (onSelect)="onRangeSelected(dateRangeControl.value)"
(onMonthChange)="onMonthChange($event)"
></p-calendar> ></p-calendar>
</div> </div>

View File

@ -14,4 +14,4 @@
color: #1a211c; color: #1a211c;
letter-spacing: 0.02em; letter-spacing: 0.02em;
text-transform: uppercase; text-transform: uppercase;
} }

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, OnInit, OnDestroy, AfterViewInit, Output } from '@angular/core';
import { FormControl } from '@angular/forms'; import { FormControl } from '@angular/forms';
import { DateUtils } from '../utils'; import { DateUtils } from '../utils';
@ -12,7 +12,7 @@ export interface DateRangeSelection {
templateUrl: './date-range-control.component.html', templateUrl: './date-range-control.component.html',
styleUrls: ['./date-range-control.component.scss'] styleUrls: ['./date-range-control.component.scss']
}) })
export class DateRangeControlComponent implements OnInit { export class DateRangeControlComponent implements OnInit, AfterViewInit, OnDestroy {
@Output() rangeChange = new EventEmitter<DateRangeSelection>(); @Output() rangeChange = new EventEmitter<DateRangeSelection>();
readonly maxRangeDays = 90; readonly maxRangeDays = 90;
@ -21,12 +21,104 @@ export class DateRangeControlComponent implements OnInit {
readonly dateRangeControl = new FormControl([]); readonly dateRangeControl = new FormControl([]);
// Tracks which month/year is currently displayed in the calendar panel.
// Used to resolve day-cell numbers into full Date objects on week-row click.
private viewMonth = new Date().getMonth(); // 0-based
private viewYear = new Date().getFullYear();
// Bound document-level click handler — kept as a reference so it can be removed in ngOnDestroy.
private readonly boundWeekClick = (e: MouseEvent) => this.onCalendarClick(e);
ngOnInit(): void { ngOnInit(): void {
const defaultRange = this.getCurrentWeekRange(); const defaultRange = this.getCurrentWeekRange();
this.dateRangeControl.setValue(defaultRange); this.dateRangeControl.setValue(defaultRange);
this.emitIfRangeComplete(defaultRange); this.emitIfRangeComplete(defaultRange);
} }
ngAfterViewInit(): void {
// The calendar panel is appended to <body> (appendTo="body"), so the click
// handler must live on document — not on the component's host element.
document.addEventListener('click', this.boundWeekClick);
}
ngOnDestroy(): void {
document.removeEventListener('click', this.boundWeekClick);
}
/** Keeps viewMonth/viewYear in sync when the user navigates the calendar. */
onMonthChange(event: { month: number; year: number }): void {
this.viewMonth = event.month - 1; // PrimeNG emits 1-based month
this.viewYear = event.year;
}
/**
* Handles clicks anywhere inside the calendar panel.
* When the click lands on a week-number cell, the entire displayed week row
* is selected as the date range (clamped to maxDate / minDate).
*/
onCalendarClick(event: MouseEvent): void {
const target = event.target as HTMLElement;
// Guard: only act on clicks inside a week-picker calendar panel.
// appendTo="body" renders multiple panels in <body>; other calendars must be ignored.
if (!target.closest('.ui-datepicker.week-picker')) { return; }
const weekCell = target.closest('.ui-datepicker-weeknumber') as HTMLElement | null;
if (!weekCell) { return; }
const row = weekCell.closest('tr') as HTMLTableRowElement | null;
if (!row) { return; }
// All TD cells in the row except the week-number cell itself
const dayCells = Array.from(
row.querySelectorAll('td:not(.ui-datepicker-weeknumber)')
) as HTMLElement[];
const dates: Date[] = [];
// Track transition: prev-month overflow → current month → next-month overflow
let phase: 'prev' | 'current' | 'next' = 'prev';
for (const cell of dayCells) {
const isOther = cell.classList.contains('ui-datepicker-other-month');
// Detect transition from prev overflow to current month
if (!isOther && phase === 'prev') { phase = 'current'; }
// Detect transition from current month to next overflow
if (isOther && phase === 'current') { phase = 'next'; }
const anchor = cell.querySelector('a, span') as HTMLElement | null;
if (!anchor) { continue; }
const dayNum = parseInt(anchor.textContent?.trim() ?? '', 10);
if (isNaN(dayNum)) { continue; }
let month = this.viewMonth;
let year = this.viewYear;
if (phase === 'prev') {
month--;
if (month < 0) { month = 11; year--; }
} else if (phase === 'next') {
month++;
if (month > 11) { month = 0; year++; }
}
dates.push(new Date(year, month, dayNum));
}
if (dates.length === 0) { return; }
const today = this.maxDate;
let startDate = DateUtils.startOfDay(dates[0]);
let endDate = DateUtils.startOfDay(dates[dates.length - 1]);
// Clamp to allowed bounds
if (startDate < this.minDate) { startDate = this.minDate; }
if (endDate > today) { endDate = today; }
if (startDate > today) { return; } // entire week is in the future
const range = [startDate, endDate];
this.dateRangeControl.setValue(range);
this.onRangeSelected(range);
}
onRangeSelected(range: Date[]): void { onRangeSelected(range: Date[]): void {
if (!Array.isArray(range) || range.length < 2 || !range[0] || !range[1]) { if (!Array.isArray(range) || range.length < 2 || !range[0] || !range[1]) {
return; return;

View File

@ -739,6 +739,11 @@ export class DateUtils {
return date instanceof Date ? `${date.getFullYear()}-${NumUtils.padZero(date.getMonth() + 1, 2)}` : ''; return date instanceof Date ? `${date.getFullYear()}-${NumUtils.padZero(date.getMonth() + 1, 2)}` : '';
} }
/** Format a Date as an ISO date string (YYYY-MM-DD) in local time. */
static toIsoDate(date: Date): string {
return `${date.getFullYear()}-${NumUtils.padZero(date.getMonth() + 1, 2)}-${NumUtils.padZero(date.getDate(), 2)}`;
}
static msToTime(ms, tz = undefined) { static msToTime(ms, tz = undefined) {
if (!ms) return "00:00:00.0"; if (!ms) return "00:00:00.0";
let secs, min, hrs; let secs, min, hrs;

View File

@ -1966,6 +1966,8 @@ body .ui-datepicker.week-picker .ui-datepicker-calendar tbody tr:hover td:last-c
border-radius: 0 50% 50% 0; border-radius: 0 50% 50% 0;
} }
// Form calendar internals PrimeNG v9 renders p-calendar > span.ui-calendar.ui-calendar-w-btn > [input + button] // Form calendar internals PrimeNG v9 renders p-calendar > span.ui-calendar.ui-calendar-w-btn > [input + button]
body span.form-calendar.ui-calendar, body span.form-calendar.ui-calendar,
body .form-calendar.ui-calendar, body .form-calendar.ui-calendar,