bowling-analysis-system
Version:
A comprehensive system for analyzing bowling techniques using video processing and metrics calculation
339 lines (294 loc) • 10 kB
JavaScript
/**
* Comparison Generator Processor
*
* Generates comparisons between multiple metrics datasets
* @module processors/ComparisonGeneratorProcessor
*/
const { BaseProcessor } = require('../core/BaseProcessor');
/**
* @class ComparisonGeneratorProcessor
* @description Processor for generating comparisons between multiple metrics datasets
* @extends BaseProcessor
*/
class ComparisonGeneratorProcessor extends BaseProcessor {
/**
* Create a new comparison generator processor
* @param {Object} config - Processor configuration
*/
constructor(config = {}) {
super('comparisonGenerator', config);
this.formatters = new Map();
// Register default formatters
this.registerFormatter('html', this._formatHtml.bind(this));
this.registerFormatter('json', this._formatJson.bind(this));
this.registerFormatter('csv', this._formatCsv.bind(this));
}
/**
* Register a comparison formatter
* @param {string} format - Format name
* @param {Function} formatter - Formatter function
* @returns {ComparisonGeneratorProcessor} Processor instance for chaining
*/
registerFormatter(format, formatter) {
this.formatters.set(format, formatter);
return this;
}
/**
* Process input by generating a comparison
* @param {Array<Object>} input - Input metrics datasets
* @param {Object} context - Processing context
* @returns {Promise<*>} Generated comparison
* @protected
*/
async _process(input, context) {
if (!Array.isArray(input) || input.length < 2) {
throw new Error('Comparison requires at least two datasets');
}
const format = this.config.format || 'html';
const metrics = this.config.metrics || null;
// Extract metrics from each dataset
const datasets = input.map((item, index) => {
return {
name: item.path ? item.path.split('/').pop() : `Dataset ${index + 1}`,
metrics: item.data?.metrics || {}
};
});
// Generate comparison data
const comparison = this._generateComparison(datasets, metrics);
// Get formatter
const formatter = this.formatters.get(format);
if (!formatter) {
throw new Error(`Unknown comparison format: ${format}`);
}
// Apply formatter
return formatter(comparison, context);
}
/**
* Generate comparison data
* @param {Array<Object>} datasets - Metrics datasets
* @param {Array<string>|null} metricsList - List of metrics to compare
* @returns {Object} Comparison data
* @private
*/
_generateComparison(datasets, metricsList) {
// Collect all available metrics
const allMetrics = new Set();
datasets.forEach(dataset => {
this._collectMetricPaths(dataset.metrics, '', allMetrics);
});
// Filter metrics if specified
const metricsToCompare = metricsList
? Array.from(allMetrics).filter(metric =>
metricsList.some(pattern => metric.includes(pattern))
)
: Array.from(allMetrics);
// Generate comparison for each metric
const comparisons = {};
metricsToCompare.forEach(metric => {
const values = datasets.map(dataset => {
return this._getNestedValue(dataset.metrics, metric.split('.'));
});
comparisons[metric] = {
values: values.map((value, index) => ({
dataset: datasets[index].name,
value
})),
min: this._getNumericMin(values),
max: this._getNumericMax(values),
mean: this._getNumericMean(values),
diff: values.length >= 2 ? this._calculateDiff(values[0], values[1]) : null
};
});
return {
timestamp: Date.now(),
datasets: datasets.map(d => d.name),
metrics: metricsToCompare,
comparisons
};
}
/**
* Collect all metric paths from an object
* @param {Object} obj - Object to collect metrics from
* @param {string} prefix - Current path prefix
* @param {Set<string>} result - Set to collect paths
* @private
*/
_collectMetricPaths(obj, prefix, result) {
if (!obj || typeof obj !== 'object') {
return;
}
Object.entries(obj).forEach(([key, value]) => {
const path = prefix ? `${prefix}.${key}` : key;
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
this._collectMetricPaths(value, path, result);
} else {
result.add(path);
}
});
}
/**
* Get a nested value from an object
* @param {Object} obj - Object to get value from
* @param {Array<string>} path - Path to value
* @returns {*} Nested value
* @private
*/
_getNestedValue(obj, path) {
let current = obj;
for (const key of path) {
if (current === undefined || current === null) {
return undefined;
}
current = current[key];
}
return current;
}
/**
* Get minimum numeric value
* @param {Array<*>} values - Values to get minimum from
* @returns {number|null} Minimum value
* @private
*/
_getNumericMin(values) {
const numericValues = values.filter(v => typeof v === 'number');
return numericValues.length > 0 ? Math.min(...numericValues) : null;
}
/**
* Get maximum numeric value
* @param {Array<*>} values - Values to get maximum from
* @returns {number|null} Maximum value
* @private
*/
_getNumericMax(values) {
const numericValues = values.filter(v => typeof v === 'number');
return numericValues.length > 0 ? Math.max(...numericValues) : null;
}
/**
* Get mean numeric value
* @param {Array<*>} values - Values to get mean from
* @returns {number|null} Mean value
* @private
*/
_getNumericMean(values) {
const numericValues = values.filter(v => typeof v === 'number');
return numericValues.length > 0
? numericValues.reduce((sum, v) => sum + v, 0) / numericValues.length
: null;
}
/**
* Calculate difference between two values
* @param {*} a - First value
* @param {*} b - Second value
* @returns {Object|null} Difference information
* @private
*/
_calculateDiff(a, b) {
if (typeof a === 'number' && typeof b === 'number') {
const absoluteDiff = b - a;
const percentDiff = a !== 0 ? (absoluteDiff / Math.abs(a)) * 100 : null;
return {
absolute: absoluteDiff,
percent: percentDiff,
improved: absoluteDiff > 0
};
}
return null;
}
/**
* Format comparison as HTML
* @param {Object} comparison - Comparison data
* @param {Object} context - Context data
* @returns {string} HTML comparison
* @private
*/
_formatHtml(comparison, context) {
// Generate HTML table for comparison
const metricsHtml = comparison.metrics.map(metric => {
const data = comparison.comparisons[metric];
const values = data.values.map(v =>
`<td>${typeof v.value === 'number' ? v.value.toFixed(2) : v.value || 'N/A'}</td>`
).join('');
const diff = data.diff
? `<td class="${data.diff.improved ? 'improved' : 'declined'}">${data.diff.absolute.toFixed(2)} (${data.diff.percent.toFixed(1)}%)</td>`
: '<td>N/A</td>';
return `
<tr>
<td>${metric}</td>
${values}
${comparison.datasets.length === 2 ? diff : ''}
</tr>
`;
}).join('');
return `<!DOCTYPE html>
<html>
<head>
<title>Metrics Comparison</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; }
h1 { color: #333; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
tr:nth-child(even) { background-color: #f9f9f9; }
.improved { color: green; }
.declined { color: red; }
</style>
</head>
<body>
<h1>Metrics Comparison</h1>
<p>Generated on ${new Date().toLocaleString()}</p>
<p>Comparing ${comparison.datasets.length} datasets: ${comparison.datasets.join(', ')}</p>
<table>
<thead>
<tr>
<th>Metric</th>
${comparison.datasets.map(name => `<th>${name}</th>`).join('')}
${comparison.datasets.length === 2 ? '<th>Difference</th>' : ''}
</tr>
</thead>
<tbody>
${metricsHtml}
</tbody>
</table>
</body>
</html>`;
}
/**
* Format comparison as JSON
* @param {Object} comparison - Comparison data
* @param {Object} context - Context data
* @returns {string} JSON comparison
* @private
*/
_formatJson(comparison, context) {
return JSON.stringify(comparison, null, 2);
}
/**
* Format comparison as CSV
* @param {Object} comparison - Comparison data
* @param {Object} context - Context data
* @returns {string} CSV comparison
* @private
*/
_formatCsv(comparison, context) {
// Generate CSV header
const header = ['Metric', ...comparison.datasets];
if (comparison.datasets.length === 2) {
header.push('Difference', 'Percent');
}
// Generate CSV rows
const rows = comparison.metrics.map(metric => {
const data = comparison.comparisons[metric];
const values = data.values.map(v =>
typeof v.value === 'number' ? v.value.toFixed(4) : (v.value || 'N/A')
);
const row = [metric, ...values];
if (comparison.datasets.length === 2 && data.diff) {
row.push(data.diff.absolute.toFixed(4), data.diff.percent.toFixed(2) + '%');
}
return row.join(',');
});
return [header.join(','), ...rows].join('\n');
}
}
module.exports = ComparisonGeneratorProcessor;