From b24bd78ddd24c0afff8a34a06629cd4e014f5af6 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 23 Sep 2026 20:18:46 +0700 Subject: [PATCH] web: draw the picture's own distribution behind the tone curve, and let the card be dragged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docker/frontend/src/App.tsx | 3 + docker/frontend/src/styles/app.css | 6 +- docker/frontend/src/ui/ToneCurvePanel.tsx | 146 +++++++++++++++++++++- 3 files changed, 151 insertions(+), 4 deletions(-) diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 0151fef..186e38d 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -2789,6 +2789,9 @@ export function Workspace() { openParam === 'curve' ? ( 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('rgb'); + const [hist, setHist] = useState(null); + const [pos, setPos] = useState<{ x: number; y: number } | null>(null); + const [dragging, setDragging] = useState(false); const svgRef = useRef(null); + const boxRef = useRef(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) => { + 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) => { + 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) => { + 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) => { 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 ( -
e.stopPropagation()}> -
+
e.stopPropagation()} + > +
TONE CURVE