diff --git a/components/units/MainConverter.tsx b/components/units/MainConverter.tsx index b542749..d7c042b 100644 --- a/components/units/MainConverter.tsx +++ b/components/units/MainConverter.tsx @@ -1,11 +1,7 @@ 'use client'; import { useState, useEffect, useCallback } from 'react'; -import { ArrowLeftRight, BarChart3 } from 'lucide-react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Button } from '@/components/ui/button'; +import { ArrowLeftRight, BarChart3, Grid3X3 } from 'lucide-react'; import { Select, SelectContent, @@ -15,7 +11,6 @@ import { } from '@/components/ui/select'; import SearchUnits from './SearchUnits'; import VisualComparison from './VisualComparison'; - import { getAllMeasures, getUnitsForMeasure, @@ -27,211 +22,313 @@ import { } from '@/lib/units/units'; import { parseNumberInput, formatNumber, cn } from '@/lib/utils'; +type Tab = 'category' | 'convert'; + +const CATEGORY_ICONS: Partial> = { + length: 'πŸ“', mass: 'βš–οΈ', temperature: '🌑️', speed: '⚑', time: '⏱️', + area: '⬛', volume: '🧊', digital: 'πŸ’Ύ', energy: '⚑', pressure: 'πŸ”΅', + power: 'πŸ”†', frequency: '〰️', angle: 'πŸ“', current: '⚑', voltage: 'πŸ”Œ', +}; + export default function MainConverter() { const [selectedMeasure, setSelectedMeasure] = useState('length'); const [selectedUnit, setSelectedUnit] = useState('m'); const [targetUnit, setTargetUnit] = useState('ft'); const [inputValue, setInputValue] = useState('1'); const [conversions, setConversions] = useState([]); - const [showVisualComparison, setShowVisualComparison] = useState(false); - const [isDragging, setIsDragging] = useState(false); + const [showChart, setShowChart] = useState(false); + const [tab, setTab] = useState('category'); const measures = getAllMeasures(); const units = getUnitsForMeasure(selectedMeasure); - // Update conversions when input changes useEffect(() => { const numValue = parseNumberInput(inputValue); if (numValue !== null && selectedUnit) { - const results = convertToAll(numValue, selectedUnit); - setConversions(results); + setConversions(convertToAll(numValue, selectedUnit)); } else { setConversions([]); } }, [inputValue, selectedUnit]); - // Update selected unit when measure changes useEffect(() => { const availableUnits = getUnitsForMeasure(selectedMeasure); if (availableUnits.length > 0) { setSelectedUnit(availableUnits[0]); - setTargetUnit(availableUnits[1] || availableUnits[0]); + setTargetUnit(availableUnits[1] ?? availableUnits[0]); } }, [selectedMeasure]); - // Swap units const handleSwapUnits = useCallback(() => { - const temp = selectedUnit; - setSelectedUnit(targetUnit); - setTargetUnit(temp); - - // Convert the value const numValue = parseNumberInput(inputValue); if (numValue !== null) { - const converted = convertUnit(numValue, selectedUnit, targetUnit); - setInputValue(converted.toString()); + setInputValue(convertUnit(numValue, selectedUnit, targetUnit).toString()); } + setSelectedUnit(targetUnit); + setTargetUnit(selectedUnit); }, [selectedUnit, targetUnit, inputValue]); - // Handle search selection const handleSearchSelect = useCallback((unit: string, measure: Measure) => { setSelectedMeasure(measure); setSelectedUnit(unit); + setTab('convert'); }, []); - // Handle value change from draggable bars - const handleValueChange = useCallback((value: number, unit: string, dragging: boolean) => { - setIsDragging(dragging); + const handleCategorySelect = useCallback((measure: Measure) => { + setSelectedMeasure(measure); + setTab('convert'); + }, []); - // Convert the dragged unit's value back to the currently selected unit - // This keeps the source unit stable while updating the value - const convertedValue = convertUnit(value, unit, selectedUnit); - setInputValue(convertedValue.toString()); - // Keep selectedUnit unchanged - }, [selectedUnit]); + const handleValueChange = useCallback( + (value: number, unit: string, _dragging: boolean) => { + setInputValue(convertUnit(value, unit, selectedUnit).toString()); + }, + [selectedUnit] + ); + + const resultValue = (() => { + const n = parseNumberInput(inputValue); + return n !== null ? convertUnit(n, selectedUnit, targetUnit) : null; + })(); return ( -
+
- {/* Quick Access Row */} - - -
+ {/* ── Mobile tab switcher ────────────────────────────────── */} +
+ {(['category', 'convert'] as Tab[]).map((t) => ( + + ))} +
+ + {/* ── Main layout ────────────────────────────────────────── */} +
+ + {/* Left panel: search + categories */} +
+ {/* Search */} +
+ + Search +
-
- -
- - - {/* Main Converter Card */} - - - Convert {formatMeasureName(selectedMeasure)} - - -
-
- - +
+ + Categories + + + {measures.length} + +
+ +
+ {measures.map((measure) => { + const isSelected = selectedMeasure === measure; + const unitCount = getUnitsForMeasure(measure).length; + return ( + + ); + })} +
+
+
+ + {/* Right panel: converter + results */} +
+ {/* Converter card */} +
+ + Convert {formatMeasureName(selectedMeasure)} + + + {/* Input row */} +
+ {/* Value input */} + setInputValue(e.target.value)} - placeholder="Enter value" - className={cn("text-lg", "w-full", "max-w-full")} + placeholder="0" + className="flex-1 min-w-0 bg-transparent border border-border/40 rounded-lg px-3 py-2 text-sm font-mono outline-none focus:border-primary/50 transition-colors placeholder:text-muted-foreground/30 tabular-nums" /> -
-
- - + + {units.map((unit) => ( - - {unit} - - ))} - - -
- -
- - + + {/* Swap */} + + + {/* To unit */} +
+ + {/* Result display */} + {resultValue !== null && ( +
+
Result
+
+ + {formatNumber(resultValue)} + + {targetUnit} +
+
+ )}
- {parseNumberInput(inputValue) !== null && ( -
-
Result
-
- {formatNumber(convertUnit(parseNumberInput(inputValue)!, selectedUnit, targetUnit))} {targetUnit} + {/* All conversions */} +
+
+ + All Conversions + + {/* Grid / Chart toggle */} +
+ +
- )} - - - {/* Results */} - - -
- All Conversions - -
-
- - {showVisualComparison ? ( - - ) : ( -
- {conversions.map((conversion) => ( -
-
{conversion.unitInfo.plural}
-
{formatNumber(conversion.value)}
-
{conversion.unit}
+
+ {showChart ? ( + + ) : ( +
+ {conversions.map((conversion) => { + const isTarget = targetUnit === conversion.unit; + return ( + + ); + })}
- ))} + )}
- )} - - +
+
+
); } diff --git a/components/units/SearchUnits.tsx b/components/units/SearchUnits.tsx index 56de033..f310224 100644 --- a/components/units/SearchUnits.tsx +++ b/components/units/SearchUnits.tsx @@ -3,8 +3,6 @@ import { useState, useEffect, useRef } from 'react'; import { Search, X } from 'lucide-react'; import Fuse from 'fuse.js'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; import { getAllMeasures, getUnitsForMeasure, @@ -31,30 +29,17 @@ export default function SearchUnits({ onSelectUnit, className }: SearchUnitsProp const [isOpen, setIsOpen] = useState(false); const inputRef = useRef(null); const containerRef = useRef(null); - - // Build search index const searchIndex = useRef | null>(null); useEffect(() => { - // Build comprehensive search data const allData: SearchResult[] = []; const measures = getAllMeasures(); - for (const measure of measures) { - const units = getUnitsForMeasure(measure); - - for (const unit of units) { + for (const unit of getUnitsForMeasure(measure)) { const unitInfo = getUnitInfo(unit); - if (unitInfo) { - allData.push({ - unitInfo, - measure, - }); - } + if (unitInfo) allData.push({ unitInfo, measure }); } } - - // Initialize Fuse.js for fuzzy search searchIndex.current = new Fuse(allData, { keys: [ { name: 'unitInfo.abbr', weight: 2 }, @@ -67,30 +52,22 @@ export default function SearchUnits({ onSelectUnit, className }: SearchUnitsProp }); }, []); - // Perform search useEffect(() => { if (!query.trim() || !searchIndex.current) { setResults([]); setIsOpen(false); return; } - - const searchResults = searchIndex.current.search(query); - setResults(searchResults.map(r => r.item).slice(0, 10)); + setResults(searchIndex.current.search(query).map((r) => r.item).slice(0, 10)); setIsOpen(true); }, [query]); - // Handle click outside useEffect(() => { function handleClickOutside(event: MouseEvent) { - if ( - containerRef.current && - !containerRef.current.contains(event.target as Node) - ) { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { setIsOpen(false); } } - document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); @@ -102,67 +79,60 @@ export default function SearchUnits({ onSelectUnit, className }: SearchUnitsProp inputRef.current?.blur(); }; - const clearSearch = () => { - setQuery(''); - setIsOpen(false); - }; - return ( -
+
- - + setQuery(e.target.value)} onFocus={() => query && setIsOpen(true)} - className="pl-10 pr-10" + className="w-full bg-transparent border border-border/40 rounded-lg pl-8 pr-7 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors placeholder:text-muted-foreground/30" /> {query && ( - + + )}
- {/* Results dropdown */} {isOpen && results.length > 0 && ( -
+
{results.map((result, index) => ( ))}
)} {isOpen && query && results.length === 0 && ( -
- No units found for "{query}" +
+

No units found for "{query}"

)}
diff --git a/components/units/VisualComparison.tsx b/components/units/VisualComparison.tsx index 0ab646e..b18f1b2 100644 --- a/components/units/VisualComparison.tsx +++ b/components/units/VisualComparison.tsx @@ -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(null); const [draggedPercentage, setDraggedPercentage] = useState(null); const dragStartX = useRef(0); @@ -20,197 +17,130 @@ export default function VisualComparison({ const activeBarRef = useRef(null); const lastUpdateTime = useRef(0); const baseConversionsRef = useRef([]); - // 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 ( -
- Enter a value to see conversions +
+

Enter a value to see conversions

); } return ( -
- {withPercentages.map(item => { +
+ {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 ( -
-
- - {item.unitInfo.plural} - - +
+
+ {item.unitInfo.plural} + {formatNumber(item.value)} - - {item.unit} - + {item.unit}
- {/* Progress bar */}
{ - 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 */}
- {/* Percentage label overlay */} -
- - {Math.round(displayPercentage)}% - -
- - {/* Drag hint on hover */} {isDraggable && !isDragging && ( -
- - Drag to adjust - +
+ drag
)}