src/components/ControlPanel.tsx 6.6 K raw
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
}