feat: add draggable bars for interactive value adjustment
Implement innovative drag-to-adjust interaction in visual comparison view: Visual feedback: - Cursor changes to grab/grabbing when draggable - Active bar scales up and shows ring focus indicator - Hover overlay displays "Drag to adjust" hint - Smooth transitions when not dragging, instant updates while dragging Drag mechanics: - Mouse drag support for desktop - Touch drag support for mobile devices - Logarithmic scale conversion preserves intuitive feel - Clamped percentage range (3-100%) prevents invalid values - Dragging updates input value and selected unit in real-time Technical implementation: - Added onValueChange callback to VisualComparison component - Reverse logarithmic calculation converts drag position to value - Global event listeners for smooth drag-outside-element tracking - Prevents scrolling during touch drag on mobile - MainConverter integrates drag callback to update state This creates a highly tactile, visual way to adjust conversion values by directly manipulating the bar chart representation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -123,6 +123,12 @@ export default function MainConverter() {
|
||||
setSelectedUnit(record.from.unit);
|
||||
}, []);
|
||||
|
||||
// Handle value change from draggable bars
|
||||
const handleValueChange = useCallback((value: number, unit: string) => {
|
||||
setInputValue(value.toString());
|
||||
setSelectedUnit(unit);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto space-y-6">
|
||||
{/* Command Palette */}
|
||||
@@ -261,6 +267,7 @@ export default function MainConverter() {
|
||||
<VisualComparison
|
||||
conversions={conversions}
|
||||
color={getCategoryColorHex(selectedMeasure)}
|
||||
onValueChange={handleValueChange}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { type ConversionResult } from '@/lib/units';
|
||||
import { formatNumber, cn } from '@/lib/utils';
|
||||
|
||||
interface VisualComparisonProps {
|
||||
conversions: ConversionResult[];
|
||||
color: string;
|
||||
onValueChange?: (value: number, unit: string) => void;
|
||||
}
|
||||
|
||||
export default function VisualComparison({
|
||||
conversions,
|
||||
color,
|
||||
onValueChange,
|
||||
}: VisualComparisonProps) {
|
||||
const [draggingUnit, setDraggingUnit] = useState<string | null>(null);
|
||||
const dragStartX = useRef<number>(0);
|
||||
const dragStartWidth = useRef<number>(0);
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
// Calculate percentages for visual bars using logarithmic scale
|
||||
const withPercentages = useMemo(() => {
|
||||
if (conversions.length === 0) return [];
|
||||
@@ -57,6 +63,116 @@ export default function VisualComparison({
|
||||
});
|
||||
}, [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;
|
||||
|
||||
// Convert percentage back to log value
|
||||
const logValue = logMin + (percentage / 100) * logRange;
|
||||
// Convert log value back to actual value
|
||||
return Math.pow(10, logValue);
|
||||
}, []);
|
||||
|
||||
// Mouse drag handlers
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent, unit: string, currentPercentage: number) => {
|
||||
if (!onValueChange) return;
|
||||
|
||||
e.preventDefault();
|
||||
setDraggingUnit(unit);
|
||||
dragStartX.current = e.clientX;
|
||||
dragStartWidth.current = currentPercentage;
|
||||
}, [onValueChange]);
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (!draggingUnit || !barRef.current || !onValueChange) return;
|
||||
|
||||
const barWidth = barRef.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));
|
||||
|
||||
// Find the conversion result for this unit
|
||||
const conversion = conversions.find(c => c.unit === draggingUnit);
|
||||
if (!conversion) return;
|
||||
|
||||
// Calculate min/max values for the scale
|
||||
const values = conversions.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);
|
||||
}, [draggingUnit, conversions, onValueChange, calculateValueFromPercentage]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setDraggingUnit(null);
|
||||
}, []);
|
||||
|
||||
// Touch drag handlers
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent, unit: string, currentPercentage: number) => {
|
||||
if (!onValueChange) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
setDraggingUnit(unit);
|
||||
dragStartX.current = touch.clientX;
|
||||
dragStartWidth.current = currentPercentage;
|
||||
}, [onValueChange]);
|
||||
|
||||
const handleTouchMove = useCallback((e: TouchEvent) => {
|
||||
if (!draggingUnit || !barRef.current || !onValueChange) return;
|
||||
|
||||
e.preventDefault(); // Prevent scrolling while dragging
|
||||
const touch = e.touches[0];
|
||||
const barWidth = barRef.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));
|
||||
|
||||
const conversion = conversions.find(c => c.unit === draggingUnit);
|
||||
if (!conversion) return;
|
||||
|
||||
const values = conversions.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);
|
||||
}, [draggingUnit, conversions, onValueChange, calculateValueFromPercentage]);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
setDraggingUnit(null);
|
||||
}, []);
|
||||
|
||||
// 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);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
}
|
||||
}, [draggingUnit, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
|
||||
|
||||
if (conversions.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
@@ -67,46 +183,73 @@ export default function VisualComparison({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{withPercentages.map(item => (
|
||||
<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">
|
||||
{formatNumber(item.value)}
|
||||
<span className="text-sm font-normal text-muted-foreground ml-1">
|
||||
{item.unit}
|
||||
{withPercentages.map(item => {
|
||||
const isDragging = draggingUnit === item.unit;
|
||||
const isDraggable = !!onValueChange;
|
||||
|
||||
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>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="w-full h-8 bg-muted rounded-lg overflow-hidden border border-border relative">
|
||||
{/* Colored fill */}
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 transition-all duration-500 ease-out"
|
||||
style={{
|
||||
width: `${item.percentage}%`,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
{/* Percentage label overlay */}
|
||||
<div className="absolute inset-0 flex items-center justify-between px-3 text-xs font-bold pointer-events-none">
|
||||
<span className="text-foreground drop-shadow-sm">
|
||||
{Math.round(item.percentage)}%
|
||||
</span>
|
||||
<span
|
||||
className="tabular-nums drop-shadow-sm"
|
||||
style={{
|
||||
color: item.percentage > 30 ? 'white' : 'var(--foreground)',
|
||||
}}
|
||||
>
|
||||
{item.percentage > 30 && formatNumber(item.value)}
|
||||
<span className="text-lg font-bold tabular-nums flex-shrink-0">
|
||||
{formatNumber(item.value)}
|
||||
<span className="text-sm font-normal text-muted-foreground ml-1">
|
||||
{item.unit}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div
|
||||
ref={barRef}
|
||||
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"
|
||||
)}
|
||||
onMouseDown={(e) => isDraggable && handleMouseDown(e, item.unit, item.percentage)}
|
||||
onTouchStart={(e) => isDraggable && handleTouchStart(e, item.unit, item.percentage)}
|
||||
>
|
||||
{/* Colored fill */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0",
|
||||
isDragging ? "transition-none" : "transition-all duration-500 ease-out"
|
||||
)}
|
||||
style={{
|
||||
width: `${item.percentage}%`,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
{/* Percentage label overlay */}
|
||||
<div className="absolute inset-0 flex items-center justify-between px-3 text-xs font-bold pointer-events-none">
|
||||
<span className="text-foreground drop-shadow-sm">
|
||||
{Math.round(item.percentage)}%
|
||||
</span>
|
||||
<span
|
||||
className="tabular-nums drop-shadow-sm"
|
||||
style={{
|
||||
color: item.percentage > 30 ? 'white' : 'var(--foreground)',
|
||||
}}
|
||||
>
|
||||
{item.percentage > 30 && formatNumber(item.value)}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user