Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,62 @@ describe('WorkflowBlockBorder mount', () => {
expect(path?.getAttribute('d')?.length ?? 0).toBeGreaterThan(0)
})

/**
* The knob is a recolour of one stretch of the outline, so its commands have
* to BE the outline's commands. When the two were generated off different
* sample grids they disagreed by a fraction of a pixel at the shoulder tip,
* and the sliver of dark stroke the knob failed to cover read as a barb
* hanging off it.
*
* Only holds where a knob's own bulge is the tallest thing under it: a knob
* is painted from its own feature alone, while the silhouette takes the max
* over all of them, so two bulges tall enough to overlap genuinely part
* company. Every port layout the editor builds keeps them clear of one
* another.
*/
const expectKnobsOnSilhouette = (host: HTMLElement) => {
const silhouette = host.querySelector('svg > path')?.getAttribute('d') ?? ''
const knobs = Array.from(host.querySelectorAll('svg > g[clip-path] path'))
expect(knobs.length).toBeGreaterThan(0)

for (const knob of knobs) {
const commands = (knob.getAttribute('d') ?? '').match(/C[^MLAC]+/g) ?? []
expect(commands.length).toBeGreaterThan(0)
for (const command of commands) {
expect(silhouette).toContain(command.trim())
}
}
}

it('paints every coloured knob on the silhouette’s own curve', () => {
const { host } = mount(
<div style={{ width: 250, height: 136 }}>
<WorkflowBlockBorder ports={ports} hasRing={false} ringStyles='' height={136} />
</div>
)
expectKnobsOnSilhouette(host)
})

it('keeps knobs on the curve when their resampled intervals merge', () => {
/* Two coloured ports close enough that `relativeIntervals` merges them
resample as one stretch, on a grid neither port's own bounds predict.
The odd tab length is the other half of the same trap: a knob measured
in whole pixels rather than against the interval it sits in lands half a
step off whenever the bulge does not divide evenly. */
Comment thread
waleedlatif1 marked this conversation as resolved.
const crowdedPorts: WorkflowBorderPort[] = [
{ id: 'target', side: 'left', position: 'center', plateau: 33 },
{ id: 'row-a', side: 'right', position: 60, plateau: 24, color: 'var(--brand-accent)' },
{ id: 'row-b', side: 'right', position: 87.5, plateau: 24, color: 'var(--text-error)' },
{ id: 'row-c', side: 'right', position: 140, plateau: 23, color: 'var(--warning)' },
]
const { host } = mount(
<div style={{ width: 250, height: 260 }}>
<WorkflowBlockBorder ports={crowdedPorts} hasRing={false} ringStyles='' height={260} />
</div>
)
expectKnobsOnSilhouette(host)
})

it('paints a tall selector card across floating-point segment seams', () => {
const selectorPorts: WorkflowBorderPort[] = [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,18 @@ const findQuietStart = (intervals: ActiveInterval[], perimeterLength: number) =>
return largestGap > 0 ? start : 0
}

/** Flat run resampled either side of a bulge, so its tail rejoins the edge. */
const BULGE_INTERVAL_SLACK_PX = 4

/**
* How far either side of a bulge's centre the outline is resampled — wider than
* the bulge itself, so the curve has flat perimeter to settle onto. It also
* fixes the sample grid a knob has to land on, which is why `visibleBulgeHalf`
* measures against it.
*/
const bulgeIntervalHalf = (plateau: number, shoulder: number) =>
plateau / 2 + shoulder + BULGE_INTERVAL_SLACK_PX

const relativeIntervals = (features: BulgeFeature[], startS: number, perimeterLength: number) => {
const intervals = features
.filter(
Expand All @@ -614,7 +626,7 @@ const relativeIntervals = (features: BulgeFeature[], startS: number, perimeterLe
)
.map((feature) => {
const center = modulo(feature.center - startS, perimeterLength)
const half = feature.plateau / 2 + feature.shoulder + 4
const half = bulgeIntervalHalf(feature.plateau, feature.shoulder)
return { start: center - half, end: center + half }
})
.filter((interval) => interval.end > 0 && interval.start < perimeterLength)
Expand Down Expand Up @@ -671,19 +683,32 @@ const displacementAt = (
* Where a bulge stops being drawn, by inverting the shoulder's easing at the
* visibility threshold. The mathematical footprint (`plateau/2 + shoulder`)
* overshoots this, because the tail is cut off once it flattens out.
*
* Pulled back to the silhouette's own sample points. `relativeIntervals`
* resamples a bulge over `bulgeIntervalHalf` either side of its centre, split
* into whole steps of about `SAMPLE_SPACING_PX` — so the crossing itself falls
* between two of them. A knob cut there sits on a grid of its own and drifts
* off the curve it is recolouring; see `buildSpanPath` for what that costs.
* Retreating to the last sample at or beyond the crossing keeps the knob on
* the silhouette's points whatever the bulge measures.
*/
const visibleBulgeHalf = (plateau: number, shoulder: number, peak: number) => {
const plateauHalf = plateau / 2
if (peak <= BULGE_VISIBLE_THRESHOLD_PX || shoulder <= 0) return plateauHalf
const target = 1 - BULGE_VISIBLE_THRESHOLD_PX / peak
let low = 0
let high = 1
for (let step = 0; step < 24; step++) {
const mid = (low + high) / 2
if (smootherstep(mid) < target) low = mid
else high = mid
let crossing = plateauHalf
if (peak > BULGE_VISIBLE_THRESHOLD_PX && shoulder > 0) {
const target = 1 - BULGE_VISIBLE_THRESHOLD_PX / peak
let low = 0
let high = 1
for (let step = 0; step < 24; step++) {
const mid = (low + high) / 2
if (smootherstep(mid) < target) low = mid
else high = mid
}
crossing = plateauHalf + high * shoulder
}
return plateauHalf + high * shoulder
const intervalHalf = bulgeIntervalHalf(plateau, shoulder)
const step = (intervalHalf * 2) / Math.max(2, Math.ceil((intervalHalf * 2) / SAMPLE_SPACING_PX))
return intervalHalf - Math.floor((intervalHalf - crossing) / step) * step
}

const appendExactInterval = (
Expand Down Expand Up @@ -764,6 +789,7 @@ const appendActiveInterval = (
`C${control1.x.toFixed(2)} ${control1.y.toFixed(2)} ${control2.x.toFixed(2)} ${control2.y.toFixed(2)} ${next.x.toFixed(2)} ${next.y.toFixed(2)}`
)
}
return points
}

/**
Expand All @@ -777,6 +803,16 @@ const appendActiveInterval = (
* off its own knob, leaving a crescent of base colour showing inside it. Giving
* the knob its own path removes the arc-length bookkeeping altogether: the
* colour is drawn on the same points the silhouette was.
*
* Sampled a step wide on each side and then trimmed back to the span. Every
* control point is derived from the samples either side of it, so a span that
* stopped at its own ends would have to clamp its first and last to the bare
* perimeter tangent — and those two segments would bow differently from the
* outline they are painted over. The knob was then still flat where the
* silhouette had begun its descent, and the uncovered dark stroke read as a
* barb off the shoulder tip. Borrowing a sample beyond each end gives every
* emitted segment the neighbours the silhouette had, so the two agree command
* for command.
*/
const buildSpanPath = (
geometry: PerimeterGeometry,
Expand All @@ -787,18 +823,13 @@ const buildSpanPath = (
) => {
const length = toS - fromS
if (length <= 0) return ''
const located = pointAtArcLength(geometry, fromS)
const displacement = displacementAt(
modulo(fromS, geometry.length),
features,
geometry.length,
maximum
)
const originX = located.point.x + located.point.nx * displacement
const originY = located.point.y + located.point.ny * displacement
const commands = [`M${originX.toFixed(2)} ${originY.toFixed(2)}`]
appendActiveInterval(commands, geometry, features, maximum, fromS, { start: 0, end: length })
return commands.join(' ')
const commands: string[] = []
const points = appendActiveInterval(commands, geometry, features, maximum, fromS, {
start: -SAMPLE_SPACING_PX,
end: length + SAMPLE_SPACING_PX,
})
const origin = points[1]
return [`M${origin.x.toFixed(2)} ${origin.y.toFixed(2)}`, ...commands.slice(1, -1)].join(' ')
}

const buildPiecewisePath = (
Expand Down
Loading