2026-05-25-00-07-30 优化自动匹配对比视图与采样设置

This commit is contained in:
2026-05-25 00:28:52 +08:00
parent 2fac0200fc
commit d1fa79aef9
8 changed files with 426 additions and 179 deletions

View File

@@ -1,4 +1,4 @@
import express from 'express';
import express, { type Request, type Response } from 'express';
import AdmZip from 'adm-zip';
import multer from 'multer';
import { createServer as createViteServer } from 'vite';
@@ -747,6 +747,23 @@ function writeState(state: AppState) {
fs.writeFileSync(statePath, JSON.stringify({ ...state, updatedAt: now() }, null, 2));
}
function sendJsonPayload(req: Request, res: Response, payload: unknown) {
const json = JSON.stringify(payload);
const acceptsGzip = String(req.headers['accept-encoding'] ?? '').includes('gzip');
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Vary', 'Accept-Encoding');
if (acceptsGzip && Buffer.byteLength(json) > 64 * 1024) {
const compressed = zlib.gzipSync(Buffer.from(json));
res.setHeader('Content-Encoding', 'gzip');
res.setHeader('Content-Length', String(compressed.length));
res.send(compressed);
return;
}
res.send(json);
}
interface DicomHuVolume {
width: number;
height: number;
@@ -1026,9 +1043,9 @@ function transformPointForExportPose(x: number, y: number, z: number, metrics: E
const defaultAutoMatchWeights: AutoMatchWeights = {
boneReward: 1,
missPenalty: 0.45,
movementPenalty: 0.08,
scalePenalty: 0.12,
missPenalty: 0.1,
movementPenalty: 0,
scalePenalty: 0,
};
const defaultAutoMatchAdjustable: AutoMatchParameterSelection = {
translateX: true,
@@ -3753,7 +3770,7 @@ async function startServer() {
}
try {
res.json(createStlPreview(getProjectModelFilePath(project, fileName), fileName, Number.isFinite(limit) ? limit : 5000));
sendJsonPayload(req, res, createStlPreview(getProjectModelFilePath(project, fileName), fileName, Number.isFinite(limit) ? limit : 5000));
} catch (error) {
res.status(422).json({ message: error instanceof Error ? error.message : 'STL 预览失败' });
}

View File

@@ -12,15 +12,16 @@ import {
SlidersHorizontal,
} from 'lucide-react';
import {
AutoMatchCandidate,
AutoMatchParameterKey,
AutoMatchParameterSelection,
AutoMatchWeights,
ModuleStyle,
ModelPose,
Project,
} from '../types';
import { api } from '../lib/api';
import { cn } from '../lib/utils';
import { VoxelizationMappingView, type MappingDisplayMode } from './ReverseWorkspace';
const defaultModelPose: ModelPose = {
rotateX: 0,
@@ -44,9 +45,9 @@ const defaultAdjustable: AutoMatchParameterSelection = {
const defaultWeights: AutoMatchWeights = {
boneReward: 1,
missPenalty: 0.45,
movementPenalty: 0.08,
scalePenalty: 0.12,
missPenalty: 0.1,
movementPenalty: 0,
scalePenalty: 0,
};
const parameterOptions: Array<{ key: AutoMatchParameterKey; label: string }> = [
@@ -64,7 +65,13 @@ const weightOptions: Array<{ key: keyof AutoMatchWeights; label: string; min: nu
];
const boneNamePattern = /(rib|bone|hipbone|hip|vertebra|spine|sternum|pelvis|sacrum|costal|skull|肋|骨)/i;
const maxManualSampleSlices = 96;
const maxSampleSliceCount = 96;
const defaultSampleSliceCount = 9;
type AutoMatchMappingMode = 'selected' | 'score';
const mappingModeOptions: Array<{ id: AutoMatchMappingMode; label: string }> = [
{ id: 'selected', label: '所选骨骼区域' },
{ id: 'score', label: '得分可视化' },
];
function latestPoseFromProject(project: Project | null, incomingPose?: ModelPose | null) {
if (incomingPose) {
@@ -78,7 +85,7 @@ function latestPoseFromProject(project: Project | null, incomingPose?: ModelPose
?? defaultModelPose;
}
function defaultBoneFiles(project: Project | null) {
function suggestedBoneFiles(project: Project | null) {
const files = project?.stlFiles ?? [];
const matched = files.filter((fileName) => boneNamePattern.test(fileName));
return matched.length ? matched : files;
@@ -99,57 +106,41 @@ function defaultSampleSliceNumbers(total: number) {
.sort((a, b) => a - b);
}
function formatSampleSliceText(total: number) {
return defaultSampleSliceNumbers(total).join(', ');
}
function parseSampleSliceText(input: string, total: number) {
function uniformSampleSliceNumbers(total: number, count: number) {
const maxSlice = Math.max(total, 1);
const values: number[] = [];
const normalized = input
.replace(/[,、;;]/g, ',')
.replace(/\s+/g, ',')
.split(',')
.map((token) => token.trim())
.filter(Boolean);
normalized.forEach((token) => {
const rangeMatch = token.match(/^(\d+)\s*[-~]\s*(\d+)(?::(\d+))?$/);
if (rangeMatch) {
const start = Number(rangeMatch[1]);
const end = Number(rangeMatch[2]);
const rawStep = Number(rangeMatch[3] ?? 1);
const step = Math.max(1, Math.min(50, Number.isFinite(rawStep) ? Math.round(rawStep) : 1));
const direction = start <= end ? 1 : -1;
for (let value = start; direction > 0 ? value <= end : value >= end; value += step * direction) {
values.push(value);
if (values.length > maxManualSampleSlices * 2) {
break;
}
}
return;
}
const value = Number(token);
if (Number.isFinite(value)) {
values.push(value);
}
});
const slices = [...new Set(values
.map((value) => Math.round(value))
.filter((value) => Number.isFinite(value))
.map((value) => Math.max(1, Math.min(maxSlice, value))))]
.sort((a, b) => a - b)
.slice(0, maxManualSampleSlices);
return slices.length ? slices : defaultSampleSliceNumbers(total);
const safeCount = Math.max(1, Math.min(maxSampleSliceCount, Math.round(count)));
if (safeCount <= 1) {
return [Math.max(1, Math.round(maxSlice / 2))];
}
return [...new Set(Array.from({ length: safeCount }, (_, index) => (
Math.max(1, Math.min(maxSlice, Math.round(1 + ((maxSlice - 1) * index) / (safeCount - 1))))
)))].sort((a, b) => a - b);
}
function poseHasVisibleChange(base: ModelPose, next: ModelPose) {
return parameterOptions.some((option) => Math.abs(poseDelta(base, next, option.key)) >= 0.0005);
}
function buildSelectedModuleStyles(
project: Project | null,
selectedFiles: string[],
mode: AutoMatchMappingMode,
accentColor: string,
) {
const selected = new Set(selectedFiles);
return (project?.stlFiles ?? []).reduce<Record<string, ModuleStyle>>((accumulator, fileName, index) => {
const source = project?.moduleStyles?.[fileName];
const visible = selected.has(fileName);
accumulator[fileName] = {
visible,
color: visible && mode === 'score' ? accentColor : source?.color ?? ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6'][index % 5],
opacity: visible ? (mode === 'score' ? 0.9 : source?.opacity ?? 0.72) : source?.opacity ?? 0.72,
partId: source?.partId ?? index + 1,
};
return accumulator;
}, {});
}
interface AutoMatchWorkspaceProps {
projectId: string;
initialPose?: ModelPose | null;
@@ -164,7 +155,9 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
const [selectedBoneFiles, setSelectedBoneFiles] = useState<string[]>([]);
const [adjustable, setAdjustable] = useState<AutoMatchParameterSelection>(defaultAdjustable);
const [weights, setWeights] = useState<AutoMatchWeights>(defaultWeights);
const [sampleSlicesText, setSampleSlicesText] = useState('');
const [sampleSliceCount, setSampleSliceCount] = useState(defaultSampleSliceCount);
const [previewSlice, setPreviewSlice] = useState(0);
const [mappingMode, setMappingMode] = useState<AutoMatchMappingMode>('selected');
const [iterations, setIterations] = useState(6);
const [candidatesPerRound, setCandidatesPerRound] = useState(36);
const [result, setResult] = useState<{
@@ -173,7 +166,6 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
bestMode: string;
bestChanged: AutoMatchParameterKey[];
evaluated: number;
trace: AutoMatchCandidate[];
sampleSlices: number[];
} | null>(null);
const [loadingProject, setLoadingProject] = useState(true);
@@ -212,8 +204,9 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
const incomingPose = selectedProjectId === projectId ? initialPose : null;
setProject(nextProject);
setBasePose(latestPoseFromProject(nextProject, incomingPose));
setSelectedBoneFiles(defaultBoneFiles(nextProject));
setSampleSlicesText(formatSampleSliceText(nextProject.dicomCount));
setSelectedBoneFiles([]);
setSampleSliceCount(defaultSampleSliceCount);
setPreviewSlice(Math.max(0, Math.floor((nextProject.dicomCount - 1) / 2)));
setResult(null);
})
.catch((loadError) => {
@@ -247,10 +240,21 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
[adjustable],
);
const parsedSampleSlices = useMemo(
() => parseSampleSliceText(sampleSlicesText, project?.dicomCount ?? 1),
[project?.dicomCount, sampleSlicesText],
() => uniformSampleSliceNumbers(project?.dicomCount ?? 1, sampleSliceCount),
[project?.dicomCount, sampleSliceCount],
);
const resultHasPoseChange = result ? poseHasVisibleChange(basePose, result.bestPose) : false;
const maxPreviewSlice = Math.max((project?.dicomCount ?? 1) - 1, 0);
const comparePose = result?.bestPose ?? basePose;
const mappingDisplayMode: MappingDisplayMode = mappingMode === 'score' ? 'bone' : 'soft';
const currentModuleStyles = useMemo(
() => buildSelectedModuleStyles(project, selectedBoneFiles, mappingMode, '#f59e0b'),
[project, selectedBoneFiles, mappingMode],
);
const bestModuleStyles = useMemo(
() => buildSelectedModuleStyles(project, selectedBoneFiles, mappingMode, '#22c55e'),
[project, selectedBoneFiles, mappingMode],
);
const toggleBoneFile = (fileName: string) => {
setSelectedBoneFiles((current) => (
@@ -293,7 +297,6 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
bestMode: data.bestMode,
bestChanged: data.bestChanged,
evaluated: data.evaluated,
trace: data.trace,
sampleSlices: data.sampleSlices,
});
setLoadingPercent(100);
@@ -324,8 +327,6 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
}
};
const traceRows = result?.trace.slice(0, 10) ?? [];
return (
<div className="h-full min-h-0 overflow-y-auto pr-2">
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
@@ -423,10 +424,10 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-black uppercase text-slate-400"></span>
<button
onClick={() => setSelectedBoneFiles(defaultBoneFiles(project))}
onClick={() => setSelectedBoneFiles(suggestedBoneFiles(project))}
className="text-xs font-black text-blue-600 hover:text-blue-700"
>
</button>
</div>
<div className="max-h-60 space-y-2 overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-2">
@@ -455,21 +456,33 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-black uppercase text-slate-400"></span>
<button
onClick={() => setSampleSlicesText(formatSampleSliceText(project?.dicomCount ?? 1))}
onClick={() => setSampleSliceCount(defaultSampleSliceCount)}
className="text-xs font-black text-blue-600 hover:text-blue-700"
>
</button>
</div>
<textarea
value={sampleSlicesText}
onChange={(event) => setSampleSlicesText(event.target.value)}
placeholder="39, 71, 102 或 39-160:5"
className="h-20 w-full resize-none rounded-lg border border-slate-200 bg-white px-3 py-2 font-mono text-sm font-bold text-slate-700 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
/>
<div className="grid grid-cols-[1fr_72px] items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-3">
<input
type="range"
min={1}
max={maxSampleSliceCount}
value={sampleSliceCount}
onChange={(event) => setSampleSliceCount(Number(event.target.value))}
className="accent-blue-600"
/>
<input
type="number"
min={1}
max={maxSampleSliceCount}
value={sampleSliceCount}
onChange={(event) => setSampleSliceCount(Math.max(1, Math.min(maxSampleSliceCount, Number(event.target.value) || 1)))}
className="h-9 rounded-md border border-slate-200 px-2 text-right font-mono text-sm font-black text-slate-700 outline-none focus:border-blue-400"
/>
</div>
<div className="mt-2 flex items-center justify-between text-xs font-bold text-slate-400">
<span> 1-{Math.max(project?.dicomCount ?? 1, 1)}</span>
<span>{parsedSampleSlices.length}/{maxManualSampleSlices} </span>
<span> 1-{Math.max(project?.dicomCount ?? 1, 1)}</span>
<span>{parsedSampleSlices.length} </span>
</div>
</div>
@@ -577,27 +590,6 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
</div>
)}
<div className="mt-5 grid grid-cols-4 gap-3 max-xl:grid-cols-2 max-lg:grid-cols-1">
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3">
<div className="text-xs font-black text-slate-400"></div>
<div className="mt-2 font-mono text-xl font-black text-slate-800">{result?.evaluated ?? 0}</div>
</div>
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3">
<div className="text-xs font-black text-slate-400"></div>
<div className="mt-2 truncate font-mono text-sm font-black text-slate-800">
{result?.sampleSlices.map((slice) => slice + 1).join(', ') ?? '-'}
</div>
</div>
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3">
<div className="text-xs font-black text-slate-400"></div>
<div className="mt-2 font-mono text-xl font-black text-slate-800">{selectedBoneFiles.length}</div>
</div>
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3">
<div className="text-xs font-black text-slate-400"></div>
<div className="mt-2 truncate text-sm font-black text-slate-800">{result?.bestMode ?? '-'}</div>
</div>
</div>
<div className="mt-5 flex flex-wrap gap-2">
<button
onClick={() => void applyBestPose(false)}
@@ -627,32 +619,77 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
</button>
</div>
<div className="mt-6 overflow-hidden rounded-lg border border-slate-200">
<div className="grid grid-cols-[72px_1fr_100px_96px_96px] bg-slate-50 px-3 py-2 text-[11px] font-black uppercase text-slate-400">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<div className="mt-6 rounded-lg border border-slate-200 bg-slate-50 p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
{mappingModeOptions.map((option) => (
<button
key={option.id}
onClick={() => setMappingMode(option.id)}
className={cn(
'rounded-lg px-3 py-2 text-xs font-black transition',
mappingMode === option.id ? 'bg-slate-900 text-white' : 'bg-white text-slate-500 hover:text-blue-700',
)}
>
{option.label}
</button>
))}
</div>
<div className="font-mono text-xs font-black text-slate-500">
{previewSlice + 1} / {Math.max(project?.dicomCount ?? 1, 1)}
</div>
</div>
<div className="max-h-72 overflow-y-auto">
{traceRows.length ? traceRows.map((item, index) => (
<div
key={`${item.iteration}-${item.mode}-${index}`}
className="grid grid-cols-[72px_1fr_100px_96px_96px] border-t border-slate-100 px-3 py-2 text-xs font-bold text-slate-600"
>
<span className="font-mono">{item.iteration + 1}</span>
<span className="truncate">{item.mode}</span>
<span className="font-mono text-slate-900">{item.score.toFixed(4)}</span>
<span className="font-mono text-emerald-600">{item.boneReward.toFixed(3)}</span>
<span className="font-mono">{item.contributors}</span>
<input
type="range"
min={0}
max={maxPreviewSlice}
value={Math.min(previewSlice, maxPreviewSlice)}
onChange={(event) => setPreviewSlice(Number(event.target.value))}
className="mb-4 w-full accent-blue-600"
/>
{selectedBoneFiles.length ? (
<div className="grid grid-cols-2 gap-4 max-2xl:grid-cols-1">
<div>
<div className="mb-2 text-xs font-black text-slate-500">姿</div>
<VoxelizationMappingView
project={project}
moduleStyles={currentModuleStyles}
modelPose={basePose}
detailLimit={360000}
slice={Math.min(previewSlice, maxPreviewSlice)}
totalSlices={Math.max(project?.dicomCount ?? 1, 1)}
onSliceChange={setPreviewSlice}
displayMode={mappingDisplayMode}
rotation={0}
variant="workspace"
overlayPlacement="bottom"
showSliceControl={false}
/>
</div>
)) : (
<div className="px-3 py-10 text-center text-sm font-bold text-slate-400">
<div>
<div className="mb-2 text-xs font-black text-slate-500"></div>
<VoxelizationMappingView
project={project}
moduleStyles={bestModuleStyles}
modelPose={comparePose}
detailLimit={360000}
slice={Math.min(previewSlice, maxPreviewSlice)}
totalSlices={Math.max(project?.dicomCount ?? 1, 1)}
onSliceChange={setPreviewSlice}
displayMode={mappingDisplayMode}
rotation={0}
variant="workspace"
overlayPlacement="bottom"
showSliceControl={false}
/>
</div>
)}
</div>
</div>
) : (
<div className="rounded-lg border border-dashed border-slate-300 bg-white px-4 py-12 text-center text-sm font-bold text-slate-400">
</div>
)}
</div>
</div>
</section>

View File

@@ -2227,6 +2227,7 @@ export function VoxelizationMappingView({
toolbar,
overlayPlacement,
onOverlayStatsChange,
showSliceControl = true,
}: {
project: Project | null;
moduleStyles: Record<string, ModuleStyle>;
@@ -2241,6 +2242,7 @@ export function VoxelizationMappingView({
toolbar?: React.ReactNode;
overlayPlacement?: 'bottom' | 'side' | 'none';
onOverlayStatsChange?: (stats: OverlayStats, visibleModuleCount: number) => void;
showSliceControl?: boolean;
}) {
const baseCanvasRef = useRef<HTMLCanvasElement | null>(null);
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
@@ -2593,7 +2595,7 @@ export function VoxelizationMappingView({
</div>
</div>
<div className={`grid min-h-0 flex-1 ${activeOverlayPlacement === 'side' ? 'grid-cols-[minmax(0,1fr)_188px]' : 'grid-cols-[minmax(0,1fr)_56px]'} bg-black`}>
<div className={`grid min-h-0 flex-1 ${showSliceControl ? (activeOverlayPlacement === 'side' ? 'grid-cols-[minmax(0,1fr)_188px]' : 'grid-cols-[minmax(0,1fr)_56px]') : 'grid-cols-1'} bg-black`}>
<div
className="flex min-h-0 flex-col"
>
@@ -2632,21 +2634,23 @@ export function VoxelizationMappingView({
{activeOverlayPlacement === 'bottom' && renderOverlaySummary('bottom')}
</div>
<aside className="flex min-h-0 flex-col items-center gap-3 border-l border-white/10 bg-[#0f172a] px-2 py-5">
<div className="relative min-h-[220px] w-8 flex-1">
<div className="absolute inset-y-0 left-1/2 w-1.5 -translate-x-1/2 rounded-full bg-white/10" />
<input
type="range"
min="0"
max={maxSlice}
value={sliderSliceValue}
onChange={(event) => onSliceChange(Number(event.target.value))}
className="mapping-slice-dark-vertical-input"
aria-label="项目库逆向分割映射视图切片导航"
/>
</div>
{activeOverlayPlacement === 'side' && renderOverlaySummary('side')}
</aside>
{showSliceControl && (
<aside className="flex min-h-0 flex-col items-center gap-3 border-l border-white/10 bg-[#0f172a] px-2 py-5">
<div className="relative min-h-[220px] w-8 flex-1">
<div className="absolute inset-y-0 left-1/2 w-1.5 -translate-x-1/2 rounded-full bg-white/10" />
<input
type="range"
min="0"
max={maxSlice}
value={sliderSliceValue}
onChange={(event) => onSliceChange(Number(event.target.value))}
className="mapping-slice-dark-vertical-input"
aria-label="项目库逆向分割映射视图切片导航"
/>
</div>
{activeOverlayPlacement === 'side' && renderOverlaySummary('side')}
</aside>
)}
</div>
</div>
);
@@ -2676,7 +2680,7 @@ export function VoxelizationMappingView({
</button>
</div>
<div className="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_110px]">
<div className={`grid min-h-0 flex-1 ${showSliceControl ? 'grid-cols-[minmax(0,1fr)_110px]' : 'grid-cols-1'}`}>
<div className="flex min-h-0 flex-col">
<div
ref={mappingViewportRef}
@@ -2734,47 +2738,49 @@ export function VoxelizationMappingView({
</div>
</div>
<aside className="flex min-h-0 flex-col items-center gap-3 border-l border-slate-100 bg-white px-3 py-4">
<div className="w-full rounded-2xl border border-slate-100 bg-white px-2 py-3 text-center shadow-sm">
<p className="text-[10px] font-bold text-slate-700">DICOM </p>
<span className="mt-1 block font-mono text-[10px] font-bold text-blue-600">
{displaySliceNumber} / {Math.max(totalSlices, 1)}
</span>
</div>
<button
onClick={() => stepSlice(1)}
disabled={safeSlice >= maxSlice}
className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-35"
title="上一层"
>
<ChevronUp size={16} />
</button>
<div className="relative min-h-[240px] w-10 flex-1">
<div className="absolute inset-y-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-slate-200" />
<input
type="range"
min="0"
max={maxSlice}
value={sliderSliceValue}
onChange={(event) => onSliceChange(Number(event.target.value))}
className="mapping-slice-vertical-input"
aria-label="逆向分割映射视图切片导航"
/>
</div>
<button
onClick={() => stepSlice(-1)}
disabled={safeSlice <= 0}
className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-35"
title="下一层"
>
<ChevronDown size={16} />
</button>
<div className="grid w-full grid-cols-1 gap-1 text-center text-[9px] font-bold text-slate-500">
<span> 1</span>
<span className="text-blue-600"> {displaySliceNumber}</span>
<span> {Math.max(totalSlices, 1)}</span>
</div>
</aside>
{showSliceControl && (
<aside className="flex min-h-0 flex-col items-center gap-3 border-l border-slate-100 bg-white px-3 py-4">
<div className="w-full rounded-2xl border border-slate-100 bg-white px-2 py-3 text-center shadow-sm">
<p className="text-[10px] font-bold text-slate-700">DICOM </p>
<span className="mt-1 block font-mono text-[10px] font-bold text-blue-600">
{displaySliceNumber} / {Math.max(totalSlices, 1)}
</span>
</div>
<button
onClick={() => stepSlice(1)}
disabled={safeSlice >= maxSlice}
className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-35"
title="上一层"
>
<ChevronUp size={16} />
</button>
<div className="relative min-h-[240px] w-10 flex-1">
<div className="absolute inset-y-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-slate-200" />
<input
type="range"
min="0"
max={maxSlice}
value={sliderSliceValue}
onChange={(event) => onSliceChange(Number(event.target.value))}
className="mapping-slice-vertical-input"
aria-label="逆向分割映射视图切片导航"
/>
</div>
<button
onClick={() => stepSlice(-1)}
disabled={safeSlice <= 0}
className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-35"
title="下一层"
>
<ChevronDown size={16} />
</button>
<div className="grid w-full grid-cols-1 gap-1 text-center text-[9px] font-bold text-slate-500">
<span> 1</span>
<span className="text-blue-600"> {displaySliceNumber}</span>
<span> {Math.max(totalSlices, 1)}</span>
</div>
</aside>
)}
</div>
</div>
);