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
395 changes: 395 additions & 0 deletions electron/src/renderer/src/components/DriftWizard.tsx

Large diffs are not rendered by default.

67 changes: 62 additions & 5 deletions electron/src/renderer/src/components/FloatingToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ import { StrainWizard } from './StrainWizard'
import { CropWizard } from './CropWizard'
import { FitWizard } from './FitWizard'
import { BackgroundWizard } from './BackgroundWizard'
import { DriftWizard } from './DriftWizard'

const WIZARD_ACTIONS = new Set([
'Orientation Mapping', 'Find Diffraction Vectors', 'Vector Orientation Mapping',
'EBSD Indexing',
'Center Zero Beam', 'Strain Mapping', 'Crop', 'Fit', 'Remove Background',
'Drift Correction',
])

/**
Expand Down Expand Up @@ -118,6 +120,9 @@ export function FloatingToolbar({
// caret's absolute positioning) used only to measure the caret's real size.
const caretWrapRef = React.useRef<HTMLDivElement>(null)
const caretBox = React.useRef<{ w: number; h: number } | null>(null)
/** The measured caret width, mirrored into state so the side-placement clamp
* re-runs when a caret changes width without changing placement. */
const [caretW, setCaretW] = React.useState(240)
const live = state.activeActions.get(windowId) ?? EMPTY

// Keep the toolbar shown while a popout/caret is open or an action is live —
Expand All @@ -133,7 +138,10 @@ export function FloatingToolbar({
// room again.
const wr = winRect ?? { x: 0, y: 0, w: 0, h: 0 }
const area = areaSize ?? { w: 100000, h: 100000 }
React.useLayoutEffect(() => {
// Held in a ref so the ResizeObserver below always runs the LATEST closure —
// `wr`/`area` change on every window move and resize.
const place = React.useRef<() => void>(() => {})
place.current = () => {
if (!openName) return
const el = caretWrapRef.current?.firstElementChild as HTMLElement | null
if (el) {
Expand All @@ -148,7 +156,33 @@ export function FloatingToolbar({
next = wr.x + wr.w + CARET_GAP + cw <= area.w ? 'right' : 'left'
}
setPlacement(p => (p === next ? p : next))
})
// The WIDTH has to be state, not just the ref: the side placements clamp
// with it (see `caretPos`), and the ref is written in a layout effect. If
// the placement itself does not change there is no re-render, so the
// clamp would keep using the previous caret's width — which for a caret
// that widens on a disclosure is exactly the case that needs it.
setCaretW(w => (w === cw ? w : cw))
}
React.useLayoutEffect(() => { place.current() })

// A caret's height can change WITHOUT this component re-rendering: a wizard's
// disclosure (Drift's `▸ Advanced`, a growing result note) is the WIZARD's
// own React state, and a child's state update does not re-render its parent.
// The layout effect above then never re-runs, the placement stays stale, and
// the caret finally JUMPS on some unrelated later render.
//
// That is not merely cosmetic: a jump that lands between a mousedown and a
// mouseup means the two land on DIFFERENT elements, so the browser emits no
// `click` at all. The control takes focus and silently does nothing, and the
// next click works — "every other click is ignored". Observing the caret's own
// box is what makes the placement track its content.
React.useLayoutEffect(() => {
const el = caretWrapRef.current?.firstElementChild as HTMLElement | null
if (!el || typeof ResizeObserver === 'undefined') return
const ro = new ResizeObserver(() => place.current())
ro.observe(el)
return () => ro.disconnect()
}, [openName])

React.useEffect(() => {
if (!openName) return
Expand Down Expand Up @@ -217,12 +251,29 @@ export function FloatingToolbar({
// Where the bar's TOP edge sits in window coords — carets are DOM children of
// the bar, so the side placements are expressed relative to it.
const barTopInWin = inside ? wr.h - BAR_H - BAR_GAP : wr.h + BAR_GAP
// Where a SIDE-placed caret's left edge wants to be, in MDI-area coords, then
// CLAMPED into the area. `left` anchors the caret's RIGHT edge to the
// window's left edge, so a caret wider than the room beside the window simply
// walked off the edge of the app and its controls became unclickable —
// Playwright's "element is outside of the viewport", and for a user a panel
// with its labels sliced off. Overlapping the owning window is recoverable;
// being off-screen is not, so the clamp wins.
//
// When there IS room this is arithmetically identical to the old
// marginLeft/marginRight pair — the clamp is a no-op and nothing moves.
const sideLeft = placement === 'right'
? wr.x + wr.w + CARET_GAP
: wr.x - CARET_GAP - caretW
const clampedLeft = Math.max(0, Math.min(sideLeft, area.w - caretW))
const caretPos: React.CSSProperties =
placement === 'below'
? { position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)', marginTop: CARET_GAP }
: placement === 'right'
? { position: 'absolute', top: -barTopInWin, left: '50%', marginLeft: wr.w / 2 + CARET_GAP, transform: 'none' }
: { position: 'absolute', top: -barTopInWin, right: '50%', marginRight: wr.w / 2 + CARET_GAP, left: 'auto', transform: 'none' }
: {
position: 'absolute', top: -barTopInWin, left: '50%',
// The bar is centred on the window, so `left:50%` is the window's
// midline — walk from there to the clamped absolute position.
marginLeft: clampedLeft - (wr.x + wr.w / 2), transform: 'none',
}

return (
<div
Expand Down Expand Up @@ -327,6 +378,12 @@ export function FloatingToolbar({
onClose={() => setOpenName(null)}
/>
)}
{openAction && openAction.name === 'Drift Correction' && (
<DriftWizard
caretPos={caretPos} windowId={windowId} sendAction={sendAction}
onClose={() => setOpenName(null)}
/>
)}
{openAction && !WIZARD_ACTIONS.has(openAction.name) && hasParams(openAction) && (
<ParamPopout
action={openAction} caretPos={caretPos} below={placement === 'below'}
Expand Down
10 changes: 10 additions & 0 deletions electron/src/renderer/src/kernel/SpyDEContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1566,6 +1566,16 @@ export function SpyDEProvider({ children }: { children: React.ReactNode }) {
// (app-global, not wizard-scoped, but the same re-broadcast fits).
case 'download_progress':
case 'download_done':
// Drift Correction caret (spyde/actions/drift_action.py) — caret state,
// the ROI discovery preview (~20 frames aligned on the box, with its
// sharpening gain), whole-movie solve progress, the streamed dy/dx
// batches, and the solved model. Consumed by DriftWizard; the dy/dx
// curve itself is painted by the backend into its own figure window.
case 'drift_state':
case 'drift_preview':
case 'drift_trace':
case 'drift_progress':
case 'drift_result':
// Cluster telemetry — consumed by the StatusBar DaskMonitor HUD.
case 'dask_stats':
// Read-throughput readout — consumed by the StatusBar IoThroughput HUD.
Expand Down
81 changes: 81 additions & 0 deletions electron/src/renderer/src/kernel/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,82 @@ export interface IoThroughputMessage extends MsgBase {
color: 'green' | 'yellow' | 'red'
}

// ── Drift Correction caret (spyde/actions/drift_action.py, plan A8) ──────────

/** Caret state. `window_id` is the SOURCE plot's window (where the caret
* lives); `check_window_id` is the bare-figure Drift Check window (whole-movie
* before/after sums on top, the ROI discovery pair beneath) and
* `trace_window_id` the bare-figure dy/dx window the solve opens. */
export interface DriftStateMessage extends MsgBase {
type: 'drift_state'
window_id: number | null
check_window_id: number | null
trace_window_id: number | null
/** 'rigid' | 'rigid_affine' — only rigid has a solver. */
method: string
solved: boolean
/** Whether the FULL solve restricts itself to the alignment box. The preview
* always uses the box regardless — the toggle is the commitment. */
use_roi: boolean
/** The alignment box as `[y0, x0, h, w]` in IMAGE PIXELS (what
* `solve_translation(roi=…)` takes), or null when there is no usable box. */
roi: number[] | null
params: Record<string, unknown>
/** Present on the open/ready emissions. */
n_frames?: number
}

/** The discovery preview: ~20 frames sampled across the whole movie, aligned on
* the box alone. `gain` is the gradient energy of the aligned sum over the raw
* sum, measured on the pixels both cover — > 1.5 means a usable landmark,
* <= 1 means aligning there changes nothing. */
export interface DriftPreviewMessage extends MsgBase {
type: 'drift_preview'
window_id: number | null
roi: number[] | null
frames: number
gain: number
max_abs_shift: number
params: Record<string, unknown>
}

/** A batch of solved shifts streamed from `solve_translation`'s `on_shift`
* while the solve runs — `[frame_index, dy, dx]` each. The backend paints
* these into the dy/dx window itself; the message exists so any host can
* follow the curve live. */
export interface DriftTraceMessage extends MsgBase {
type: 'drift_trace'
window_id: number | null
points: [number, number, number][]
}

/** Whole-movie solve progress (also emitted as a plain `progress` message). */
export interface DriftProgressMessage extends MsgBase {
type: 'drift_progress'
window_id: number | null
done: number
total: number
}

/** The solved model. `shifts[i]` is the correction ADDED to frame i, `[dy, dx]`
* in pixels; a cancelled solve leaves NaN rows for frames it never reached. */
export interface DriftResultMessage extends MsgBase {
type: 'drift_result'
window_id: number | null
shifts: [number, number][]
kind: string
reference: string
max_abs_shift: number
/** The box the solve correlated on (`use_roi`), or null for whole-frame. */
roi: number[] | null
/** Whole-movie sharpening: gradient energy of the corrected sum over the raw
* one. The same number the preview reports, now for what actually ran. */
gain: number
/** Frames dropped from the running reference by the outlier rejector. */
rejected: number
cancelled: boolean
}

/**
* Wizard-scoped events re-broadcast verbatim as DOM CustomEvents (the caret
* components subscribe directly). The payload beyond `type` is consumer-defined,
Expand Down Expand Up @@ -984,6 +1060,11 @@ export type PlotAppMessage =
| DownloadDoneMessage
| DaskStatsMessage
| IoThroughputMessage
| DriftStateMessage
| DriftPreviewMessage
| DriftTraceMessage
| DriftProgressMessage
| DriftResultMessage

/**
* Narrow a raw incoming message (`Record<string, unknown>` from the IPC bridge)
Expand Down
161 changes: 161 additions & 0 deletions electron/tests/drift_wizard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* drift_wizard.spec.ts — the Drift Correction caret, end-to-end on the bundled
* synthetic particle movie (whose per-frame drift is ground truth, stamped into
* `metadata.Spyde.synthetic`).
*
* What this proves that tsc + headless tests cannot:
* 1. The caret's default face is SMALL (plan §0.9a) — two toggles, one
* readout, one button, and a collapsed Advanced. The count is asserted,
* not eyeballed, because "too many options" is exactly what regressed.
* 2. The discovery loop is real: a draggable box on the movie, a live
* drift-corrected sum of just that box in the Drift Check window, and a
* sharpness number that MOVES when the box moves.
* 3. The dy/dx curve is its OWN window, opened by the solve and filled while
* it runs — not caret furniture.
* 4. Apply adds the lazy corrected node.
*/
import { test, expect } from '@playwright/test'
import { mkdirSync } from 'fs'
const {
launchApp, backendAction, waitForSubwindowCount, sigWindow, backendErrorLines,
} = require('./_harness.cjs')

const SHOTS = 'drift_wizard_shots'
let ctx: Awaited<ReturnType<typeof launchApp>>

test.describe.configure({ mode: 'serial' })
test.setTimeout(300_000)

/** Every interactive control the caret shows by default. If this list grows,
* §0.9a has been walked back and the review comment applies again. */
const FACE = ['drift-use-roi', 'drift-reject', 'drift-solve', 'drift-advanced-toggle']

test.beforeAll(async () => {
mkdirSync(SHOTS, { recursive: true })
ctx = await launchApp({ dask: true, env: { SPYDE_LOG_LEVEL: 'INFO' } })
const { page } = ctx
await page.waitForTimeout(1500)
// Enough frames that the solve takes about a second — a 12-frame movie
// finishes before a screenshot can catch the dy/dx window mid-fill, which is
// the thing this spec has to see.
await backendAction(page, 'load_test_data_particles', { frames: 40 })
await waitForSubwindowCount(page, 2, 120_000)
await page.waitForTimeout(2000)
})

test.afterAll(async () => {
await ctx?.app?.close()
})

test('the caret opens small: 2 toggles, 1 button, Advanced collapsed', async () => {
const { page } = ctx
const sig = sigWindow(page)
await sig.getByTestId('subwindow-title').click()
await sig.getByTestId('subwindow-titlebar').hover()
await sig.getByTestId('action-btn-Drift Correction').click()
await expect(page.getByTestId('drift-wizard')).toBeVisible()

// The verification surface is a WINDOW, not the caret (plan A8): whole-movie
// raw/corrected sums on top, the ROI discovery pair beneath.
await waitForSubwindowCount(page, 3, 120_000)
// …and the discovery preview lands on its own, with no click at all.
await expect.poll(
async () => await page.getByTestId('drift-roi-readout').getAttribute('data-gain'),
{ timeout: 120_000, message: 'the discovery preview never reported a gain' },
).toBeTruthy()
await page.waitForTimeout(1500)
await page.screenshot({ path: `${SHOTS}/01-caret-open.png` })
await page.getByTestId('drift-wizard').screenshot({ path: `${SHOTS}/02-caret-face.png` })

for (const id of FACE) await expect(page.getByTestId(id)).toBeVisible()
await expect(page.getByTestId('drift-advanced')).toHaveCount(0)
// Everything algorithmic is behind the disclosure.
for (const id of ['drift-reference', 'drift-upsample', 'drift-max-shift',
'drift-order', 'drift-tab-rigid', 'drift-apodize']) {
await expect(page.getByTestId(id)).toHaveCount(0)
}
// Count what a user actually sees: buttons + inputs inside the caret.
const shown = await page.getByTestId('drift-wizard')
.locator('button, input, [data-testid$="-trigger"]').count()
expect(shown, 'controls visible on the default face').toBeLessThanOrEqual(5)

await page.getByTestId('drift-solve').click({ trial: true }) // enabled
ctx.assertNoJsErrors()
})

test('Advanced holds the algorithm; the affine stub stays locked', async () => {
const { page } = ctx
await page.getByTestId('drift-advanced-toggle').click()
await expect(page.getByTestId('drift-advanced')).toBeVisible()
// The locked tab catches a real trap: the backend's _UNAVAILABLE list is
// duplicated in the renderer, so implementing a model there while leaving
// the tab locked here makes a finished feature unreachable — with every
// headless test still green, because none of them can see a disabled tab.
await expect(page.getByTestId('drift-tab-rigid_affine')).toBeDisabled()
await page.getByTestId('drift-wizard').screenshot({ path: `${SHOTS}/03-advanced.png` })

await page.getByTestId('drift-max-shift').fill('24')
await page.getByTestId('drift-max-shift').blur()
await page.getByTestId('drift-advanced-toggle').click()
await expect(page.getByTestId('drift-advanced')).toHaveCount(0)
ctx.assertNoJsErrors()
})

test('dragging the ROI re-solves the preview and moves the sharpness number', async () => {
const { page } = ctx
const sig = sigWindow(page)
const gain = () => page.getByTestId('drift-roi-readout').getAttribute('data-gain')
const before = await gain()
expect(before, 'no gain before the drag').toBeTruthy()

// Drag the box's centre a long way — the widget lives inside the signal
// window's figure iframe, so this is a real pointer drag on real pixels.
const box = await sig.locator('iframe').first().boundingBox()
expect(box).toBeTruthy()
const cx = box!.x + box!.width / 2, cy = box!.y + box!.height / 2
await page.mouse.move(cx, cy)
await page.mouse.down()
for (let i = 1; i <= 8; i++) {
await page.mouse.move(cx - i * 6, cy - i * 4)
await page.waitForTimeout(30)
}
await page.screenshot({ path: `${SHOTS}/04-roi-mid-drag.png` })
await page.mouse.up()

await expect.poll(async () => await gain(),
{ timeout: 90_000, message: 'the preview never re-solved after the drag' },
).not.toBe(before)
await page.waitForTimeout(1500)
await page.screenshot({ path: `${SHOTS}/05-roi-settled.png` })
ctx.assertNoJsErrors()
})

test('Correct Drift opens the dy/dx window and fills it, then Apply lands the node', async () => {
const { page } = ctx
await page.getByTestId('drift-solve').click()

// The curve is its OWN window (plan §0.9a), opened by the solve and filled
// from the on_shift stream — so it has points BEFORE the solve finishes.
await waitForSubwindowCount(page, 4, 120_000)
await expect(page.getByTestId('drift-progress')).toBeVisible({ timeout: 60_000 })
await page.screenshot({ path: `${SHOTS}/06-trace-filling.png` })

await expect(page.getByTestId('drift-result')).toBeVisible({ timeout: 180_000 })
await expect(page.getByTestId('drift-result')).toContainText('px drift')
await expect(page.getByTestId('drift-status')).toContainText('Solved')
await page.waitForTimeout(2000)
await page.getByTestId('drift-wizard').screenshot({ path: `${SHOTS}/07-solved-caret.png` })
await page.screenshot({ path: `${SHOTS}/08-solved-full.png` })

// Apply adds the LAZY corrected node to the tree (map_blocks, nothing copied)
// and shows it — so it appears in the Plot Control workflow list.
await page.getByTestId('drift-commit').click()
await expect(page.getByTestId('tree-node-Drift corrected')).toBeVisible({ timeout: 60_000 })
await expect(page.getByTestId('status-text')).toContainText('Drift corrected node added')
await page.waitForTimeout(2000)
await page.screenshot({ path: `${SHOTS}/09-applied.png` })

const errors = backendErrorLines(ctx.backend)
expect(errors, `backend errors:\n${errors.join('\n')}`).toEqual([])
ctx.assertNoJsErrors()
})
Loading
Loading