fix: Tidy says Tidy, and takes the shake out when pressed
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:
parent
7c1689ca7f
commit
6312bf7a00
4 changed files with 73 additions and 19 deletions
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]])
|
||||
|
|
|
|||
Loading…
Reference in a new issue