2026-05-24-17-29-17 增加构件加载进度并修正滚轮事件

This commit is contained in:
2026-05-24 17:42:48 +08:00
parent d9572e6966
commit 1dcfc2a4c1
7 changed files with 299 additions and 58 deletions

View File

@@ -359,6 +359,7 @@ function getDicomDisplaySliceNumber(sliceIndex: number, totalSlices: number) {
}
function DicomCanvas({ preview, rotation, resetSignal }: { preview: DicomPreview; rotation: number; resetSignal: number }) {
const viewportRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [viewport, setViewport] = useState({ scale: 1, offsetX: 0, offsetY: 0 });
const [isPanning, setIsPanning] = useState(false);
@@ -383,14 +384,27 @@ function DicomCanvas({ preview, rotation, resetSignal }: { preview: DicomPreview
setViewport({ scale: 1, offsetX: 0, offsetY: 0 });
}, [resetSignal]);
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
event.preventDefault();
const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
setViewport((current) => ({
...current,
scale: Math.max(0.35, Math.min(6, current.scale * scaleFactor)),
}));
};
useEffect(() => {
const element = viewportRef.current;
if (!element) {
return;
}
const handleWheel = (event: WheelEvent) => {
event.preventDefault();
const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
setViewport((current) => ({
...current,
scale: Math.max(0.35, Math.min(6, current.scale * scaleFactor)),
}));
};
element.addEventListener('wheel', handleWheel, { passive: false });
return () => {
element.removeEventListener('wheel', handleWheel);
};
}, []);
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) {
return;
@@ -431,8 +445,8 @@ function DicomCanvas({ preview, rotation, resetSignal }: { preview: DicomPreview
return (
<div
className={`relative flex h-full w-full items-center justify-center overflow-hidden rounded-xl bg-black ${isPanning ? 'cursor-grabbing' : 'cursor-grab'}`}
onWheel={handleWheel}
ref={viewportRef}
className={`relative flex h-full w-full touch-none items-center justify-center overflow-hidden rounded-xl bg-black ${isPanning ? 'cursor-grabbing' : 'cursor-grab'}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={stopPointerDrag}
@@ -1961,15 +1975,13 @@ export default function ProjectLibrary({
{dicomDisplaySlice} / {dicomSliceTotal || selectedProject.dicomCount}
</span>
<button
onMouseDown={() => startSliceStep(1)}
onMouseUp={stopSliceStep}
onMouseLeave={stopSliceStep}
onTouchStart={(event) => {
event.preventDefault();
onPointerDown={() => {
startSliceStep(1);
}}
onTouchEnd={stopSliceStep}
className="mb-3 h-8 w-8 rounded-full bg-white text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:border-blue-100 flex items-center justify-center"
onPointerUp={stopSliceStep}
onPointerLeave={stopSliceStep}
onPointerCancel={stopSliceStep}
className="mb-3 flex h-8 w-8 touch-none items-center justify-center rounded-full border border-slate-100 bg-white text-slate-500 shadow-sm hover:border-blue-100 hover:text-blue-600"
title="长按向上移动切片"
>
<ChevronUp size={16} />
@@ -1987,15 +1999,13 @@ export default function ProjectLibrary({
/>
</div>
<button
onMouseDown={() => startSliceStep(-1)}
onMouseUp={stopSliceStep}
onMouseLeave={stopSliceStep}
onTouchStart={(event) => {
event.preventDefault();
onPointerDown={() => {
startSliceStep(-1);
}}
onTouchEnd={stopSliceStep}
className="mt-3 h-8 w-8 rounded-full bg-white text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:border-blue-100 flex items-center justify-center"
onPointerUp={stopSliceStep}
onPointerLeave={stopSliceStep}
onPointerCancel={stopSliceStep}
className="mt-3 flex h-8 w-8 touch-none items-center justify-center rounded-full border border-slate-100 bg-white text-slate-500 shadow-sm hover:border-blue-100 hover:text-blue-600"
title="长按向下移动切片"
>
<ChevronDown size={16} />

View File

@@ -2154,11 +2154,13 @@ export function VoxelizationMappingView({
}) {
const baseCanvasRef = useRef<HTMLCanvasElement | null>(null);
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
const mappingViewportRef = useRef<HTMLDivElement | null>(null);
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
const [modelPreviews, setModelPreviews] = useState<Record<string, ModelPreviewPayload>>({});
const [dicomStatus, setDicomStatus] = useState('等待 DICOM 切片');
const [overlayStatus, setOverlayStatus] = useState('等待 STL 映射');
const [overlayStats, setOverlayStats] = useState<OverlayStats>({ activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] });
const [overlayLoadState, setOverlayLoadState] = useState({ loading: false, loaded: 0, total: 0, phase: '' });
const [mappingViewport, setMappingViewport] = useState({ scale: 1, offsetX: 0, offsetY: 0 });
const mappingPanRef = useRef({
active: false,
@@ -2210,15 +2212,38 @@ export function VoxelizationMappingView({
useEffect(() => {
if (!project || !visibleStlFiles.length) {
setModelPreviews({});
setOverlayStats({ activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] });
setOverlayLoadState({ loading: false, loaded: 0, total: 0, phase: '' });
setOverlayStatus(stlFiles.length ? '当前没有可见 STL 构件' : '当前项目没有 STL 构件');
return;
}
let disposed = false;
let loaded = 0;
const total = visibleStlFiles.length;
const previewLimit = Math.max(detailLimit, 500000);
const updateLoadProgress = (phase: string) => {
if (!disposed) {
setOverlayLoadState({ loading: true, loaded, total, phase });
}
};
setModelPreviews({});
setOverlayStats({ activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] });
setOverlayLoadState({ loading: true, loaded: 0, total, phase: '正在载入可见构件' });
setOverlayStatus('正在载入可见 STL 构件层级...');
Promise.allSettled(visibleStlFiles.map((fileName) => (
getCachedModelPreview(project.id, fileName, Math.max(detailLimit, 500000))
.then((payload) => ({ fileName, payload }))
getCachedModelPreview(project.id, fileName, previewLimit)
.then((payload) => {
loaded += 1;
updateLoadProgress(fileName.replace(/\.stl$/i, ''));
return { fileName, payload };
})
.catch((error) => {
loaded += 1;
updateLoadProgress(`${fileName.replace(/\.stl$/i, '')} 载入失败`);
throw error;
})
))).then((results) => {
if (disposed) return;
const nextPreviews: Record<string, ModelPreviewPayload> = {};
@@ -2228,6 +2253,7 @@ export function VoxelizationMappingView({
}
});
setModelPreviews(nextPreviews);
setOverlayLoadState({ loading: false, loaded: total, total, phase: '' });
setOverlayStatus(Object.keys(nextPreviews).length ? 'Overlay Label Map 已就绪' : 'Overlay Layer 无可用 STL 数据');
});
@@ -2283,6 +2309,27 @@ export function VoxelizationMappingView({
totalSlices,
]);
useEffect(() => {
const element = mappingViewportRef.current;
if (!element) {
return;
}
const handleWheel = (event: WheelEvent) => {
event.preventDefault();
const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
setMappingViewport((current) => ({
...current,
scale: clamp(current.scale * scaleFactor, 0.45, 6),
}));
};
element.addEventListener('wheel', handleWheel, { passive: false });
return () => {
element.removeEventListener('wheel', handleWheel);
};
}, [isLibraryVariant]);
const stepSlice = (delta: number) => {
onSliceChange(clamp(safeSlice + delta, 0, maxSlice));
};
@@ -2291,14 +2338,6 @@ export function VoxelizationMappingView({
const resetMappingViewport = () => {
setMappingViewport({ scale: 1, offsetX: 0, offsetY: 0 });
};
const handleMappingWheel = (event: React.WheelEvent<HTMLDivElement>) => {
event.preventDefault();
const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
setMappingViewport((current) => ({
...current,
scale: clamp(current.scale * scaleFactor, 0.45, 6),
}));
};
const handleMappingPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) {
return;
@@ -2334,6 +2373,40 @@ export function VoxelizationMappingView({
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
const renderOverlayLoadProgress = (tone: 'dark' | 'light') => {
if (!overlayLoadState.loading || !overlayLoadState.total) {
return null;
}
const percent = Math.round((overlayLoadState.loaded / overlayLoadState.total) * 100);
const isDark = tone === 'dark';
return (
<div className={`pointer-events-none absolute left-4 top-4 z-20 w-64 rounded-xl border px-3 py-2 shadow-lg backdrop-blur-md ${
isDark
? 'border-white/10 bg-black/70 text-white'
: 'border-slate-200 bg-white/90 text-slate-700'
}`}>
<div className="flex items-center justify-between gap-3 text-[10px] font-bold">
<span className={`min-w-0 truncate ${isDark ? 'text-cyan-100' : 'text-cyan-700'}`}>
</span>
<span className={`font-mono ${isDark ? 'text-white/70' : 'text-slate-500'}`}>
{overlayLoadState.loaded}/{overlayLoadState.total}
</span>
</div>
<div className={`mt-2 h-1.5 overflow-hidden rounded-full ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
<div
className="h-full rounded-full bg-cyan-400 transition-[width] duration-200"
style={{ width: `${percent}%` }}
/>
</div>
<div className={`mt-1 flex items-center justify-between gap-3 text-[9px] font-bold ${isDark ? 'text-white/45' : 'text-slate-400'}`}>
<span className="min-w-0 truncate">{overlayLoadState.phase || overlayStatus}</span>
<span className="font-mono">{percent}%</span>
</div>
</div>
);
};
const renderOverlaySummary = (placement: 'bottom' | 'side') => (
<div className={`${placement === 'side' ? 'w-full rounded-2xl border border-white/10 bg-black/40 p-2' : 'border-t border-white/10 bg-[#030712] px-4 py-3'}`}>
<div className={`mb-2 flex gap-2 text-[10px] font-bold text-white/60 ${placement === 'side' ? 'flex-col' : 'items-center justify-between'}`}>
@@ -2389,8 +2462,8 @@ export function VoxelizationMappingView({
className="flex min-h-0 flex-col"
>
<div
className={`relative min-h-0 flex-1 overflow-hidden bg-black ${mappingPanRef.current.active ? 'cursor-grabbing' : 'cursor-grab'}`}
onWheel={handleMappingWheel}
ref={mappingViewportRef}
className={`relative min-h-0 flex-1 touch-none overflow-hidden bg-black ${mappingPanRef.current.active ? 'cursor-grabbing' : 'cursor-grab'}`}
onPointerDown={handleMappingPointerDown}
onPointerMove={handleMappingPointerMove}
onPointerUp={stopMappingPointerDrag}
@@ -2412,6 +2485,7 @@ export function VoxelizationMappingView({
{dicomStatus}
</div>
)}
{renderOverlayLoadProgress('dark')}
<div className="pointer-events-none absolute bottom-4 right-4 rounded-xl border border-white/10 bg-black/70 px-3 py-2 text-right shadow-lg">
<p className="text-[9px] font-bold text-white/45">DICOM </p>
<p className="mt-1 font-mono text-[12px] font-bold text-cyan-100">
@@ -2469,8 +2543,8 @@ export function VoxelizationMappingView({
<div className="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_110px]">
<div className="flex min-h-0 flex-col">
<div
className={`relative min-h-0 flex-1 overflow-hidden bg-black ${mappingPanRef.current.active ? 'cursor-grabbing' : 'cursor-grab'}`}
onWheel={handleMappingWheel}
ref={mappingViewportRef}
className={`relative min-h-0 flex-1 touch-none overflow-hidden bg-black ${mappingPanRef.current.active ? 'cursor-grabbing' : 'cursor-grab'}`}
onPointerDown={handleMappingPointerDown}
onPointerMove={handleMappingPointerMove}
onPointerUp={stopMappingPointerDrag}
@@ -2492,6 +2566,7 @@ export function VoxelizationMappingView({
{dicomStatus}
</div>
)}
{renderOverlayLoadProgress('dark')}
</div>
<div className="border-t border-slate-100 bg-white px-4 py-3">
@@ -3910,19 +3985,14 @@ export default function ReverseWorkspace({
<div key={item.key} className="grid grid-cols-[44px_28px_1fr_28px_72px] items-center gap-2 text-[10px] font-bold text-slate-500">
<span>{item.label}</span>
<button
onMouseDown={(event) => {
event.preventDefault();
onPointerDown={() => {
startPoseRepeat(item.key, -poseStepConfig[item.key].step);
}}
onMouseUp={stopPoseRepeat}
onMouseLeave={stopPoseRepeat}
onTouchStart={(event) => {
event.preventDefault();
startPoseRepeat(item.key, -poseStepConfig[item.key].step);
}}
onTouchEnd={stopPoseRepeat}
onPointerUp={stopPoseRepeat}
onPointerLeave={stopPoseRepeat}
onPointerCancel={stopPoseRepeat}
onClick={() => nudgeModelPose(item.key, -poseStepConfig[item.key].step)}
className="h-6 rounded-md bg-white text-[9px] font-bold text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:bg-blue-50"
className="h-6 touch-none rounded-md border border-slate-100 bg-white text-[9px] font-bold text-slate-500 shadow-sm hover:bg-blue-50 hover:text-blue-600"
title={`单击移动最低刻度,长按连续移动。${poseStepConfig[item.key].minus}`}
>
-
@@ -3937,19 +4007,14 @@ export default function ReverseWorkspace({
className="accent-blue-600"
/>
<button
onMouseDown={(event) => {
event.preventDefault();
onPointerDown={() => {
startPoseRepeat(item.key, poseStepConfig[item.key].step);
}}
onMouseUp={stopPoseRepeat}
onMouseLeave={stopPoseRepeat}
onTouchStart={(event) => {
event.preventDefault();
startPoseRepeat(item.key, poseStepConfig[item.key].step);
}}
onTouchEnd={stopPoseRepeat}
onPointerUp={stopPoseRepeat}
onPointerLeave={stopPoseRepeat}
onPointerCancel={stopPoseRepeat}
onClick={() => nudgeModelPose(item.key, poseStepConfig[item.key].step)}
className="h-6 rounded-md bg-white text-[9px] font-bold text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:bg-blue-50"
className="h-6 touch-none rounded-md border border-slate-100 bg-white text-[9px] font-bold text-slate-500 shadow-sm hover:bg-blue-50 hover:text-blue-600"
title={`单击移动最低刻度,长按连续移动。${poseStepConfig[item.key].plus}`}
>
+