Rewrites all three units components to use the same glass panel layout, lg:grid-cols-5 two-panel split, and interactive patterns established by the calculate tool. - MainConverter: category sidebar (left 2/5) replaces Select dropdown; converter card + scrollable conversion grid (right 3/5); mobile 'Category | Convert' tab switcher; clickable conversion cards set target unit; glass Grid/Chart toggle - SearchUnits: native input with glass border, glass dropdown panel, compact result rows matching font selector style - VisualComparison: polished gradient bars, tighter spacing, cleaner value display; all drag logic preserved Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
222 lines
9.2 KiB
TypeScript
222 lines
9.2 KiB
TypeScript
'use client';
|
|
|
|
import { useMemo, useState, useRef, useCallback, useEffect } from 'react';
|
|
import { type ConversionResult } from '@/lib/units/units';
|
|
import { formatNumber, cn } from '@/lib/utils';
|
|
|
|
interface VisualComparisonProps {
|
|
conversions: ConversionResult[];
|
|
onValueChange?: (value: number, unit: string, dragging: boolean) => void;
|
|
}
|
|
|
|
export default function VisualComparison({ conversions, onValueChange }: VisualComparisonProps) {
|
|
const [draggingUnit, setDraggingUnit] = useState<string | null>(null);
|
|
const [draggedPercentage, setDraggedPercentage] = useState<number | null>(null);
|
|
const dragStartX = useRef<number>(0);
|
|
const dragStartWidth = useRef<number>(0);
|
|
const activeBarRef = useRef<HTMLDivElement | null>(null);
|
|
const lastUpdateTime = useRef<number>(0);
|
|
const baseConversionsRef = useRef<ConversionResult[]>([]);
|
|
|
|
const withPercentages = useMemo(() => {
|
|
if (conversions.length === 0) return [];
|
|
const scaleSource = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
|
|
const values = scaleSource.map((c) => Math.abs(c.value));
|
|
const maxValue = Math.max(...values);
|
|
const minValue = Math.min(...values.filter((v) => v > 0));
|
|
|
|
if (maxValue === 0 || !isFinite(maxValue)) {
|
|
return conversions.map((c) => ({ ...c, percentage: 0 }));
|
|
}
|
|
|
|
return conversions.map((c) => {
|
|
const absValue = Math.abs(c.value);
|
|
if (absValue === 0 || !isFinite(absValue)) return { ...c, percentage: 2 };
|
|
const logValue = Math.log10(absValue);
|
|
const logMax = Math.log10(maxValue);
|
|
const logMin = minValue > 0 ? Math.log10(minValue) : logMax - 6;
|
|
const logRange = logMax - logMin;
|
|
const percentage =
|
|
logRange === 0
|
|
? 100
|
|
: Math.max(3, Math.min(100, ((logValue - logMin) / logRange) * 100));
|
|
return { ...c, percentage };
|
|
});
|
|
}, [conversions]);
|
|
|
|
const calculateValueFromPercentage = useCallback(
|
|
(percentage: number, minValue: number, maxValue: number): number => {
|
|
const logMax = Math.log10(maxValue);
|
|
const logMin = minValue > 0 ? Math.log10(minValue) : logMax - 6;
|
|
return Math.pow(10, logMin + (percentage / 100) * (logMax - logMin));
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleMouseDown = useCallback(
|
|
(e: React.MouseEvent, unit: string, currentPercentage: number, barElement: HTMLDivElement) => {
|
|
if (!onValueChange) return;
|
|
e.preventDefault();
|
|
setDraggingUnit(unit);
|
|
setDraggedPercentage(currentPercentage);
|
|
dragStartX.current = e.clientX;
|
|
dragStartWidth.current = currentPercentage;
|
|
activeBarRef.current = barElement;
|
|
baseConversionsRef.current = [...conversions];
|
|
},
|
|
[onValueChange, conversions]
|
|
);
|
|
|
|
const handleMouseMove = useCallback(
|
|
(e: MouseEvent) => {
|
|
if (!draggingUnit || !activeBarRef.current || !onValueChange) return;
|
|
const now = Date.now();
|
|
if (now - lastUpdateTime.current < 16) return;
|
|
lastUpdateTime.current = now;
|
|
const deltaPercentage = ((e.clientX - dragStartX.current) / activeBarRef.current.offsetWidth) * 100;
|
|
const newPercentage = Math.max(3, Math.min(100, dragStartWidth.current + deltaPercentage));
|
|
setDraggedPercentage(newPercentage);
|
|
const base = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
|
|
const vals = base.map((c) => Math.abs(c.value));
|
|
const newValue = calculateValueFromPercentage(newPercentage, Math.min(...vals.filter((v) => v > 0)), Math.max(...vals));
|
|
onValueChange(newValue, draggingUnit, true);
|
|
},
|
|
[draggingUnit, conversions, onValueChange, calculateValueFromPercentage]
|
|
);
|
|
|
|
const handleMouseUp = useCallback(() => {
|
|
if (draggingUnit && onValueChange) {
|
|
const conversion = conversions.find((c) => c.unit === draggingUnit);
|
|
if (conversion) onValueChange(conversion.value, draggingUnit, false);
|
|
}
|
|
setDraggingUnit(null);
|
|
activeBarRef.current = null;
|
|
}, [draggingUnit, conversions, onValueChange]);
|
|
|
|
const handleTouchStart = useCallback(
|
|
(e: React.TouchEvent, unit: string, currentPercentage: number, barElement: HTMLDivElement) => {
|
|
if (!onValueChange) return;
|
|
const touch = e.touches[0];
|
|
setDraggingUnit(unit);
|
|
setDraggedPercentage(currentPercentage);
|
|
dragStartX.current = touch.clientX;
|
|
dragStartWidth.current = currentPercentage;
|
|
activeBarRef.current = barElement;
|
|
baseConversionsRef.current = [...conversions];
|
|
},
|
|
[onValueChange, conversions]
|
|
);
|
|
|
|
const handleTouchMove = useCallback(
|
|
(e: TouchEvent) => {
|
|
if (!draggingUnit || !activeBarRef.current || !onValueChange) return;
|
|
const now = Date.now();
|
|
if (now - lastUpdateTime.current < 16) return;
|
|
lastUpdateTime.current = now;
|
|
e.preventDefault();
|
|
const touch = e.touches[0];
|
|
const deltaPercentage = ((touch.clientX - dragStartX.current) / activeBarRef.current.offsetWidth) * 100;
|
|
const newPercentage = Math.max(3, Math.min(100, dragStartWidth.current + deltaPercentage));
|
|
setDraggedPercentage(newPercentage);
|
|
const base = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
|
|
const vals = base.map((c) => Math.abs(c.value));
|
|
const newValue = calculateValueFromPercentage(newPercentage, Math.min(...vals.filter((v) => v > 0)), Math.max(...vals));
|
|
onValueChange(newValue, draggingUnit, true);
|
|
},
|
|
[draggingUnit, conversions, onValueChange, calculateValueFromPercentage]
|
|
);
|
|
|
|
const handleTouchEnd = useCallback(() => {
|
|
if (draggingUnit && onValueChange) {
|
|
const conversion = conversions.find((c) => c.unit === draggingUnit);
|
|
if (conversion) onValueChange(conversion.value, draggingUnit, false);
|
|
}
|
|
setDraggingUnit(null);
|
|
activeBarRef.current = null;
|
|
}, [draggingUnit, conversions, onValueChange]);
|
|
|
|
useEffect(() => {
|
|
if (draggingUnit) {
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
document.addEventListener('mouseup', handleMouseUp);
|
|
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
document.addEventListener('touchend', handleTouchEnd);
|
|
return () => {
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
|
document.removeEventListener('mouseup', handleMouseUp);
|
|
document.removeEventListener('touchmove', handleTouchMove);
|
|
document.removeEventListener('touchend', handleTouchEnd);
|
|
};
|
|
}
|
|
}, [draggingUnit, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
|
|
|
|
useEffect(() => {
|
|
if (!draggingUnit && draggedPercentage !== null) {
|
|
setDraggedPercentage(null);
|
|
baseConversionsRef.current = [];
|
|
}
|
|
}, [conversions, draggingUnit, draggedPercentage]);
|
|
|
|
if (conversions.length === 0) {
|
|
return (
|
|
<div className="py-10 text-center">
|
|
<p className="text-xs text-muted-foreground/35 font-mono italic">Enter a value to see conversions</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2.5">
|
|
{withPercentages.map((item) => {
|
|
const isDragging = draggingUnit === item.unit;
|
|
const isDraggable = !!onValueChange;
|
|
const displayPercentage = isDragging && draggedPercentage !== null ? draggedPercentage : item.percentage;
|
|
|
|
return (
|
|
<div key={item.unit} className="space-y-1">
|
|
<div className="flex items-baseline justify-between gap-3">
|
|
<span className="text-[10px] text-muted-foreground/60 font-mono truncate">{item.unitInfo.plural}</span>
|
|
<span className="text-xs font-bold tabular-nums font-mono shrink-0 text-foreground/85">
|
|
{formatNumber(item.value)}
|
|
<span className="text-[10px] font-normal text-muted-foreground/50 ml-1">{item.unit}</span>
|
|
</span>
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
'w-full h-5 rounded-md overflow-hidden relative',
|
|
'bg-primary/6 border border-border/25',
|
|
isDraggable && 'cursor-grab active:cursor-grabbing',
|
|
isDragging && 'ring-1 ring-primary/40'
|
|
)}
|
|
onMouseDown={(e) => {
|
|
if (isDraggable && e.currentTarget instanceof HTMLDivElement)
|
|
handleMouseDown(e, item.unit, item.percentage, e.currentTarget);
|
|
}}
|
|
onTouchStart={(e) => {
|
|
if (isDraggable && e.currentTarget instanceof HTMLDivElement)
|
|
handleTouchStart(e, item.unit, item.percentage, e.currentTarget);
|
|
}}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'absolute inset-y-0 left-0 rounded-sm',
|
|
draggingUnit ? 'transition-none' : 'transition-all duration-500 ease-out'
|
|
)}
|
|
style={{
|
|
width: `${displayPercentage}%`,
|
|
background: 'linear-gradient(to right, hsl(var(--primary) / 0.35), hsl(var(--primary) / 0.75))',
|
|
}}
|
|
/>
|
|
{isDraggable && !isDragging && (
|
|
<div className="absolute inset-0 flex items-center justify-end px-2 opacity-0 hover:opacity-100 transition-opacity">
|
|
<span className="text-[9px] font-mono text-muted-foreground/40">drag</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|