Skip to content

Commit d8b9d08

Browse files
Restore direct ROI resizing controls
1 parent 05fe7e5 commit d8b9d08

5 files changed

Lines changed: 321 additions & 4 deletions

File tree

‎docs/代码实现.md‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,72 @@
8686

8787
---
8888

89+
## 2026-06-20 00:22
90+
91+
## ROI 交互回退优化:三类框均支持直接缩放,修复检测框宽度滑杆失效
92+
本轮修复针对用户明确反馈的两个实际可用性问题:
93+
- 上一轮增强后,`检测框宽度` 滑杆虽然改了设置值,但不会立即改变画布上已经存在的 ROI,表现为“滑了没变化”
94+
- 用户不希望继续依赖抽象滑杆猜测框体变化,希望蓝色泳道框、橙色目标框、青色内参框都能直接在图上调大小
95+
96+
## 根因定位
97+
- `work/app/src/App.tsx`
98+
- 右侧控制面板的滑杆只调用 `updateSettings(...)`,并未同步改写当前 `draftsByPanelId`
99+
- 因此只要不重新草拟,现有 ROI 几何就保持不动,用户看到的就是参数变了、框没变
100+
- 另一个问题是当前画布交互仍是“按下记录起点,松手时一次性位移”,并不是真正的实时拖拽,更不支持直接缩放
101+
102+
## 本轮修改点
103+
### 1. 新增现有 ROI 几何重整函数
104+
- `work/app/src/lib/analysis.ts`
105+
- 新增 `retuneDraftsForSettings(...)`
106+
- 作用:
107+
- 当 `laneWidthScale / laneHeight / bandWidthScale / bandHeight / primaryY / referenceY` 变化时
108+
- 直接在当前 drafts 上做几何重整
109+
- 保留用户已经手工拖过的大致位置,而不是强制重新自动草拟
110+
111+
### 2. 修复检测框宽度滑杆“滑了没变化”
112+
- `work/app/src/App.tsx`
113+
- `updateSettings(...)` 现在在相关参数变化时会同步调用 `retuneDraftsForSettings(...)`
114+
- 结果是:
115+
- 调 `检测框宽度`,橙框和青框会立即变宽/变窄
116+
- 调 `泳道宽度` / `泳道高度`,蓝框也会立即改变
117+
- 调 `目标条带行` / `内参条带行`,短框会直接上下移动
118+
119+
### 3. 三类框全部恢复为可直接缩放
120+
- `work/app/src/App.tsx`
121+
- `work/app/src/App.css`
122+
- 现在蓝框、橙框、青框都支持:
123+
- 直接拖动位置
124+
- 选中后显示四个小型角点
125+
- 从角点直接缩放框体
126+
- 角点尺寸做得很小,只在选中状态下显示,避免再次遮挡条带图像
127+
128+
### 4. 拖动改为实时生效
129+
- `work/app/src/App.tsx`
130+
- 原来是 `mouseDown` 记录起点、`mouseUp` 时一次性结算位移
131+
- 现在改为 `mouseMove` 实时更新,`mouseUp / mouseLeave` 结束操作
132+
- 这让框体移动和缩放的反馈更接近正常图形编辑器
133+
134+
### 5. 回归测试补充
135+
- `work/app/src/lib/analysis.test.ts`
136+
- 新增测试验证:
137+
- 当 `bandWidthScale` 从 `1.0` 调到 `1.3`
138+
- 已存在的 primary / reference ROI 会立即变宽
139+
140+
## 验证结果
141+
- `npm run lint` 通过
142+
- `npx vitest run` 通过,3 个测试文件、13 个测试全部通过
143+
- `npm run build` 通过
144+
145+
## 当前效果判断
146+
到这一步为止,交互层已经回到更符合用户直觉的状态:
147+
- 不是只能靠重新草拟或抽象滑杆猜
148+
- 三类框都能直接在图上拖和改大小
149+
- `检测框宽度` 滑杆终于会即时改变当前 ROI,而不是看起来失效
150+
151+
这轮修复本质上是把工具从“参数驱动的半静态框架”重新拉回“图上直接编辑”的工作流,更接近真实实验用户的操作习惯。
152+
153+
---
154+
89155
## 2026-06-20 00:01
90156

91157
## 宽条带 / 拖尾条带场景的 ROI 宽度自适应修复

‎work/app/src/App.css‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,44 @@
500500
background: rgba(49, 77, 86, 0.12);
501501
}
502502

503+
.resize-handle {
504+
position: absolute;
505+
width: 10px;
506+
height: 10px;
507+
padding: 0;
508+
border: 1px solid rgba(255, 252, 247, 0.95);
509+
border-radius: 999px;
510+
background: var(--ink);
511+
box-shadow: 0 0 0 1px rgba(63, 53, 42, 0.12);
512+
cursor: nwse-resize;
513+
z-index: 3;
514+
}
515+
516+
.resize-ne,
517+
.resize-sw {
518+
cursor: nesw-resize;
519+
}
520+
521+
.resize-nw {
522+
left: -5px;
523+
top: -5px;
524+
}
525+
526+
.resize-ne {
527+
right: -5px;
528+
top: -5px;
529+
}
530+
531+
.resize-sw {
532+
left: -5px;
533+
bottom: -5px;
534+
}
535+
536+
.resize-se {
537+
right: -5px;
538+
bottom: -5px;
539+
}
540+
503541
.rail {
504542
display: grid;
505543
gap: 14px;

‎work/app/src/App.tsx‎

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
defaultSettings,
1616
draftLaneLayout,
1717
readFileAsDataUrl,
18+
resizeRegionFromHandle,
19+
retuneDraftsForSettings,
1820
syncLaneConfigs,
1921
type AnalysisResult,
2022
type AnalysisSettings,
@@ -44,6 +46,7 @@ import type {
4446
LaneDraft,
4547
PanelAsset,
4648
Rect,
49+
ResizeHandle,
4750
SelectedRegion,
4851
StatisticalSettings,
4952
} from './types'
@@ -126,6 +129,7 @@ function App() {
126129
startX: number
127130
startY: number
128131
region: SelectedRegion
132+
handle?: ResizeHandle
129133
} | null>(null)
130134
const [showAutosaveBanner, setShowAutosaveBanner] = useState(Boolean(restoredProject))
131135
const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(null)
@@ -368,10 +372,35 @@ function App() {
368372
if (!activePanelId) {
369373
return
370374
}
375+
const previousSettings = settingsByPanelId[activePanelId] ?? settings
376+
const nextSettings = { ...previousSettings, [key]: value }
371377
setSettingsByPanelId((current) => ({
372378
...current,
373-
[activePanelId]: { ...(current[activePanelId] ?? settings), [key]: value },
379+
[activePanelId]: nextSettings,
374380
}))
381+
382+
if (
383+
draftsByPanelId[activePanelId]?.length &&
384+
['laneWidthScale', 'laneHeight', 'bandWidthScale', 'bandHeight', 'primaryY', 'referenceY'].includes(
385+
String(key),
386+
)
387+
) {
388+
const bounds = {
389+
x: 0,
390+
y: 0,
391+
width: activeAnalysis?.width ?? 1000,
392+
height: activeAnalysis?.height ?? 620,
393+
}
394+
setDraftsByPanelId((current) => ({
395+
...current,
396+
[activePanelId]: retuneDraftsForSettings({
397+
drafts: current[activePanelId] ?? [],
398+
previous: previousSettings,
399+
next: nextSettings,
400+
bounds,
401+
}),
402+
}))
403+
}
375404
}
376405

377406
function updateStatisticalSettings<K extends keyof StatisticalSettings>(
@@ -700,16 +729,20 @@ function App() {
700729
function handleOverlayMouseDown(
701730
event: ReactMouseEvent<HTMLButtonElement>,
702731
region: SelectedRegion,
732+
handle?: ResizeHandle,
703733
) {
734+
event.preventDefault()
735+
event.stopPropagation()
704736
setSelectedRegion(region)
705737
setDragState({
706738
startX: event.clientX,
707739
startY: event.clientY,
708740
region,
741+
handle,
709742
})
710743
}
711744

712-
function handleOverlayMouseUp(event: ReactMouseEvent<HTMLDivElement>) {
745+
function handleOverlayMouseMove(event: ReactMouseEvent<HTMLDivElement>) {
713746
if (!dragState || !activePanelId) {
714747
return
715748
}
@@ -730,10 +763,34 @@ function App() {
730763
...current,
731764
[activePanelId]: current[activePanelId].map((draft) =>
732765
draft.id === dragState.region.laneId
733-
? applyRegionDelta(draft, dragState.region, deltaX, deltaY, bounds)
766+
? dragState.handle
767+
? resizeRegionFromHandle(
768+
draft,
769+
dragState.region,
770+
dragState.handle,
771+
deltaX,
772+
deltaY,
773+
dragState.region.target === 'lane' ? bounds : draft.lane,
774+
)
775+
: applyRegionDelta(draft, dragState.region, deltaX, deltaY, bounds)
734776
: draft,
735777
),
736778
}))
779+
setDragState((current) =>
780+
current
781+
? {
782+
...current,
783+
startX: event.clientX,
784+
startY: event.clientY,
785+
}
786+
: null,
787+
)
788+
}
789+
790+
function handleOverlayMouseUp() {
791+
if (!dragState) {
792+
return
793+
}
737794
setDragState(null)
738795
}
739796

@@ -784,6 +841,31 @@ function App() {
784841
setShowAutosaveBanner(false)
785842
}
786843

844+
function renderResizeHandles(
845+
laneId: string,
846+
target: SelectedRegion['target'],
847+
) {
848+
const handles: ResizeHandle[] = ['nw', 'ne', 'sw', 'se']
849+
return handles.map((handle) => (
850+
<button
851+
key={`${laneId}-${target}-${handle}`}
852+
type="button"
853+
className={`resize-handle resize-${handle}`}
854+
onMouseDown={(event) =>
855+
handleOverlayMouseDown(
856+
event,
857+
{
858+
laneId,
859+
target,
860+
},
861+
handle,
862+
)
863+
}
864+
onClick={(event) => event.stopPropagation()}
865+
/>
866+
))
867+
}
868+
787869
function handleDropzoneDragOver(event: ReactDragEvent<HTMLDivElement>) {
788870
event.preventDefault()
789871
if (!isDragActive) {
@@ -1084,7 +1166,13 @@ function App() {
10841166
alt={activePanel.name}
10851167
className="stage-image"
10861168
/>
1087-
<div className="overlay" style={overlayStyle(stageMetrics)} onMouseUp={handleOverlayMouseUp}>
1169+
<div
1170+
className="overlay"
1171+
style={overlayStyle(stageMetrics)}
1172+
onMouseMove={handleOverlayMouseMove}
1173+
onMouseUp={handleOverlayMouseUp}
1174+
onMouseLeave={handleOverlayMouseUp}
1175+
>
10881176
{activeGeometries.map((geometry, index) => (
10891177
<div
10901178
key={geometry.id}
@@ -1108,6 +1196,9 @@ function App() {
11081196
}
11091197
onClick={() => handleSelectRegion({ laneId: geometry.id, target: 'lane' })}
11101198
/>
1199+
{isSelected(selectedRegion, geometry.id, 'lane')
1200+
? renderResizeHandles(geometry.id, 'lane')
1201+
: null}
11111202
<button
11121203
type="button"
11131204
className={`band-outline primary ${
@@ -1122,6 +1213,9 @@ function App() {
11221213
}
11231214
onClick={() => handleSelectRegion({ laneId: geometry.id, target: 'primary' })}
11241215
/>
1216+
{isSelected(selectedRegion, geometry.id, 'primary')
1217+
? renderResizeHandles(geometry.id, 'primary')
1218+
: null}
11251219
{geometry.reference ? (
11261220
<>
11271221
<button
@@ -1140,6 +1234,9 @@ function App() {
11401234
handleSelectRegion({ laneId: geometry.id, target: 'reference' })
11411235
}
11421236
/>
1237+
{isSelected(selectedRegion, geometry.id, 'reference')
1238+
? renderResizeHandles(geometry.id, 'reference')
1239+
: null}
11431240
</>
11441241
) : null}
11451242
</div>

‎work/app/src/lib/analysis.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
autoDraftFromSignal,
44
buildDefaultDrafts,
55
detectSignalBounds,
6+
retuneDraftsForSettings,
67
resizeRegionFromHandle,
78
summarizeGroups,
89
type AnalysisSettings,
@@ -222,4 +223,22 @@ describe('analysis helpers', () => {
222223
expect(resized.primary.x).toBeGreaterThanOrEqual(draft.lane.x)
223224
expect(resized.primary.y).toBeGreaterThanOrEqual(draft.lane.y)
224225
})
226+
227+
it('retunes existing drafts when band width scale changes', () => {
228+
const original = buildDefaultDrafts({
229+
width: 1000,
230+
height: 600,
231+
settings: { ...settings, laneCount: 1, bandWidthScale: 1 },
232+
})[0]
233+
234+
const widened = retuneDraftsForSettings({
235+
drafts: [original],
236+
previous: { ...settings, laneCount: 1, bandWidthScale: 1 },
237+
next: { ...settings, laneCount: 1, bandWidthScale: 1.3 },
238+
bounds: { x: 0, y: 0, width: 1000, height: 600 },
239+
})[0]
240+
241+
expect(widened.primary.width).toBeGreaterThan(original.primary.width)
242+
expect(widened.reference?.width ?? 0).toBeGreaterThan(original.reference?.width ?? 0)
243+
})
225244
})

0 commit comments

Comments
 (0)