localgreenchain/lib/analytics/metrics.ts
Claude 816c3b3f2e
Implement Agent 7: Advanced Analytics Dashboard
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
2025-11-23 04:02:07 +00:00

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