refactor: refactor units tool to match calculate blueprint

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>
This commit is contained in:
2026-03-01 07:57:29 +01:00
parent 141ab1f4e3
commit 7eb28851b7
3 changed files with 377 additions and 400 deletions

View File

@@ -9,10 +9,7 @@ interface VisualComparisonProps {
onValueChange?: (value: number, unit: string, dragging: boolean) => void;
}
export default function VisualComparison({
conversions,
onValueChange,
}: VisualComparisonProps) {
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);
@@ -20,197 +17,130 @@ export default function VisualComparison({
const activeBarRef = useRef<HTMLDivElement | null>(null);
const lastUpdateTime = useRef<number>(0);
const baseConversionsRef = useRef<ConversionResult[]>([]);
// Calculate percentages for visual bars using logarithmic scale
const withPercentages = useMemo(() => {
if (conversions.length === 0) return [];
// Use base conversions for scale if we're dragging (keeps scale stable)
const scaleSource = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
// Get all values from the SCALE SOURCE (not current conversions)
const values = scaleSource.map(c => Math.abs(c.value));
const values = scaleSource.map((c) => Math.abs(c.value));
const maxValue = Math.max(...values);
const minValue = Math.min(...values.filter(v => v > 0));
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) => ({ ...c, percentage: 0 }));
}
// Use logarithmic scale for better visualization
return conversions.map(c => {
return conversions.map((c) => {
const absValue = Math.abs(c.value);
if (absValue === 0 || !isFinite(absValue)) {
return { ...c, percentage: 2 }; // Show minimal bar
}
// Logarithmic scale
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; // 6 orders of magnitude range
const logMin = minValue > 0 ? Math.log10(minValue) : logMax - 6;
const logRange = logMax - logMin;
let percentage: number;
if (logRange === 0) {
percentage = 100;
} else {
percentage = ((logValue - logMin) / logRange) * 100;
// Ensure bars are visible - minimum 3%, maximum 100%
percentage = Math.max(3, Math.min(100, percentage));
}
return {
...c,
percentage,
};
const percentage =
logRange === 0
? 100
: Math.max(3, Math.min(100, ((logValue - logMin) / logRange) * 100));
return { ...c, percentage };
});
}, [conversions]);
// Calculate value from percentage (reverse logarithmic scale)
const calculateValueFromPercentage = useCallback((
percentage: number,
minValue: number,
maxValue: number
): number => {
const logMax = Math.log10(maxValue);
const logMin = minValue > 0 ? Math.log10(minValue) : logMax - 6;
const logRange = logMax - logMin;
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));
},
[]
);
// Convert percentage back to log value
const logValue = logMin + (percentage / 100) * logRange;
// Convert log value back to actual value
return Math.pow(10, logValue);
}, []);
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]
);
// Mouse drag handlers
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;
// Save the current conversions as reference
baseConversionsRef.current = [...conversions];
}, [onValueChange, conversions]);
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!draggingUnit || !activeBarRef.current || !onValueChange) return;
// Throttle updates to every 16ms (~60fps)
const now = Date.now();
if (now - lastUpdateTime.current < 16) return;
lastUpdateTime.current = now;
const barWidth = activeBarRef.current.offsetWidth;
const deltaX = e.clientX - dragStartX.current;
const deltaPercentage = (deltaX / barWidth) * 100;
let newPercentage = dragStartWidth.current + deltaPercentage;
newPercentage = Math.max(3, Math.min(100, newPercentage));
// Update visual percentage immediately
setDraggedPercentage(newPercentage);
// Use the base conversions (from when drag started) for scale calculation
const baseConversions = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
// Calculate min/max values for the scale from BASE conversions
const values = baseConversions.map(c => Math.abs(c.value));
const maxValue = Math.max(...values);
const minValue = Math.min(...values.filter(v => v > 0));
// Calculate new value from percentage
const newValue = calculateValueFromPercentage(newPercentage, minValue, maxValue);
onValueChange(newValue, draggingUnit, true); // true = currently dragging
}, [draggingUnit, conversions, onValueChange, calculateValueFromPercentage]);
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) {
// Find the current value for the dragged unit
const conversion = conversions.find(c => c.unit === draggingUnit);
if (conversion) {
onValueChange(conversion.value, draggingUnit, false); // false = drag ended
}
const conversion = conversions.find((c) => c.unit === draggingUnit);
if (conversion) onValueChange(conversion.value, draggingUnit, false);
}
setDraggingUnit(null);
// Don't clear draggedPercentage yet - let it clear when conversions update
activeBarRef.current = null;
// baseConversionsRef cleared after conversions update
}, [draggingUnit, conversions, onValueChange]);
// Touch drag handlers
const handleTouchStart = useCallback((e: React.TouchEvent, unit: string, currentPercentage: number, barElement: HTMLDivElement) => {
if (!onValueChange) return;
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 touch = e.touches[0];
setDraggingUnit(unit);
setDraggedPercentage(currentPercentage);
dragStartX.current = touch.clientX;
dragStartWidth.current = currentPercentage;
activeBarRef.current = barElement;
// Save the current conversions as reference
baseConversionsRef.current = [...conversions];
}, [onValueChange, conversions]);
const handleTouchMove = useCallback((e: TouchEvent) => {
if (!draggingUnit || !activeBarRef.current || !onValueChange) return;
// Throttle updates to every 16ms (~60fps)
const now = Date.now();
if (now - lastUpdateTime.current < 16) return;
lastUpdateTime.current = now;
e.preventDefault(); // Prevent scrolling while dragging
const touch = e.touches[0];
const barWidth = activeBarRef.current.offsetWidth;
const deltaX = touch.clientX - dragStartX.current;
const deltaPercentage = (deltaX / barWidth) * 100;
let newPercentage = dragStartWidth.current + deltaPercentage;
newPercentage = Math.max(3, Math.min(100, newPercentage));
// Update visual percentage immediately
setDraggedPercentage(newPercentage);
// Use the base conversions (from when drag started) for scale calculation
const baseConversions = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
const values = baseConversions.map(c => Math.abs(c.value));
const maxValue = Math.max(...values);
const minValue = Math.min(...values.filter(v => v > 0));
const newValue = calculateValueFromPercentage(newPercentage, minValue, maxValue);
onValueChange(newValue, draggingUnit, true); // true = currently dragging
}, [draggingUnit, conversions, onValueChange, calculateValueFromPercentage]);
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) {
// Find the current value for the dragged unit
const conversion = conversions.find(c => c.unit === draggingUnit);
if (conversion) {
onValueChange(conversion.value, draggingUnit, false); // false = drag ended
}
const conversion = conversions.find((c) => c.unit === draggingUnit);
if (conversion) onValueChange(conversion.value, draggingUnit, false);
}
setDraggingUnit(null);
// Don't clear draggedPercentage yet - let it clear when conversions update
activeBarRef.current = null;
// baseConversionsRef cleared after conversions update
}, [draggingUnit, conversions, onValueChange]);
// Add/remove global event listeners for drag
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);
@@ -220,10 +150,8 @@ export default function VisualComparison({
}
}, [draggingUnit, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
// Clear drag state when conversions update after drag ends
useEffect(() => {
if (!draggingUnit && draggedPercentage !== null) {
// Drag has ended, conversions have updated, now clear visual state
setDraggedPercentage(null);
baseConversionsRef.current = [];
}
@@ -231,75 +159,57 @@ export default function VisualComparison({
if (conversions.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
Enter a value to see conversions
<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-3">
{withPercentages.map(item => {
<div className="space-y-2.5">
{withPercentages.map((item) => {
const isDragging = draggingUnit === item.unit;
const isDraggable = !!onValueChange;
// Use draggedPercentage if this bar is being dragged
const displayPercentage = isDragging && draggedPercentage !== null ? draggedPercentage : item.percentage;
return (
<div key={item.unit} className="space-y-1.5">
<div className="flex items-baseline justify-between gap-4">
<span className="text-sm font-medium text-foreground min-w-0 flex-shrink">
{item.unitInfo.plural}
</span>
<span className="text-lg font-bold tabular-nums flex-shrink-0">
<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-sm font-normal text-muted-foreground ml-1">
{item.unit}
</span>
<span className="text-[10px] font-normal text-muted-foreground/50 ml-1">{item.unit}</span>
</span>
</div>
{/* Progress bar */}
<div
className={cn(
"w-full h-8 bg-muted rounded-lg overflow-hidden border border-border relative",
"transition-all duration-200",
isDraggable && "cursor-grab active:cursor-grabbing",
isDragging && "ring-2 ring-ring ring-offset-2 ring-offset-background scale-105"
'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) {
if (isDraggable && e.currentTarget instanceof HTMLDivElement)
handleMouseDown(e, item.unit, item.percentage, e.currentTarget);
}
}}
onTouchStart={(e) => {
if (isDraggable && e.currentTarget instanceof HTMLDivElement) {
if (isDraggable && e.currentTarget instanceof HTMLDivElement)
handleTouchStart(e, item.unit, item.percentage, e.currentTarget);
}
}}
>
{/* Colored fill */}
<div
className={cn(
"absolute inset-y-0 left-0 bg-primary",
draggingUnit ? "transition-none" : "transition-all duration-500 ease-out"
'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))',
}}
/>
{/* Percentage label overlay */}
<div className="absolute inset-0 flex items-center px-3 text-xs font-bold pointer-events-none">
<span className="text-foreground drop-shadow-sm">
{Math.round(displayPercentage)}%
</span>
</div>
{/* Drag hint on hover */}
{isDraggable && !isDragging && (
<div className="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity bg-background/10 backdrop-blur-[1px]">
<span className="text-xs font-semibold text-foreground drop-shadow-md">
Drag to adjust
</span>
<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>