| 1 | import { useState, useEffect, useRef, useCallback } from "react"; |
| 2 | import type { BasicFilters, CurveChannel, CurvePoint, FilterState } from "../lib/types"; |
| 3 | import { DEFAULT_BASIC, DEFAULT_CURVES } from "../lib/types"; |
| 4 | import { SliderGroup } from "./SliderGroup"; |
| 5 | import { CurvesEditor } from "./CurvesEditor"; |
| 6 | |
| 7 | const SLIDER_RANGES: Record<keyof BasicFilters, [number, number]> = { |
| 8 | brightness: [0, 200], |
| 9 | contrast: [0, 200], |
| 10 | exposure: [-100, 100], |
| 11 | saturation: [0, 200], |
| 12 | temperature: [-100, 100], |
| 13 | tint: [-100, 100], |
| 14 | highlights: [-100, 100], |
| 15 | shadows: [-100, 100], |
| 16 | }; |
| 17 | |
| 18 | function randBetween(min: number, max: number) { |
| 19 | return Math.round(min + Math.random() * (max - min)); |
| 20 | } |
| 21 | |
| 22 | function randomCurve(): CurvePoint[] { |
| 23 | const numMid = 1 + Math.floor(Math.random() * 3); |
| 24 | const points: CurvePoint[] = [{ x: 0, y: randBetween(0, 60) }]; |
| 25 | for (let i = 0; i < numMid; i++) { |
| 26 | const x = Math.round(((i + 1) / (numMid + 1)) * 255); |
| 27 | points.push({ x, y: randBetween(30, 225) }); |
| 28 | } |
| 29 | points.push({ x: 255, y: randBetween(195, 255) }); |
| 30 | return points; |
| 31 | } |
| 32 | |
| 33 | function generateRandomState(): FilterState { |
| 34 | const basic = {} as BasicFilters; |
| 35 | for (const key of Object.keys(SLIDER_RANGES) as (keyof BasicFilters)[]) { |
| 36 | const [min, max] = SLIDER_RANGES[key]; |
| 37 | basic[key] = randBetween(min, max); |
| 38 | } |
| 39 | return { |
| 40 | basic, |
| 41 | curves: { |
| 42 | rgb: randomCurve(), |
| 43 | r: randomCurve(), |
| 44 | g: randomCurve(), |
| 45 | b: randomCurve(), |
| 46 | }, |
| 47 | }; |
| 48 | } |
| 49 | |
| 50 | function lerpNum(a: number, b: number, t: number) { |
| 51 | return a + (b - a) * t; |
| 52 | } |
| 53 | |
| 54 | function lerpBasic(a: BasicFilters, b: BasicFilters, t: number): BasicFilters { |
| 55 | const result = {} as BasicFilters; |
| 56 | for (const key of Object.keys(a) as (keyof BasicFilters)[]) { |
| 57 | result[key] = Math.round(lerpNum(a[key], b[key], t)); |
| 58 | } |
| 59 | return result; |
| 60 | } |
| 61 | |
| 62 | function lerpCurve(a: CurvePoint[], b: CurvePoint[], t: number): CurvePoint[] { |
| 63 | // Resample both curves to a common set of x points for smooth interpolation |
| 64 | const xs = new Set<number>(); |
| 65 | a.forEach((p) => xs.add(p.x)); |
| 66 | b.forEach((p) => xs.add(p.x)); |
| 67 | const sortedX = Array.from(xs).sort((a, b) => a - b); |
| 68 | |
| 69 | return sortedX.map((x) => ({ |
| 70 | x, |
| 71 | y: Math.round(lerpNum(sampleCurve(a, x), sampleCurve(b, x), t)), |
| 72 | })); |
| 73 | } |
| 74 | |
| 75 | function sampleCurve(points: CurvePoint[], x: number): number { |
| 76 | if (x <= points[0].x) return points[0].y; |
| 77 | if (x >= points[points.length - 1].x) return points[points.length - 1].y; |
| 78 | for (let i = 0; i < points.length - 1; i++) { |
| 79 | if (x >= points[i].x && x <= points[i + 1].x) { |
| 80 | const t = (x - points[i].x) / (points[i + 1].x - points[i].x); |
| 81 | return lerpNum(points[i].y, points[i + 1].y, t); |
| 82 | } |
| 83 | } |
| 84 | return points[points.length - 1].y; |
| 85 | } |
| 86 | |
| 87 | function lerpState(a: FilterState, b: FilterState, t: number): FilterState { |
| 88 | return { |
| 89 | basic: lerpBasic(a.basic, b.basic, t), |
| 90 | curves: { |
| 91 | rgb: lerpCurve(a.curves.rgb, b.curves.rgb, t), |
| 92 | r: lerpCurve(a.curves.r, b.curves.r, t), |
| 93 | g: lerpCurve(a.curves.g, b.curves.g, t), |
| 94 | b: lerpCurve(a.curves.b, b.curves.b, t), |
| 95 | }, |
| 96 | }; |
| 97 | } |
| 98 | |
| 99 | interface ControlPanelProps { |
| 100 | filterState: FilterState; |
| 101 | onBasicChange: (key: keyof BasicFilters, value: number) => void; |
| 102 | onCurveChange: (channel: CurveChannel, points: CurvePoint[]) => void; |
| 103 | onSetAll: (state: FilterState) => void; |
| 104 | onReset: () => void; |
| 105 | } |
| 106 | |
| 107 | export function ControlPanel({ filterState, onBasicChange, onCurveChange, onSetAll, onReset }: ControlPanelProps) { |
| 108 | const [open, setOpen] = useState(false); |
| 109 | const [fluid, setFluid] = useState(false); |
| 110 | |
| 111 | const fluidRef = useRef(false); |
| 112 | const rafRef = useRef<number>(0); |
| 113 | const fromRef = useRef<FilterState>(filterState); |
| 114 | const toRef = useRef<FilterState>(generateRandomState()); |
| 115 | const progressRef = useRef(0); |
| 116 | const onSetAllRef = useRef(onSetAll); |
| 117 | onSetAllRef.current = onSetAll; |
| 118 | |
| 119 | const startFluid = useCallback(() => { |
| 120 | fluidRef.current = true; |
| 121 | fromRef.current = filterState; |
| 122 | toRef.current = generateRandomState(); |
| 123 | progressRef.current = 0; |
| 124 | |
| 125 | const LERP_SPEED = 0.008; |
| 126 | |
| 127 | const tick = () => { |
| 128 | if (!fluidRef.current) return; |
| 129 | progressRef.current += LERP_SPEED; |
| 130 | if (progressRef.current >= 1) { |
| 131 | fromRef.current = toRef.current; |
| 132 | toRef.current = generateRandomState(); |
| 133 | progressRef.current = 0; |
| 134 | } |
| 135 | // Smooth easing |
| 136 | const t = progressRef.current * progressRef.current * (3 - 2 * progressRef.current); |
| 137 | const interpolated = lerpState(fromRef.current, toRef.current, t); |
| 138 | onSetAllRef.current(interpolated); |
| 139 | rafRef.current = requestAnimationFrame(tick); |
| 140 | }; |
| 141 | rafRef.current = requestAnimationFrame(tick); |
| 142 | }, [filterState]); |
| 143 | |
| 144 | const stopFluid = useCallback(() => { |
| 145 | fluidRef.current = false; |
| 146 | if (rafRef.current) cancelAnimationFrame(rafRef.current); |
| 147 | }, []); |
| 148 | |
| 149 | useEffect(() => { |
| 150 | return () => { |
| 151 | if (rafRef.current) cancelAnimationFrame(rafRef.current); |
| 152 | }; |
| 153 | }, []); |
| 154 | |
| 155 | const handleFluidToggle = () => { |
| 156 | if (fluid) { |
| 157 | stopFluid(); |
| 158 | setFluid(false); |
| 159 | } else { |
| 160 | setFluid(true); |
| 161 | startFluid(); |
| 162 | } |
| 163 | }; |
| 164 | |
| 165 | const handleRandomize = () => { |
| 166 | if (fluid) { |
| 167 | stopFluid(); |
| 168 | setFluid(false); |
| 169 | } |
| 170 | onSetAll(generateRandomState()); |
| 171 | }; |
| 172 | |
| 173 | const hasChanges = |
| 174 | JSON.stringify(filterState.basic) !== JSON.stringify(DEFAULT_BASIC) || |
| 175 | JSON.stringify(filterState.curves) !== JSON.stringify(DEFAULT_CURVES); |
| 176 | |
| 177 | return ( |
| 178 | <div className={`control-panel ${open ? "open" : ""}`}> |
| 179 | <button className="panel-toggle" onClick={() => setOpen(!open)}> |
| 180 | {open ? "\u203A" : "\u2039"} |
| 181 | </button> |
| 182 | <div className="panel-content"> |
| 183 | <div className="panel-header"> |
| 184 | <span className="panel-title">CONTROLS</span> |
| 185 | <div className="panel-header-actions"> |
| 186 | {hasChanges && ( |
| 187 | <button className="reset-btn" onClick={onReset}> |
| 188 | RESET |
| 189 | </button> |
| 190 | )} |
| 191 | </div> |
| 192 | </div> |
| 193 | |
| 194 | <div className="randomize-section"> |
| 195 | <button className="randomize-btn" onClick={handleRandomize}> |
| 196 | RANDOMIZE |
| 197 | </button> |
| 198 | <button |
| 199 | className={`fluid-toggle ${fluid ? "active" : ""}`} |
| 200 | onClick={handleFluidToggle} |
| 201 | > |
| 202 | FLUID |
| 203 | </button> |
| 204 | </div> |
| 205 | |
| 206 | <div className="panel-section"> |
| 207 | <span className="section-label">ADJUSTMENTS</span> |
| 208 | <SliderGroup filters={filterState.basic} onChange={onBasicChange} /> |
| 209 | </div> |
| 210 | |
| 211 | <div className="panel-section"> |
| 212 | <span className="section-label">CURVES</span> |
| 213 | <CurvesEditor curves={filterState.curves} onChange={onCurveChange} /> |
| 214 | </div> |
| 215 | </div> |
| 216 | </div> |
| 217 | ); |
| 218 | } |