fix: Tidy says Tidy, and takes the shake out when pressed
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Successful in 35s
Tests / e2e (push) Failing after 37s

It was a bare ⌁, 30px square, wedged between the kind and the remove
cross in a 360px panel — which also squeezed the label box down to four
characters. Nobody could be expected to recognise it, and the answer to
"where is Tidy" was "you are looking at it". The row is two lines now:
the shape and its two buttons, then the label across the full width, and
the button says Tidy.

And pressing it does something. smoothOut ran Ramer–Douglas–Peucker and
stopped: simplification drops the points that carry no shape, but it
never moves the ones it keeps, so a stroke that wandered by more than
epsilon came back wandering by exactly as much. A relaxing pass follows
it now — a moving average over the interior, twice — so the shake goes
and the route stays. The two ends never move: where somebody put the pen
down and lifted it is the deliberate part of a freehand mark.

Still offered rather than applied, per shape and undoable. A traced
anatomical edge is meant to wander.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-13 04:50:19 +02:00
parent 7c1689ca7f
commit 6312bf7a00
4 changed files with 73 additions and 19 deletions

View file

@ -77,14 +77,15 @@
.ovl-empty { margin: 0; color: #9aa3b2; line-height: 1.45; }
.ovl-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.ovl-list li {
display: flex; align-items: center; gap: 8px;
padding: 4px 4px 4px 10px; border: 1px solid #262c35; border-radius: 8px;
display: flex; flex-direction: column; gap: 6px;
padding: 6px; border: 1px solid #262c35; border-radius: 8px;
}
.ovl-shape-row { display: flex; align-items: center; gap: 8px; }
.ovl-list li.is-active { border-color: #5eead4; }
.ovl-dot { flex: none; width: 12px; height: 12px; border-radius: 50%; }
.ovl-kind { flex: none; min-width: 5.5em; }
.ovl-kind { flex: 1; min-width: 0; }
.ovl-label {
flex: 1 1 60px; min-width: 0; min-height: 40px; padding: 0 8px;
width: 100%; box-sizing: border-box; min-height: 40px; padding: 0 8px;
font: inherit; color: inherit; background: #0f1216;
border: 1px solid #343b47; border-radius: 6px;
}
@ -126,8 +127,10 @@
is meant to wander, and straightening one silently would correct the finding
instead of the drawing. */
.ovl-tidy {
flex: none; width: 30px; height: 30px; padding: 0; cursor: pointer;
flex: none; display: inline-flex; align-items: center; gap: 5px;
height: 30px; padding: 0 10px; cursor: pointer;
border: 1px solid #2c3444; border-radius: 8px;
background: transparent; color: #cbd5e1; font-size: 1rem; line-height: 1;
background: transparent; color: #cbd5e1;
font: inherit; font-size: .82rem; font-weight: 600; line-height: 1;
}
.ovl-tidy:hover { border-color: #5eead4; color: #5eead4; }

View file

@ -397,8 +397,28 @@ export default function OverlayEditor({ src, overlay, onSave, onClose }) {
onFocus={() => setActiveId(s.id)}
onBlur={() => setActiveId(null)}
>
<span className="ovl-dot" style={{ background: s.color }} aria-hidden="true" />
<span className="ovl-kind">{name}</span>
{/* Two lines rather than one. All four sat in a row in a
360px panel, which squeezed the label box down to
four characters and left Tidy as a bare nobody
could be expected to recognise a button that may
as well not have been there. */}
<div className="ovl-shape-row">
<span className="ovl-dot" style={{ background: s.color }} aria-hidden="true" />
<span className="ovl-kind">{name}</span>
{(s.kind === 'path' || s.kind === 'rect' || s.kind === 'ellipse') && (
<button type="button" className="ovl-tidy"
title="Straighten this, or take the shake out of it"
/* Named per shape: four rows all reading "Tidy" is
four buttons a screen reader cannot tell apart. */
aria-label={`Tidy ${lower} ${i + 1}`}
onClick={() => tidy(s.id)}>
<span aria-hidden="true"></span> Tidy
</button>
)}
<button type="button" className="ovl-remove" aria-label={`Remove ${lower} ${i + 1}`} onClick={() => remove(s.id)}>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18" /></svg>
</button>
</div>
<input
type="text"
className="ovl-label"
@ -407,16 +427,6 @@ export default function OverlayEditor({ src, overlay, onSave, onClose }) {
aria-label={`Label for ${lower} ${i + 1}`}
onChange={(e) => relabel(s.id, e.target.value)}
/>
{(s.kind === 'path' || s.kind === 'rect' || s.kind === 'ellipse') && (
<button type="button" className="ovl-tidy"
title="Straighten this, or take the shake out of it"
aria-label={`Tidy ${lower} ${i + 1}`} onClick={() => tidy(s.id)}>
</button>
)}
<button type="button" className="ovl-remove" aria-label={`Remove ${lower} ${i + 1}`} onClick={() => remove(s.id)}>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18" /></svg>
</button>
</li>
)
})}

View file

@ -117,5 +117,31 @@ export default function tidyShape(shape, { tolerance = 0.015 } = {}) {
export function smoothOut(shape, epsilon = 0.004) {
const points = (shape?.points || []).filter(p => Array.isArray(p) && p.length === 2)
if (points.length < 3) return shape
return { ...shape, points: simplify(points, epsilon).map(p => p.map(round)) }
// Two passes, because they do different jobs. Simplification drops points
// that carry no shape, which is sampling noise; it does not touch the ones
// it keeps, so a stroke that wandered by more than epsilon came back
// wandering by exactly as much and pressing Tidy looked like it had done
// nothing at all. Relaxing afterwards pulls each remaining point a little
// towards its neighbours — the shake goes, the route stays.
return { ...shape, points: relax(simplify(points, epsilon)).map(p => p.map(round)) }
}
/** A moving average over the interior, twice. The ends never move: they are
* where somebody put the pen down and lifted it, which is the one part of a
* freehand mark that is deliberate. */
export function relax(points, passes = 2) {
let out = points
for (let pass = 0; pass < passes; pass += 1) {
if (out.length < 3) return out
const next = [out[0]]
for (let i = 1; i < out.length - 1; i += 1) {
next.push([
(out[i - 1][0] + out[i][0] * 2 + out[i + 1][0]) / 4,
(out[i - 1][1] + out[i][1] * 2 + out[i + 1][1]) / 4,
])
}
next.push(out[out.length - 1])
out = next
}
return out
}

View file

@ -43,6 +43,21 @@ describe('tidying a hand-drawn mark', () => {
expect(tidied.points.at(-1)[0]).toBeCloseTo(points.at(-1)[0], 3)
})
it('takes the shake out of a stroke without moving where it went', () => {
// Simplification alone left a shaky trace shaking: it drops points, it
// does not move the ones it keeps, so Tidy on a wobble that is bigger than
// epsilon looked like a button that did nothing.
const shaky = Array.from({ length: 21 }, (_, i) => (
[0.2 + i * 0.03, 0.5 + (i % 2 ? 0.012 : -0.012)]))
const tidied = smoothOut(path(shaky))
const swing = ys => Math.max(...ys) - Math.min(...ys)
expect(swing(tidied.points.slice(1, -1).map(p => p[1])))
.toBeLessThan(swing(shaky.slice(1, -1).map(p => p[1])) / 2)
// Where the pen went down and came up is deliberate, and stays.
expect(tidied.points[0]).toEqual(shaky[0])
expect(tidied.points.at(-1)).toEqual(shaky.at(-1))
})
it('keeps the corners a simplification is for', () => {
const elbow = [[0, 0], [0.25, 0], [0.5, 0], [0.5, 0.25], [0.5, 0.5]]
expect(simplify(elbow, 0.004)).toEqual([[0, 0], [0.5, 0], [0.5, 0.5]])