Add comprehensive analytics system with: - Analytics data layer (aggregator, metrics, trends, cache) - 6 API endpoints (overview, plants, transport, farms, sustainability, export) - 6 chart components (LineChart, BarChart, PieChart, AreaChart, Gauge, Heatmap) - 5 dashboard widgets (KPICard, TrendIndicator, DataTable, DateRangePicker, FilterPanel) - 5 dashboard pages (overview, plants, transport, farms, sustainability) - Export functionality (CSV, JSON) Dependencies added: recharts, d3, date-fns Also includes minor fixes: - Fix EnvironmentalForm spread type error - Fix AgentOrchestrator Map iteration issues - Fix next.config.js image domains undefined error - Add downlevelIteration to tsconfig
326 lines
9.2 KiB
TypeScript
326 lines
9.2 KiB
TypeScript
/**
|
|
* Metrics Calculations for Analytics
|
|
* Provides metric calculations and statistical functions
|
|
*/
|
|
|
|
import { TrendDirection, TimeSeriesDataPoint, KPICardData } from './types';
|
|
|
|
/**
|
|
* Calculate mean of an array of numbers
|
|
*/
|
|
export function mean(values: number[]): number {
|
|
if (values.length === 0) return 0;
|
|
return values.reduce((sum, v) => sum + v, 0) / values.length;
|
|
}
|
|
|
|
/**
|
|
* Calculate median of an array of numbers
|
|
*/
|
|
export function median(values: number[]): number {
|
|
if (values.length === 0) return 0;
|
|
const sorted = [...values].sort((a, b) => a - b);
|
|
const mid = Math.floor(sorted.length / 2);
|
|
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
}
|
|
|
|
/**
|
|
* Calculate standard deviation
|
|
*/
|
|
export function standardDeviation(values: number[]): number {
|
|
if (values.length === 0) return 0;
|
|
const avg = mean(values);
|
|
const squareDiffs = values.map(v => Math.pow(v - avg, 2));
|
|
return Math.sqrt(mean(squareDiffs));
|
|
}
|
|
|
|
/**
|
|
* Calculate percentile
|
|
*/
|
|
export function percentile(values: number[], p: number): number {
|
|
if (values.length === 0) return 0;
|
|
const sorted = [...values].sort((a, b) => a - b);
|
|
const index = (p / 100) * (sorted.length - 1);
|
|
const lower = Math.floor(index);
|
|
const upper = Math.ceil(index);
|
|
const weight = index - lower;
|
|
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
|
|
}
|
|
|
|
/**
|
|
* Calculate min and max
|
|
*/
|
|
export function minMax(values: number[]): { min: number; max: number } {
|
|
if (values.length === 0) return { min: 0, max: 0 };
|
|
return {
|
|
min: Math.min(...values),
|
|
max: Math.max(...values),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Determine trend direction from two values
|
|
*/
|
|
export function getTrendDirection(current: number, previous: number, threshold: number = 0.5): TrendDirection {
|
|
const change = ((current - previous) / Math.abs(previous || 1)) * 100;
|
|
if (Math.abs(change) < threshold) return 'stable';
|
|
return change > 0 ? 'up' : 'down';
|
|
}
|
|
|
|
/**
|
|
* Calculate percentage change
|
|
*/
|
|
export function percentageChange(current: number, previous: number): number {
|
|
if (previous === 0) return current > 0 ? 100 : 0;
|
|
return ((current - previous) / Math.abs(previous)) * 100;
|
|
}
|
|
|
|
/**
|
|
* Calculate moving average
|
|
*/
|
|
export function movingAverage(data: TimeSeriesDataPoint[], windowSize: number): TimeSeriesDataPoint[] {
|
|
return data.map((point, index) => {
|
|
const start = Math.max(0, index - windowSize + 1);
|
|
const window = data.slice(start, index + 1);
|
|
const avg = mean(window.map(p => p.value));
|
|
return {
|
|
...point,
|
|
value: Math.round(avg * 100) / 100,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Calculate rate of change (derivative)
|
|
*/
|
|
export function rateOfChange(data: TimeSeriesDataPoint[]): TimeSeriesDataPoint[] {
|
|
return data.slice(1).map((point, index) => ({
|
|
...point,
|
|
value: point.value - data[index].value,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Normalize values to 0-100 range
|
|
*/
|
|
export function normalize(values: number[]): number[] {
|
|
const { min, max } = minMax(values);
|
|
const range = max - min;
|
|
if (range === 0) return values.map(() => 50);
|
|
return values.map(v => ((v - min) / range) * 100);
|
|
}
|
|
|
|
/**
|
|
* Calculate compound annual growth rate (CAGR)
|
|
*/
|
|
export function cagr(startValue: number, endValue: number, years: number): number {
|
|
if (startValue <= 0 || years <= 0) return 0;
|
|
return (Math.pow(endValue / startValue, 1 / years) - 1) * 100;
|
|
}
|
|
|
|
/**
|
|
* Calculate efficiency score
|
|
*/
|
|
export function efficiencyScore(actual: number, optimal: number): number {
|
|
if (optimal === 0) return actual === 0 ? 100 : 0;
|
|
return Math.min(100, (optimal / actual) * 100);
|
|
}
|
|
|
|
/**
|
|
* Calculate carbon intensity (kg CO2 per km)
|
|
*/
|
|
export function carbonIntensity(carbonKg: number, distanceKm: number): number {
|
|
if (distanceKm === 0) return 0;
|
|
return carbonKg / distanceKm;
|
|
}
|
|
|
|
/**
|
|
* Calculate food miles score (0-100, lower is better)
|
|
*/
|
|
export function foodMilesScore(miles: number, maxMiles: number = 5000): number {
|
|
if (miles >= maxMiles) return 0;
|
|
return Math.round((1 - miles / maxMiles) * 100);
|
|
}
|
|
|
|
/**
|
|
* Calculate sustainability composite score
|
|
*/
|
|
export function sustainabilityScore(
|
|
carbonReduction: number,
|
|
localPercentage: number,
|
|
waterEfficiency: number,
|
|
wasteReduction: number
|
|
): number {
|
|
const weights = {
|
|
carbon: 0.35,
|
|
local: 0.25,
|
|
water: 0.25,
|
|
waste: 0.15,
|
|
};
|
|
|
|
return Math.round(
|
|
carbonReduction * weights.carbon +
|
|
localPercentage * weights.local +
|
|
waterEfficiency * weights.water +
|
|
wasteReduction * weights.waste
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Generate KPI card data from metrics
|
|
*/
|
|
export function generateKPICards(metrics: {
|
|
plants: { current: number; previous: number };
|
|
carbon: { current: number; previous: number };
|
|
foodMiles: { current: number; previous: number };
|
|
users: { current: number; previous: number };
|
|
sustainability: { current: number; previous: number };
|
|
}): KPICardData[] {
|
|
return [
|
|
{
|
|
id: 'total-plants',
|
|
title: 'Total Plants',
|
|
value: metrics.plants.current,
|
|
change: metrics.plants.current - metrics.plants.previous,
|
|
changePercent: percentageChange(metrics.plants.current, metrics.plants.previous),
|
|
trend: getTrendDirection(metrics.plants.current, metrics.plants.previous),
|
|
color: 'green',
|
|
},
|
|
{
|
|
id: 'carbon-saved',
|
|
title: 'Carbon Saved',
|
|
value: metrics.carbon.current.toFixed(1),
|
|
unit: 'kg CO2',
|
|
change: metrics.carbon.current - metrics.carbon.previous,
|
|
changePercent: percentageChange(metrics.carbon.current, metrics.carbon.previous),
|
|
trend: getTrendDirection(metrics.carbon.current, metrics.carbon.previous),
|
|
color: 'teal',
|
|
},
|
|
{
|
|
id: 'food-miles',
|
|
title: 'Food Miles',
|
|
value: metrics.foodMiles.current.toFixed(0),
|
|
unit: 'km',
|
|
change: metrics.foodMiles.current - metrics.foodMiles.previous,
|
|
changePercent: percentageChange(metrics.foodMiles.current, metrics.foodMiles.previous),
|
|
trend: getTrendDirection(metrics.foodMiles.previous, metrics.foodMiles.current), // Inverted: lower is better
|
|
color: 'blue',
|
|
},
|
|
{
|
|
id: 'active-users',
|
|
title: 'Active Users',
|
|
value: metrics.users.current,
|
|
change: metrics.users.current - metrics.users.previous,
|
|
changePercent: percentageChange(metrics.users.current, metrics.users.previous),
|
|
trend: getTrendDirection(metrics.users.current, metrics.users.previous),
|
|
color: 'purple',
|
|
},
|
|
{
|
|
id: 'sustainability',
|
|
title: 'Sustainability Score',
|
|
value: metrics.sustainability.current.toFixed(0),
|
|
unit: '%',
|
|
change: metrics.sustainability.current - metrics.sustainability.previous,
|
|
changePercent: percentageChange(metrics.sustainability.current, metrics.sustainability.previous),
|
|
trend: getTrendDirection(metrics.sustainability.current, metrics.sustainability.previous),
|
|
color: 'green',
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Calculate growth metrics
|
|
*/
|
|
export function calculateGrowthMetrics(data: TimeSeriesDataPoint[]): {
|
|
totalGrowth: number;
|
|
averageDaily: number;
|
|
peakValue: number;
|
|
peakDate: string;
|
|
trend: TrendDirection;
|
|
} {
|
|
if (data.length === 0) {
|
|
return { totalGrowth: 0, averageDaily: 0, peakValue: 0, peakDate: '', trend: 'stable' };
|
|
}
|
|
|
|
const values = data.map(d => d.value);
|
|
const total = values.reduce((sum, v) => sum + v, 0);
|
|
const avgDaily = total / data.length;
|
|
const maxIndex = values.indexOf(Math.max(...values));
|
|
|
|
const firstHalf = mean(values.slice(0, Math.floor(values.length / 2)));
|
|
const secondHalf = mean(values.slice(Math.floor(values.length / 2)));
|
|
|
|
return {
|
|
totalGrowth: total,
|
|
averageDaily: Math.round(avgDaily * 100) / 100,
|
|
peakValue: values[maxIndex],
|
|
peakDate: data[maxIndex].timestamp,
|
|
trend: getTrendDirection(secondHalf, firstHalf),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Detect anomalies using z-score
|
|
*/
|
|
export function detectAnomalies(
|
|
data: TimeSeriesDataPoint[],
|
|
threshold: number = 2
|
|
): TimeSeriesDataPoint[] {
|
|
const values = data.map(d => d.value);
|
|
const avg = mean(values);
|
|
const std = standardDeviation(values);
|
|
|
|
if (std === 0) return [];
|
|
|
|
return data.filter(point => {
|
|
const zScore = Math.abs((point.value - avg) / std);
|
|
return zScore > threshold;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Calculate correlation coefficient between two datasets
|
|
*/
|
|
export function correlationCoefficient(x: number[], y: number[]): number {
|
|
if (x.length !== y.length || x.length === 0) return 0;
|
|
|
|
const n = x.length;
|
|
const meanX = mean(x);
|
|
const meanY = mean(y);
|
|
|
|
let numerator = 0;
|
|
let denomX = 0;
|
|
let denomY = 0;
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
const dx = x[i] - meanX;
|
|
const dy = y[i] - meanY;
|
|
numerator += dx * dy;
|
|
denomX += dx * dx;
|
|
denomY += dy * dy;
|
|
}
|
|
|
|
const denominator = Math.sqrt(denomX * denomY);
|
|
return denominator === 0 ? 0 : numerator / denominator;
|
|
}
|
|
|
|
/**
|
|
* Format large numbers for display
|
|
*/
|
|
export function formatNumber(value: number, decimals: number = 1): string {
|
|
if (Math.abs(value) >= 1000000) {
|
|
return (value / 1000000).toFixed(decimals) + 'M';
|
|
}
|
|
if (Math.abs(value) >= 1000) {
|
|
return (value / 1000).toFixed(decimals) + 'K';
|
|
}
|
|
return value.toFixed(decimals);
|
|
}
|
|
|
|
/**
|
|
* Format percentage for display
|
|
*/
|
|
export function formatPercentage(value: number, showSign: boolean = false): string {
|
|
const formatted = value.toFixed(1);
|
|
if (showSign && value > 0) return '+' + formatted + '%';
|
|
return formatted + '%';
|
|
}
|