web: draw the picture's own distribution behind the tone curve, and let the card be dragged
Setting a point on the curve was guesswork: the graph showed the mapping but
nothing about the picture it was mapping, so you placed a point where the tones
"probably" were. The graph now draws the picture's own histogram behind the
grid, and the panel can be dragged off the photo it is editing — the two halves
of the same complaint, that the card was describing a picture you could not look
at while you used it.
The histogram is not a second measurement. `ToneCurvePanel` takes the same
`previewUrl` the stage already renders and reads it through `readHistogram`, the
function the HISTOGRAM overlay beside it uses: one 320px sample, luminance bins
on the RGB tab and the channel's own bins on an R, G or B tab, so the shape
follows the tab the way the line does. The bins become one filled path in the
graph's own square, scaled to its own tallest bucket and closed along the floor,
and it is the SVG's first child — grid and curve draw over it, so the graph
reads as curve on distribution rather than two lines crossing. Nothing new is
rendered, sampled or cached: the panel reads the frame that is already there.
It is read from the render, which is post-curve, so the band shifts as the curve
moves. That is Lightroom's behaviour, not an accident, and it is the honest one:
the point of the picture is what you are looking at. A percentile or log scale
would show a shadow-heavy frame better than a linear max does, and the overlay
beside it does not have one either, so the two agree.
The drag is the panel's own head. `pos` is the card's position in the layer's
coordinates (null until first moved), and the first position is materialised
from `offsetLeft/offsetTop`, which is exactly the CSS bottom-left the card sits
at before anyone touches it — so the default layout costs no code and the card
carries no second positioning system. It is bounded by the STAGE, not the photo:
the card may sit off the photo, that is the point of moving it, but never off
the canvas the stage clips at 8px. Window `resize` and a `ResizeObserver` on the
stage re-clamp an existing position, because the stage can shrink under a parked
card and `overflow: hidden` would hide it with no way to reach it.
One real bug, found by the probe rather than by reading: with the head as the
handle, `setPointerCapture` retargets the click that follows, so the close
button in that same head never fired — pressing it started a drag and swallowed
the click. `panStart` now returns early when the pointer went down on a button.
The pre-existing `Histogram` overlay carries the same latent pattern; it has no
interactive children in its chrome, so it was left alone.
Verified:
tone-curve-probe.cjs (extended, scratchpad) — the rebuilt app at
http://localhost:8090, 42 PASS, 0 FAIL, no page errors. New checks: the
graph draws the picture's own distribution and it is the graph's first child
(`curve-hist`); the drawn band matches a histogram binned independently in
the page (256 buckets, worst deviation 0.00px); the distribution piles where
the curve put the tones (peak 128/255 after the black lift, against 9-246
before it); the card is dragged by its head (729,280 -> 689,190, the exact
delta); the drag bends no curve and drops no point; the card cannot be
dragged out of the stage (clamped to stage bounds); it is pulled back in
when the viewport shrinks to 900x640 (card 636,239 240x291 inside stage
269,109 615x429); the close button still takes the graph off the photo.
tone-curve-math.cjs — unchanged, 11/11.
Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10;
backend npm test 180 passed, 0 failed.
web tsc --noEmit clean.
ponytail: the histogram is read from the render, so it is post-curve and the
band moves with the curve; read it pre-curve by exposing pass 3e's input if the
feedback ever misleads. The card's position is component state, so it resets to
bottom-left when the panel closes — persisting it across a close is a key on the
recipe, add it when someone asks for the card to stay put. Percentile and log
scaling are not implemented: linear max, the same as the overlay beside it.
This commit is contained in:
@@ -2789,6 +2789,9 @@ export function Workspace() {
|
||||
openParam === 'curve' ? (
|
||||
<ToneCurvePanel
|
||||
curve={recipe.adjustments.toneCurve}
|
||||
// The distribution behind the graph is the render's own, so it
|
||||
// is the same picture the curve is being judged against.
|
||||
url={previewUrl}
|
||||
// One undo step per visit to the graph, the same rule the ruler
|
||||
// and the watermark box ride: a drag is one edit, not one per
|
||||
// pointer move. RESET hands back an empty object, and that has
|
||||
|
||||
@@ -344,7 +344,8 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
box-shadow: var(--shadow);
|
||||
user-select: none;
|
||||
}
|
||||
.curve-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.curve-head { display: flex; align-items: center; justify-content: space-between; cursor: grab; }
|
||||
.curve-panel.dragging .curve-head { cursor: grabbing; }
|
||||
.curve-title { font-size: 11px; letter-spacing: 0.1em; color: var(--muted); }
|
||||
.curve-close {
|
||||
border: none;
|
||||
@@ -392,6 +393,9 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
}
|
||||
.curve-reset:hover { color: var(--fg); border-color: var(--accent-line); }
|
||||
.curve-graph { display: block; background: rgba(0, 0, 0, 0.35); border: 1px solid var(--border); border-radius: 3px; cursor: crosshair; }
|
||||
/* The picture's own distribution, the floor the graph is read against. Light on
|
||||
both themes because the graph itself is always the dark box over a photo. */
|
||||
.curve-hist { fill: rgba(255, 255, 255, 0.22); }
|
||||
.curve-grid { stroke: rgba(255, 255, 255, 0.1); stroke-width: 1; }
|
||||
.curve-diagonal { stroke: rgba(255, 255, 255, 0.28); stroke-width: 1; stroke-dasharray: 3 3; }
|
||||
/* The line the active tab draws, and — on a channel tab — the composite curve
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ToneCurve, ToneCurveChannel, ToneCurvePoint } from '../../shared/types';
|
||||
import { readHistogram, type Histogram } from './Histogram';
|
||||
import {
|
||||
CURVE_CHANNELS,
|
||||
CURVE_CHANNEL_COLOR,
|
||||
@@ -20,6 +21,11 @@ import {
|
||||
// A click on the line drops a point on it, a drag moves one, and a double-click
|
||||
// drops one — the three gestures Lightroom teaches, so nothing here has to be
|
||||
// explained in words on the photo.
|
||||
//
|
||||
// The picture's own distribution is drawn behind the line, so a point can be
|
||||
// placed where the tones actually are rather than where the eye guesses, and the
|
||||
// card is dragged by its head, so it can be moved off the part of the photo the
|
||||
// curve is being judged against.
|
||||
const SIZE = 224; // the graph's own square, in px
|
||||
const STEPS = 64; // how finely the line is drawn
|
||||
const HIT = 11; // px within which the pointer takes hold of a point
|
||||
@@ -32,16 +38,43 @@ const offDiagonal = (pts: ToneCurvePoint[]) => pts.some((p) => Math.abs(p[1] - p
|
||||
|
||||
export function ToneCurvePanel({
|
||||
curve,
|
||||
url,
|
||||
onChange,
|
||||
onClose,
|
||||
}: {
|
||||
curve: ToneCurve | undefined;
|
||||
url: string | null;
|
||||
onChange: (next: ToneCurve) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [channel, setChannel] = useState<ToneCurveChannel>('rgb');
|
||||
const [hist, setHist] = useState<Histogram | null>(null);
|
||||
const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const drag = useRef<{ id: number; at: number } | null>(null);
|
||||
const pan = useRef<{ px: number; py: number; x: number; y: number } | null>(null);
|
||||
|
||||
// The distribution behind the graph is read off the render the user is looking
|
||||
// at — the same sample the HISTOGRAM overlay reads — so a tone that is clipped
|
||||
// on screen is a tone that has run off the end of the graph, and the two
|
||||
// frames can never disagree about what the picture holds.
|
||||
useEffect(() => {
|
||||
if (!url) {
|
||||
setHist(null);
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
readHistogram(url, () => live)
|
||||
.then((h) => {
|
||||
if (live && h) setHist(h);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
const pts = curvePoints(curve, channel);
|
||||
const master = curvePoints(curve, 'rgb');
|
||||
@@ -70,6 +103,92 @@ export function ToneCurvePanel({
|
||||
return out.join(' ');
|
||||
};
|
||||
|
||||
// The distribution, in the graph's own square: one bar per input value, the
|
||||
// tallest bucket touching the top, closed along the floor so it reads as the
|
||||
// picture's shape behind the line rather than a stroke across it. Scaled to its
|
||||
// own tallest bucket, so the shape is the same whether the picture is a
|
||||
// thousand pixels or ten million.
|
||||
const histPath = (bins: number[]) => {
|
||||
const max = Math.max(1, ...bins);
|
||||
const step = SIZE / (bins.length - 1);
|
||||
let d = '';
|
||||
for (let i = 0; i < bins.length; i++) {
|
||||
d += `${i ? 'L' : 'M'}${(i * step).toFixed(2)} ${(SIZE - (bins[i] / max) * SIZE).toFixed(2)} `;
|
||||
}
|
||||
return `${d}L${SIZE} ${SIZE} L0 ${SIZE} Z`;
|
||||
};
|
||||
|
||||
// The card is dragged by its head, and what it is dragged inside is the STAGE
|
||||
// the photo sits on, not the photo: the whole point of moving it is to get it
|
||||
// off the part of the picture being judged. The stage is the one box that does
|
||||
// not move when the photo is re-fitted under it, so the card is parked in the
|
||||
// layer's own coordinates — it rides with the photo — and only the bounds come
|
||||
// from the stage.
|
||||
const clampPos = (x: number, y: number) => {
|
||||
const box = boxRef.current;
|
||||
const layer = box?.parentElement;
|
||||
const stage = box?.closest('.canvas-wrap') as HTMLElement | null;
|
||||
if (!box || !layer || !stage) return { x, y };
|
||||
const l = layer.getBoundingClientRect();
|
||||
const s = stage.getBoundingClientRect();
|
||||
const b = box.getBoundingClientRect();
|
||||
const lim = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), Math.max(lo, hi));
|
||||
return {
|
||||
x: lim(x, s.left - l.left + 8, s.right - l.left - b.width - 8),
|
||||
y: lim(y, s.top - l.top + 8, s.bottom - l.top - b.height - 8),
|
||||
};
|
||||
};
|
||||
|
||||
// A stage that shrinks — the window, or a column opening beside it — can leave
|
||||
// the card outside its own bounds, where the stage's overflow would hide it and
|
||||
// there would be no grabbing it back. So it is pulled inside as that box moves.
|
||||
const parked = pos !== null;
|
||||
useEffect(() => {
|
||||
if (!parked) return;
|
||||
const keep = () => setPos((p) => (p ? clampPos(p.x, p.y) : p));
|
||||
window.addEventListener('resize', keep);
|
||||
const stage = boxRef.current?.closest('.canvas-wrap');
|
||||
const ro = stage ? new ResizeObserver(keep) : null;
|
||||
if (stage) ro?.observe(stage);
|
||||
return () => {
|
||||
window.removeEventListener('resize', keep);
|
||||
ro?.disconnect();
|
||||
};
|
||||
}, [parked]);
|
||||
|
||||
const panStart = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
// The close button lives in the head and the head is the drag handle, so a
|
||||
// press on it has to be left alone: capturing the pointer here would retarget
|
||||
// the click that follows to the head, and the button would never hear it.
|
||||
if ((e.target as Element).closest('button')) return;
|
||||
const box = boxRef.current;
|
||||
if (!box) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
// The card's first position is the one its stylesheet parked it at; from
|
||||
// then on it is the drag's own, in the layer's coordinates.
|
||||
const from = pos ?? { x: box.offsetLeft, y: box.offsetTop };
|
||||
pan.current = { px: e.clientX, py: e.clientY, x: from.x, y: from.y };
|
||||
setPos(from);
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
const panMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const d = pan.current;
|
||||
if (!d) return;
|
||||
e.preventDefault();
|
||||
setPos(clampPos(d.x + (e.clientX - d.px), d.y + (e.clientY - d.py)));
|
||||
};
|
||||
|
||||
const panEnd = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!pan.current) return;
|
||||
pan.current = null;
|
||||
setDragging(false);
|
||||
if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const down = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation(); // the photo must not pan under the graph
|
||||
@@ -111,10 +230,27 @@ export function ToneCurvePanel({
|
||||
};
|
||||
|
||||
const grid = [0, 1, 2, 3, 4].map((i) => (i * SIZE) / 4);
|
||||
// What the graph is drawn against: the luminance of the picture on the grey
|
||||
// channel, and that channel's own distribution when a single channel is being
|
||||
// bent — the histogram follows the tab, the way Lightroom's does.
|
||||
const bins = hist ? (channel === 'rgb' ? hist.lum : hist[channel]) : null;
|
||||
|
||||
return (
|
||||
<div className="curve-panel" data-key="curve-panel" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<div className="curve-head">
|
||||
<div
|
||||
className={`curve-panel${dragging ? ' dragging' : ''}`}
|
||||
data-key="curve-panel"
|
||||
ref={boxRef}
|
||||
style={pos ? { left: pos.x, top: pos.y, bottom: 'auto' } : undefined}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="curve-head"
|
||||
data-key="curve-drag"
|
||||
onPointerDown={panStart}
|
||||
onPointerMove={panMove}
|
||||
onPointerUp={panEnd}
|
||||
onPointerCancel={panEnd}
|
||||
>
|
||||
<span className="curve-title">TONE CURVE</span>
|
||||
<button type="button" className="curve-close" data-key="curve-close" aria-label="close" onClick={onClose}>
|
||||
✕
|
||||
@@ -184,6 +320,10 @@ export function ToneCurvePanel({
|
||||
onChange(put(removeCurvePoint(pts, near)));
|
||||
}}
|
||||
>
|
||||
{/* The picture's own distribution, under everything: the grid and the
|
||||
line are read against it, and a point placed on a peak is a point
|
||||
placed where the tones are. */}
|
||||
{bins ? <path className="curve-hist" data-key="curve-hist" d={histPath(bins)} /> : null}
|
||||
{grid.map((g) => (
|
||||
<line key={`v${g}`} className="curve-grid" x1={g} y1={0} x2={g} y2={SIZE} />
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user