/** * 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 + '%'; }