Files
units-ui/lib/units.ts
Sebastian Krüger fa1b82bbd6 fix: handle same-unit conversions in tempo converter
Fix issue where converting BPM to BPM (or any tempo unit to itself)
was incorrectly applying the conversion formula instead of returning
the same value.

Example bug: 120 BPM → BPM was returning 0.008333 instead of 120

Added check: if fromUnit === toUnit, return value unchanged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 11:59:15 +01:00

307 lines
7.4 KiB
TypeScript

/**
* Unit conversion service wrapper for convert-units library
* Provides type-safe conversion utilities and metadata
*/
import convert from 'convert-units';
import { tempoMeasure } from './tempo';
export type Measure =
| 'angle'
| 'apparentPower'
| 'area'
| 'current'
| 'digital'
| 'each'
| 'energy'
| 'frequency'
| 'illuminance'
| 'length'
| 'mass'
| 'pace'
| 'partsPer'
| 'power'
| 'pressure'
| 'reactiveEnergy'
| 'reactivePower'
| 'speed'
| 'temperature'
| 'tempo'
| 'time'
| 'voltage'
| 'volume'
| 'volumeFlowRate';
export interface UnitInfo {
abbr: string;
measure: Measure;
system: 'metric' | 'imperial' | 'bits' | 'bytes' | string;
singular: string;
plural: string;
}
export interface ConversionResult {
value: number;
unit: string;
unitInfo: UnitInfo;
}
/**
* Get all available measures/categories
*/
export function getAllMeasures(): Measure[] {
const standardMeasures = convert().measures() as Measure[];
return [...standardMeasures, 'tempo'];
}
/**
* Get all units for a specific measure
*/
export function getUnitsForMeasure(measure: Measure): string[] {
if (measure === 'tempo') {
return Object.keys(tempoMeasure.tempo.systems.metric);
}
return convert().possibilities(measure);
}
/**
* Get detailed information about a unit
*/
export function getUnitInfo(unit: string): UnitInfo | null {
try {
// Check if it's a tempo unit
const tempoUnits = tempoMeasure.tempo.systems.metric;
if (unit in tempoUnits) {
const tempoUnit = tempoUnits[unit as keyof typeof tempoUnits];
return {
abbr: unit,
measure: 'tempo',
system: 'metric',
singular: tempoUnit.name.singular,
plural: tempoUnit.name.plural,
};
}
const description = convert().describe(unit);
return description as UnitInfo;
} catch {
return null;
}
}
/**
* Convert a value from one unit to another
*/
export function convertUnit(
value: number,
fromUnit: string,
toUnit: string
): number {
try {
// Handle tempo conversions
const tempoUnits = tempoMeasure.tempo.systems.metric;
const isFromTempo = fromUnit in tempoUnits;
const isToTempo = toUnit in tempoUnits;
if (isFromTempo && isToTempo) {
// Same unit - no conversion needed
if (fromUnit === toUnit) {
return value;
}
const fromAnchor = tempoUnits[fromUnit as keyof typeof tempoUnits].to_anchor;
const toAnchor = tempoUnits[toUnit as keyof typeof tempoUnits].to_anchor;
// Special handling for BPM conversions (reciprocal relationship)
if (fromUnit === 'BPM') {
// BPM → time unit: divide anchor by BPM value
// Example: 120 BPM → quarter = 60000 / 120 = 500ms
return toAnchor / value;
} else if (toUnit === 'BPM') {
// Time unit → BPM: divide anchor by time value
// Example: 500ms quarter → BPM = 60000 / 500 = 120
return fromAnchor / value;
} else {
// Time unit → time unit: convert through BPM
// Example: 500ms quarter → eighth
// Step 1: 500ms → BPM = 60000 / 500 = 120
// Step 2: 120 BPM → eighth = 30000 / 120 = 250ms
const bpm = fromAnchor / value;
return toAnchor / bpm;
}
}
return convert(value).from(fromUnit).to(toUnit);
} catch (error) {
console.error('Conversion error:', error);
return value;
}
}
/**
* Convert a value to all compatible units in the same measure
*/
export function convertToAll(
value: number,
fromUnit: string
): ConversionResult[] {
try {
const unitInfo = getUnitInfo(fromUnit);
if (!unitInfo) return [];
const compatibleUnits = getUnitsForMeasure(unitInfo.measure);
return compatibleUnits.map(toUnit => {
const convertedValue = convertUnit(value, fromUnit, toUnit);
const toUnitInfo = getUnitInfo(toUnit);
return {
value: convertedValue,
unit: toUnit,
unitInfo: toUnitInfo!,
};
});
} catch (error) {
console.error('Conversion error:', error);
return [];
}
}
/**
* Get category color for a measure (Tailwind class name)
*/
export function getCategoryColor(measure: Measure): string {
const colorMap: Record<Measure, string> = {
angle: 'category-angle',
apparentPower: 'category-apparent-power',
area: 'category-area',
current: 'category-current',
digital: 'category-digital',
each: 'category-each',
energy: 'category-energy',
frequency: 'category-frequency',
illuminance: 'category-illuminance',
length: 'category-length',
mass: 'category-mass',
pace: 'category-pace',
partsPer: 'category-parts-per',
power: 'category-power',
pressure: 'category-pressure',
reactiveEnergy: 'category-reactive-energy',
reactivePower: 'category-reactive-power',
speed: 'category-speed',
temperature: 'category-temperature',
tempo: 'category-tempo',
time: 'category-time',
voltage: 'category-voltage',
volume: 'category-volume',
volumeFlowRate: 'category-volume-flow-rate',
};
return colorMap[measure];
}
/**
* Get category color hex value for a measure
*/
export function getCategoryColorHex(measure: Measure): string {
const colorMap: Record<Measure, string> = {
angle: '#0EA5E9',
apparentPower: '#8B5CF6',
area: '#F59E0B',
current: '#F59E0B',
digital: '#06B6D4',
each: '#64748B',
energy: '#EAB308',
frequency: '#A855F7',
illuminance: '#84CC16',
length: '#3B82F6',
mass: '#10B981',
pace: '#14B8A6',
partsPer: '#EC4899',
power: '#F43F5E',
pressure: '#6366F1',
reactiveEnergy: '#D946EF',
reactivePower: '#E879F9',
speed: '#10B981',
temperature: '#EF4444',
tempo: '#F97316', // Orange for music/tempo
time: '#7C3AED',
voltage: '#FB923C',
volume: '#8B5CF6',
volumeFlowRate: '#22D3EE',
};
return colorMap[measure];
}
/**
* Format measure name for display
*/
export function formatMeasureName(measure: Measure): string {
const nameMap: Record<Measure, string> = {
angle: 'Angle',
apparentPower: 'Apparent Power',
area: 'Area',
current: 'Current',
digital: 'Digital Storage',
each: 'Each',
energy: 'Energy',
frequency: 'Frequency',
illuminance: 'Illuminance',
length: 'Length',
mass: 'Mass',
pace: 'Pace',
partsPer: 'Parts Per',
power: 'Power',
pressure: 'Pressure',
reactiveEnergy: 'Reactive Energy',
reactivePower: 'Reactive Power',
speed: 'Speed',
temperature: 'Temperature',
tempo: 'Tempo / BPM',
time: 'Time',
voltage: 'Voltage',
volume: 'Volume',
volumeFlowRate: 'Volume Flow Rate',
};
return nameMap[measure];
}
/**
* Search units by query string (fuzzy search)
*/
export function searchUnits(query: string): UnitInfo[] {
if (!query) return [];
const allMeasures = getAllMeasures();
const results: UnitInfo[] = [];
const lowerQuery = query.toLowerCase();
for (const measure of allMeasures) {
const units = getUnitsForMeasure(measure);
for (const unit of units) {
const info = getUnitInfo(unit);
if (!info) continue;
const searchableText = [
info.abbr,
info.singular,
info.plural,
measure,
formatMeasureName(measure),
]
.join(' ')
.toLowerCase();
if (searchableText.includes(lowerQuery)) {
results.push(info);
}
}
}
return results;
}