Migrate registration workspace fusion mapping views
This commit is contained in:
@@ -20,6 +20,9 @@ const DEFAULT_POSE = {
|
|||||||
translateY: 0,
|
translateY: 0,
|
||||||
translateZ: 0,
|
translateZ: 0,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
|
scaleX: 1,
|
||||||
|
scaleY: 1,
|
||||||
|
scaleZ: 1,
|
||||||
flipX: false,
|
flipX: false,
|
||||||
flipY: false,
|
flipY: false,
|
||||||
flipZ: false,
|
flipZ: false,
|
||||||
@@ -61,9 +64,11 @@ const state = {
|
|||||||
dicomPreviewTimer: 0,
|
dicomPreviewTimer: 0,
|
||||||
autoResult: null,
|
autoResult: null,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
|
dirtyReason: "",
|
||||||
saving: false,
|
saving: false,
|
||||||
fusionVersion: 0,
|
fusionVersion: 0,
|
||||||
volumeMeta: null,
|
volumeMeta: null,
|
||||||
|
volumeScene: null,
|
||||||
sceneReady: false,
|
sceneReady: false,
|
||||||
renderer: null,
|
renderer: null,
|
||||||
camera: null,
|
camera: null,
|
||||||
@@ -72,8 +77,12 @@ const state = {
|
|||||||
dicomGroup: null,
|
dicomGroup: null,
|
||||||
modelGroup: null,
|
modelGroup: null,
|
||||||
rawModelGroup: null,
|
rawModelGroup: null,
|
||||||
|
modelMeshes: [],
|
||||||
baseModelScale: 1,
|
baseModelScale: 1,
|
||||||
dicomSceneBox: null,
|
dicomSceneBox: null,
|
||||||
|
mappingViewport: { scale: 1, offsetX: 0, offsetY: 0 },
|
||||||
|
mappingDrag: null,
|
||||||
|
mappingDrawFrame: 0,
|
||||||
resizeObserver: null,
|
resizeObserver: null,
|
||||||
resizeScene: null,
|
resizeScene: null,
|
||||||
};
|
};
|
||||||
@@ -112,13 +121,15 @@ function setAutoState(text, tone = "") {
|
|||||||
el.className = tone;
|
el.className = tone;
|
||||||
}
|
}
|
||||||
|
|
||||||
function markDirty() {
|
function markDirty(reason = "pose") {
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
|
state.dirtyReason = reason;
|
||||||
setSaveState("未保存", "warn");
|
setSaveState("未保存", "warn");
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetDirty() {
|
function resetDirty() {
|
||||||
state.dirty = false;
|
state.dirty = false;
|
||||||
|
state.dirtyReason = "";
|
||||||
setSaveState("已保存", "ok");
|
setSaveState("已保存", "ok");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +196,7 @@ async function bootstrap() {
|
|||||||
try {
|
try {
|
||||||
initScene();
|
initScene();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
initGeometryFallback();
|
||||||
$("fusionStatus").textContent = `WebGL 初始化失败:${error.message}`;
|
$("fusionStatus").textContent = `WebGL 初始化失败:${error.message}`;
|
||||||
console.warn(error);
|
console.warn(error);
|
||||||
}
|
}
|
||||||
@@ -242,6 +254,10 @@ function sameCase(a, b) {
|
|||||||
return normalize(a?.ct_number) === normalize(b?.ct_number) && (a?.algorithm_model || "未指定模型") === (b?.algorithm_model || "未指定模型");
|
return normalize(a?.ct_number) === normalize(b?.ct_number) && (a?.algorithm_model || "未指定模型") === (b?.algorithm_model || "未指定模型");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sameCtNumber(a, b) {
|
||||||
|
return normalize(a) === normalize(b);
|
||||||
|
}
|
||||||
|
|
||||||
function normalize(value) {
|
function normalize(value) {
|
||||||
return String(value || "").trim().toUpperCase();
|
return String(value || "").trim().toUpperCase();
|
||||||
}
|
}
|
||||||
@@ -263,6 +279,26 @@ function statusText(value) {
|
|||||||
return value === "registered" ? "已配准" : "未配准";
|
return value === "registered" ? "已配准" : "未配准";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seriesLabels(item) {
|
||||||
|
return Array.isArray(item?.annotation_labels) ? item.annotation_labels.map((label) => String(label || "")) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSkippedSeries(item) {
|
||||||
|
return seriesLabels(item).some((label) => label.includes("略过") || label.includes("不采用"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLabel(item, matcher) {
|
||||||
|
return seriesLabels(item).some((label) => matcher(String(label)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUpperAbdomenSeries(item) {
|
||||||
|
return hasLabel(item, (label) => label.includes("上腹部"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPortalPhaseSeries(item) {
|
||||||
|
return hasLabel(item, (label) => label.includes("上腹部") && (label.includes("门脉期") || label.includes("门静脉期")));
|
||||||
|
}
|
||||||
|
|
||||||
function bodyPartTags(row) {
|
function bodyPartTags(row) {
|
||||||
const labels = Array.isArray(row?.body_part_labels) ? row.body_part_labels : [];
|
const labels = Array.isArray(row?.body_part_labels) ? row.body_part_labels : [];
|
||||||
const keys = Array.isArray(row?.body_part_keys) ? row.body_part_keys : [];
|
const keys = Array.isArray(row?.body_part_keys) ? row.body_part_keys : [];
|
||||||
@@ -331,11 +367,17 @@ async function confirmChange() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function selectCase(ctNumber, algorithmModel = "未指定模型") {
|
async function selectCase(ctNumber, algorithmModel = "未指定模型") {
|
||||||
if (!ctNumber || !(await confirmChange())) return;
|
if (!ctNumber) return;
|
||||||
|
const normalizedAlgorithm = algorithmModel || "未指定模型";
|
||||||
|
const switchingModelOnly = state.activeCase
|
||||||
|
&& sameCtNumber(state.activeCase.ct_number, ctNumber)
|
||||||
|
&& (state.algorithmModel || "未指定模型") !== normalizedAlgorithm;
|
||||||
|
const previousPose = { ...state.pose };
|
||||||
|
if (!switchingModelOnly && !(await confirmChange())) return;
|
||||||
setTopLoading(true);
|
setTopLoading(true);
|
||||||
setFusionLoading(true, "正在读取 CT 关联数据");
|
setFusionLoading(true, "正在读取 CT 关联数据");
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ algorithm_model: algorithmModel || "未指定模型" });
|
const params = new URLSearchParams({ algorithm_model: normalizedAlgorithm });
|
||||||
const [detail, seriesPayload, stlPayload, registration] = await Promise.all([
|
const [detail, seriesPayload, stlPayload, registration] = await Promise.all([
|
||||||
api(`/api/cases/${encodeURIComponent(ctNumber)}?${params.toString()}`),
|
api(`/api/cases/${encodeURIComponent(ctNumber)}?${params.toString()}`),
|
||||||
api(`/api/cases/${encodeURIComponent(ctNumber)}/series`),
|
api(`/api/cases/${encodeURIComponent(ctNumber)}/series`),
|
||||||
@@ -347,7 +389,9 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
|
|||||||
state.series = seriesPayload.series || [];
|
state.series = seriesPayload.series || [];
|
||||||
state.stlFiles = stlPayload.files || [];
|
state.stlFiles = stlPayload.files || [];
|
||||||
state.registrationStatus = registration.registration_status || "unregistered";
|
state.registrationStatus = registration.registration_status || "unregistered";
|
||||||
state.pose = { ...DEFAULT_POSE, ...(registration.transform || {}) };
|
const savedTransform = registration.transform || {};
|
||||||
|
const keepPreviousPose = switchingModelOnly;
|
||||||
|
state.pose = keepPreviousPose ? { ...DEFAULT_POSE, ...previousPose } : { ...DEFAULT_POSE, ...savedTransform };
|
||||||
state.autoResult = null;
|
state.autoResult = null;
|
||||||
state.sliceIndex = 0;
|
state.sliceIndex = 0;
|
||||||
$("notes").value = registration.notes || "";
|
$("notes").value = registration.notes || "";
|
||||||
@@ -363,7 +407,8 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
|
|||||||
renderSeries();
|
renderSeries();
|
||||||
renderStl();
|
renderStl();
|
||||||
renderPoseControls();
|
renderPoseControls();
|
||||||
resetDirty();
|
if (keepPreviousPose) markDirty("model");
|
||||||
|
else resetDirty();
|
||||||
await loadFusion();
|
await loadFusion();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
$("fusionStatus").textContent = error.message;
|
$("fusionStatus").textContent = error.message;
|
||||||
@@ -375,9 +420,27 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pickDefaultSeries(series) {
|
function pickDefaultSeries(series) {
|
||||||
return [...series]
|
const candidates = [...series].filter((item) => Number(item.count || 0) > 1);
|
||||||
.filter((item) => Number(item.count || 0) > 1)
|
const usable = candidates.filter((item) => !isSkippedSeries(item));
|
||||||
.sort((a, b) => Number(b.count || 0) - Number(a.count || 0))[0] || series[0] || null;
|
const pool = usable.length ? usable : candidates;
|
||||||
|
const algorithm = String(state.algorithmModel || "").toLowerCase();
|
||||||
|
const rank = (item) => {
|
||||||
|
let score = Number(item.count || 0) / 10000;
|
||||||
|
if (algorithm.includes("肝胆") || algorithm.includes("liver") || algorithm.includes("bile")) {
|
||||||
|
if (isPortalPhaseSeries(item)) score += 900;
|
||||||
|
else if (isUpperAbdomenSeries(item)) score += 720;
|
||||||
|
} else if (isUpperAbdomenSeries(item)) {
|
||||||
|
score += 120;
|
||||||
|
}
|
||||||
|
if (isSkippedSeries(item)) score -= 1000;
|
||||||
|
return score;
|
||||||
|
};
|
||||||
|
return [...pool]
|
||||||
|
.sort((a, b) => {
|
||||||
|
const diff = rank(b) - rank(a);
|
||||||
|
if (Math.abs(diff) > 1e-6) return diff;
|
||||||
|
return Number(a.series_number || 999999) - Number(b.series_number || 999999);
|
||||||
|
})[0] || series[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickDefaultStlIds(files) {
|
function pickDefaultStlIds(files) {
|
||||||
@@ -437,8 +500,9 @@ function renderSeries() {
|
|||||||
function seriesCardHtml(item, active = false) {
|
function seriesCardHtml(item, active = false) {
|
||||||
const labels = (item.annotation_labels || []).map((label) => `<i>${escapeHtml(label)}</i>`).join("");
|
const labels = (item.annotation_labels || []).map((label) => `<i>${escapeHtml(label)}</i>`).join("");
|
||||||
const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean);
|
const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean);
|
||||||
|
const skipped = isSkippedSeries(item);
|
||||||
return `
|
return `
|
||||||
<button class="series-card ${active ? "active" : ""}" data-series="${escapeHtml(item.series_uid)}" title="${escapeHtml(item.description)}">
|
<button class="series-card ${active ? "active" : ""} ${skipped ? "skipped" : ""}" data-series="${escapeHtml(item.series_uid)}" title="${escapeHtml(item.description)}">
|
||||||
<strong>${escapeHtml(item.description || "未命名序列")}</strong>
|
<strong>${escapeHtml(item.description || "未命名序列")}</strong>
|
||||||
<span>拍摄 ${escapeHtml(time.length > 1 ? `${time[0]}-${time[1]}` : time[0] || "-")}</span>
|
<span>拍摄 ${escapeHtml(time.length > 1 ? `${time[0]}-${time[1]}` : time[0] || "-")}</span>
|
||||||
<small>${escapeHtml(item.slice_thickness || "厚度未知")} · 序列 ${escapeHtml(item.series_number || "-")} · ${escapeHtml(item.rows || "-")}×${escapeHtml(item.columns || "-")} · ${escapeHtml(item.modality || "CT")} · ${Number(item.count || 0)} 张</small>
|
<small>${escapeHtml(item.slice_thickness || "厚度未知")} · 序列 ${escapeHtml(item.series_number || "-")} · ${escapeHtml(item.rows || "-")}×${escapeHtml(item.columns || "-")} · ${escapeHtml(item.modality || "CT")} · ${Number(item.count || 0)} 张</small>
|
||||||
@@ -576,9 +640,14 @@ function renderPoseControls() {
|
|||||||
function updateSliceControl() {
|
function updateSliceControl() {
|
||||||
const selected = currentSeries();
|
const selected = currentSeries();
|
||||||
const count = Number(selected?.count || 0);
|
const count = Number(selected?.count || 0);
|
||||||
$("sliceSlider").max = Math.max(0, count - 1);
|
const max = Math.max(0, count - 1);
|
||||||
$("sliceSlider").value = Math.min(state.sliceIndex, Math.max(0, count - 1));
|
state.sliceIndex = Math.max(0, Math.min(state.sliceIndex, max));
|
||||||
$("sliceLabel").textContent = count ? `切片 ${Number($("sliceSlider").value) + 1} / ${count}` : "切片 0 / 0";
|
$("sliceSlider").max = max;
|
||||||
|
$("sliceSlider").value = state.sliceIndex;
|
||||||
|
$("sliceLabel").textContent = count ? `切片 ${state.sliceIndex + 1} / ${count}` : "切片 0 / 0";
|
||||||
|
$("mappingSliceSlider").max = max;
|
||||||
|
$("mappingSliceSlider").value = max - state.sliceIndex;
|
||||||
|
$("mappingSliceLabel").textContent = count ? `${state.sliceIndex + 1} / ${count}` : "0 / 0";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDicomAnnotation() {
|
function renderDicomAnnotation() {
|
||||||
@@ -604,6 +673,7 @@ function updateDicomPreview() {
|
|||||||
if (!state.activeCase || !selected) {
|
if (!state.activeCase || !selected) {
|
||||||
$("dicomPreview").removeAttribute("src");
|
$("dicomPreview").removeAttribute("src");
|
||||||
renderDicomAnnotation();
|
renderDicomAnnotation();
|
||||||
|
resetMappingCanvas("未选择 DICOM 序列");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.clearTimeout(state.dicomPreviewTimer);
|
window.clearTimeout(state.dicomPreviewTimer);
|
||||||
@@ -616,6 +686,7 @@ function updateDicomPreview() {
|
|||||||
access_token: state.token,
|
access_token: state.token,
|
||||||
});
|
});
|
||||||
$("dicomLoading").classList.remove("hidden");
|
$("dicomLoading").classList.remove("hidden");
|
||||||
|
resetMappingCanvas("正在加载 DICOM Base Layer");
|
||||||
$("dicomPreview").src = `/api/dicom/image?${params.toString()}`;
|
$("dicomPreview").src = `/api/dicom/image?${params.toString()}`;
|
||||||
renderDicomAnnotation();
|
renderDicomAnnotation();
|
||||||
}, 80);
|
}, 80);
|
||||||
@@ -672,6 +743,15 @@ function initScene() {
|
|||||||
animate();
|
animate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initGeometryFallback() {
|
||||||
|
if (state.dicomGroup && state.modelGroup && state.rawModelGroup) return;
|
||||||
|
const dicomGroup = new THREE.Group();
|
||||||
|
const modelGroup = new THREE.Group();
|
||||||
|
const rawModelGroup = new THREE.Group();
|
||||||
|
modelGroup.add(rawModelGroup);
|
||||||
|
Object.assign(state, { dicomGroup, modelGroup, rawModelGroup });
|
||||||
|
}
|
||||||
|
|
||||||
function clearGroup(group) {
|
function clearGroup(group) {
|
||||||
if (!group) return;
|
if (!group) return;
|
||||||
while (group.children.length) {
|
while (group.children.length) {
|
||||||
@@ -691,9 +771,12 @@ function clearFusion() {
|
|||||||
clearGroup(state.dicomGroup);
|
clearGroup(state.dicomGroup);
|
||||||
clearGroup(state.rawModelGroup);
|
clearGroup(state.rawModelGroup);
|
||||||
state.volumeMeta = null;
|
state.volumeMeta = null;
|
||||||
|
state.volumeScene = null;
|
||||||
|
state.modelMeshes = [];
|
||||||
$("fusionMeta").textContent = "DICOM - · STL -";
|
$("fusionMeta").textContent = "DICOM - · STL -";
|
||||||
$("fusionStatus").textContent = "等待选择 DICOM 与 STL";
|
$("fusionStatus").textContent = "等待选择 DICOM 与 STL";
|
||||||
updateSliceControl();
|
updateSliceControl();
|
||||||
|
resetMappingCanvas();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFusionMode() {
|
function applyFusionMode() {
|
||||||
@@ -707,10 +790,7 @@ function applyFusionMode() {
|
|||||||
async function loadFusion() {
|
async function loadFusion() {
|
||||||
const version = ++state.fusionVersion;
|
const version = ++state.fusionVersion;
|
||||||
const selected = currentSeries();
|
const selected = currentSeries();
|
||||||
if (!state.sceneReady) {
|
if (!state.sceneReady) initGeometryFallback();
|
||||||
$("fusionStatus").textContent = "当前浏览器未能启用 WebGL,无法显示三维融合视图";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!state.activeCase || !selected) {
|
if (!state.activeCase || !selected) {
|
||||||
clearFusion();
|
clearFusion();
|
||||||
return;
|
return;
|
||||||
@@ -732,9 +812,13 @@ async function loadFusion() {
|
|||||||
if (version !== state.fusionVersion) return;
|
if (version !== state.fusionVersion) return;
|
||||||
await buildStlModels(volume);
|
await buildStlModels(volume);
|
||||||
if (version !== state.fusionVersion) return;
|
if (version !== state.fusionVersion) return;
|
||||||
|
if (state.sceneReady) {
|
||||||
applyFusionMode();
|
applyFusionMode();
|
||||||
fitCamera();
|
fitCamera();
|
||||||
$("fusionStatus").textContent = `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)} 张`;
|
}
|
||||||
|
$("fusionStatus").textContent = state.sceneReady
|
||||||
|
? `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)} 张`
|
||||||
|
: `${selected.description || "DICOM 序列"} · 2D 映射已就绪,当前浏览器未启用 WebGL`;
|
||||||
$("fusionMeta").textContent = `DICOM ${volume.indices?.length || 0} 张局部体 · STL ${state.selectedStlIds.size} 个`;
|
$("fusionMeta").textContent = `DICOM ${volume.indices?.length || 0} 张局部体 · STL ${state.selectedStlIds.size} 个`;
|
||||||
renderDicomAnnotation();
|
renderDicomAnnotation();
|
||||||
updateDicomPreview();
|
updateDicomPreview();
|
||||||
@@ -752,49 +836,370 @@ async function loadTexture(url) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadImageData(url, maxSize = 256) {
|
||||||
|
const image = await new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
const scale = Math.min(1, maxSize / Math.max(image.naturalWidth || image.width, image.naturalHeight || image.height, 1));
|
||||||
|
const width = Math.max(1, Math.round((image.naturalWidth || image.width) * scale));
|
||||||
|
const height = Math.max(1, Math.round((image.naturalHeight || image.height) * scale));
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||||
|
context.drawImage(image, 0, 0, width, height);
|
||||||
|
return { width, height, data: context.getImageData(0, 0, width, height).data };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sliceToSceneZ(sliceIndex, volumeScene = state.volumeScene) {
|
||||||
|
if (!volumeScene) return 0;
|
||||||
|
return volumeScene.total <= 1
|
||||||
|
? 0
|
||||||
|
: -volumeScene.depth / 2 + (volumeScene.depth * Math.max(0, Math.min(sliceIndex, volumeScene.total - 1))) / (volumeScene.total - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCssColor(color) {
|
||||||
|
const value = String(color || "#19d6c3").replace("#", "");
|
||||||
|
const hex = value.length === 3 ? value.split("").map((char) => char + char).join("") : value.padStart(6, "0").slice(0, 6);
|
||||||
|
const intValue = Number.parseInt(hex, 16);
|
||||||
|
return {
|
||||||
|
r: (intValue >> 16) & 255,
|
||||||
|
g: (intValue >> 8) & 255,
|
||||||
|
b: intValue & 255,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetMappingCanvas(message = "当前切片暂无可见构件") {
|
||||||
|
const canvas = $("mappingCanvas");
|
||||||
|
const context = canvas?.getContext("2d");
|
||||||
|
if (context) context.clearRect(0, 0, canvas.width || 1, canvas.height || 1);
|
||||||
|
$("mappingStats").textContent = "0/0 构件 · 0 边 · 0 px";
|
||||||
|
$("mappingLegend").innerHTML = `<div class="empty-state">${escapeHtml(message)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleMappingDraw() {
|
||||||
|
window.cancelAnimationFrame(state.mappingDrawFrame);
|
||||||
|
state.mappingDrawFrame = window.requestAnimationFrame(() => drawMappingView());
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMappingTransform() {
|
||||||
|
const layer = $("mappingLayer");
|
||||||
|
if (!layer) return;
|
||||||
|
const { scale, offsetX, offsetY } = state.mappingViewport;
|
||||||
|
layer.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(${scale})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function intersectEdgeWithZ(a, b, targetZ) {
|
||||||
|
const da = a.z - targetZ;
|
||||||
|
const db = b.z - targetZ;
|
||||||
|
if (Math.abs(da) < 1e-5 && Math.abs(db) < 1e-5) return [a.clone(), b.clone()];
|
||||||
|
if (Math.abs(da) < 1e-5) return [a.clone()];
|
||||||
|
if (Math.abs(db) < 1e-5) return [b.clone()];
|
||||||
|
if ((da > 0 && db > 0) || (da < 0 && db < 0)) return [];
|
||||||
|
const t = da / (da - db);
|
||||||
|
return [new THREE.Vector3(
|
||||||
|
a.x + (b.x - a.x) * t,
|
||||||
|
a.y + (b.y - a.y) * t,
|
||||||
|
targetZ,
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniquePlanePoints(points) {
|
||||||
|
const unique = [];
|
||||||
|
points.forEach((point) => {
|
||||||
|
if (!unique.some((item) => item.distanceToSquared(point) < 1e-8)) unique.push(point);
|
||||||
|
});
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
function intersectTriangleWithZ(a, b, c, targetZ) {
|
||||||
|
const points = uniquePlanePoints([
|
||||||
|
...intersectEdgeWithZ(a, b, targetZ),
|
||||||
|
...intersectEdgeWithZ(b, c, targetZ),
|
||||||
|
...intersectEdgeWithZ(c, a, targetZ),
|
||||||
|
]);
|
||||||
|
if (points.length < 2) return null;
|
||||||
|
return { a: points[0], b: points[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSegmentIntersectionsToRows(rows, width, height, segment) {
|
||||||
|
const a = segment.a;
|
||||||
|
const b = segment.b;
|
||||||
|
if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) return;
|
||||||
|
const minY = Math.max(0, Math.floor(Math.min(a.y, b.y)));
|
||||||
|
const maxY = Math.min(height - 1, Math.ceil(Math.max(a.y, b.y)));
|
||||||
|
const deltaY = b.y - a.y;
|
||||||
|
if (Math.abs(deltaY) < 1e-6) {
|
||||||
|
const row = Math.max(0, Math.min(height - 1, Math.round(a.y)));
|
||||||
|
rows[row].push(Math.max(0, Math.min(width - 1, a.x)));
|
||||||
|
rows[row].push(Math.max(0, Math.min(width - 1, b.x)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let row = minY; row <= maxY; row += 1) {
|
||||||
|
const sampleY = row + 0.5;
|
||||||
|
const t = (sampleY - a.y) / deltaY;
|
||||||
|
if (t < 0 || t > 1) continue;
|
||||||
|
const x = a.x + (b.x - a.x) * t;
|
||||||
|
if (Number.isFinite(x)) rows[row].push(x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillSegmentsAsSolidMask(context, width, height, segments, color, opacity = 0.72) {
|
||||||
|
if (!segments.length) return 0;
|
||||||
|
const rgb = parseCssColor(color);
|
||||||
|
const maskCanvas = document.createElement("canvas");
|
||||||
|
maskCanvas.width = width;
|
||||||
|
maskCanvas.height = height;
|
||||||
|
const maskContext = maskCanvas.getContext("2d");
|
||||||
|
const imageData = maskContext.createImageData(width, height);
|
||||||
|
const rows = Array.from({ length: height }, () => []);
|
||||||
|
const alpha = Math.round(Math.max(0.12, Math.min(opacity, 1)) * 190);
|
||||||
|
let filled = 0;
|
||||||
|
|
||||||
|
segments.forEach((segment) => addSegmentIntersectionsToRows(rows, width, height, segment));
|
||||||
|
rows.forEach((intersections, y) => {
|
||||||
|
if (intersections.length < 2) return;
|
||||||
|
intersections.sort((a, b) => a - b);
|
||||||
|
const cleaned = [];
|
||||||
|
intersections.forEach((x) => {
|
||||||
|
const previous = cleaned[cleaned.length - 1];
|
||||||
|
if (previous === undefined || Math.abs(previous - x) > 0.35) cleaned.push(x);
|
||||||
|
});
|
||||||
|
for (let index = 0; index + 1 < cleaned.length; index += 2) {
|
||||||
|
const startX = Math.max(0, Math.min(width - 1, Math.ceil(cleaned[index])));
|
||||||
|
const endX = Math.max(0, Math.min(width - 1, Math.floor(cleaned[index + 1])));
|
||||||
|
if (endX < startX) continue;
|
||||||
|
for (let x = startX; x <= endX; x += 1) {
|
||||||
|
const offset = (y * width + x) * 4;
|
||||||
|
imageData.data[offset] = rgb.r;
|
||||||
|
imageData.data[offset + 1] = rgb.g;
|
||||||
|
imageData.data[offset + 2] = rgb.b;
|
||||||
|
imageData.data[offset + 3] = alpha;
|
||||||
|
filled += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
maskContext.putImageData(imageData, 0, 0);
|
||||||
|
context.drawImage(maskCanvas, 0, 0);
|
||||||
|
context.save();
|
||||||
|
context.globalAlpha = Math.max(0.18, Math.min(opacity, 1));
|
||||||
|
context.strokeStyle = color;
|
||||||
|
context.lineWidth = Math.max(0.8, Math.max(width, height) * 0.0012);
|
||||||
|
context.lineCap = "round";
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.beginPath();
|
||||||
|
segments.forEach((segment) => {
|
||||||
|
context.moveTo(segment.a.x, segment.a.y);
|
||||||
|
context.lineTo(segment.b.x, segment.b.y);
|
||||||
|
});
|
||||||
|
context.stroke();
|
||||||
|
context.restore();
|
||||||
|
return filled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMappingView() {
|
||||||
|
const image = $("dicomPreview");
|
||||||
|
const canvas = $("mappingCanvas");
|
||||||
|
const volumeScene = state.volumeScene;
|
||||||
|
if (!image?.complete || !image.naturalWidth || !volumeScene || !state.modelMeshes.length) {
|
||||||
|
resetMappingCanvas(state.modelMeshes.length ? "等待 DICOM Base Layer" : "当前没有可见 STL 构件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = image.naturalWidth;
|
||||||
|
const height = image.naturalHeight;
|
||||||
|
if (canvas.width !== width) canvas.width = width;
|
||||||
|
if (canvas.height !== height) canvas.height = height;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
context.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
const targetZ = sliceToSceneZ(state.sliceIndex, volumeScene);
|
||||||
|
const mapPoint = (point) => ({
|
||||||
|
x: ((point.x + volumeScene.width / 2) / volumeScene.width) * width,
|
||||||
|
y: (0.5 - point.y / volumeScene.height) * height,
|
||||||
|
});
|
||||||
|
state.modelGroup?.updateMatrixWorld(true);
|
||||||
|
|
||||||
|
let activeModules = 0;
|
||||||
|
let segmentCount = 0;
|
||||||
|
let filledPixels = 0;
|
||||||
|
const modules = [];
|
||||||
|
const tempA = new THREE.Vector3();
|
||||||
|
const tempB = new THREE.Vector3();
|
||||||
|
const tempC = new THREE.Vector3();
|
||||||
|
|
||||||
|
state.modelMeshes.forEach((record, index) => {
|
||||||
|
const position = record.mesh.geometry.attributes.position;
|
||||||
|
if (!position) return;
|
||||||
|
const triangleCount = Math.floor(position.count / 3);
|
||||||
|
const step = Math.max(1, Math.ceil(triangleCount / 70000));
|
||||||
|
const segments = [];
|
||||||
|
for (let tri = 0; tri < triangleCount; tri += step) {
|
||||||
|
tempA.fromBufferAttribute(position, tri * 3);
|
||||||
|
tempB.fromBufferAttribute(position, tri * 3 + 1);
|
||||||
|
tempC.fromBufferAttribute(position, tri * 3 + 2);
|
||||||
|
record.mesh.localToWorld(tempA);
|
||||||
|
record.mesh.localToWorld(tempB);
|
||||||
|
record.mesh.localToWorld(tempC);
|
||||||
|
const segment = intersectTriangleWithZ(tempA, tempB, tempC, targetZ);
|
||||||
|
if (!segment) continue;
|
||||||
|
const mapped = { a: mapPoint(segment.a), b: mapPoint(segment.b) };
|
||||||
|
if (
|
||||||
|
mapped.a.x > -width && mapped.a.x < width * 2
|
||||||
|
&& mapped.b.x > -width && mapped.b.x < width * 2
|
||||||
|
&& mapped.a.y > -height && mapped.a.y < height * 2
|
||||||
|
&& mapped.b.y > -height && mapped.b.y < height * 2
|
||||||
|
) {
|
||||||
|
segments.push(mapped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pixels = fillSegmentsAsSolidMask(context, width, height, segments, record.color, 0.66);
|
||||||
|
if (segments.length || pixels) {
|
||||||
|
activeModules += 1;
|
||||||
|
segmentCount += segments.length;
|
||||||
|
filledPixels += pixels;
|
||||||
|
modules.push({
|
||||||
|
id: index + 1,
|
||||||
|
name: record.file.segment_name || record.file.file_name || `STL ${index + 1}`,
|
||||||
|
color: record.color,
|
||||||
|
segments: segments.length,
|
||||||
|
pixels,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$("mappingStats").textContent = `${activeModules}/${state.modelMeshes.length} 构件 · ${segmentCount} 边 · ${filledPixels} px`;
|
||||||
|
$("mappingLegend").innerHTML = modules.length
|
||||||
|
? modules.map((item) => `
|
||||||
|
<div class="legend-item" title="${escapeHtml(item.name)}">
|
||||||
|
<span class="legend-color" style="background:${escapeHtml(item.color)}"></span>
|
||||||
|
<span class="legend-name">${escapeHtml(item.name)}</span>
|
||||||
|
<span class="legend-meta">ID ${item.id}</span>
|
||||||
|
<span></span>
|
||||||
|
<span class="legend-meta">${item.segments} 边</span>
|
||||||
|
<span class="legend-meta">${item.pixels} px</span>
|
||||||
|
</div>
|
||||||
|
`).join("")
|
||||||
|
: `<div class="empty-state">当前切片暂无可见构件</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
async function buildDicomVolume(volume) {
|
async function buildDicomVolume(volume) {
|
||||||
clearGroup(state.dicomGroup);
|
clearGroup(state.dicomGroup);
|
||||||
const physical = volume.physicalSize || { width: 1, height: 1, depth: 1 };
|
const physical = volume.physicalSize || { width: 1, height: 1, depth: 1 };
|
||||||
const maxPhysical = Math.max(physical.width || 1, physical.height || 1, physical.depth || 1, 1);
|
const maxPhysical = Math.max(physical.width || 1, physical.height || 1, physical.depth || 1, 1);
|
||||||
const sceneScale = 5.2 / maxPhysical;
|
const sceneScale = 4.8 / maxPhysical;
|
||||||
const width = (physical.width || 1) * sceneScale;
|
const width = (physical.width || 1) * sceneScale;
|
||||||
const height = (physical.height || 1) * sceneScale;
|
const height = (physical.height || 1) * sceneScale;
|
||||||
const depth = (physical.depth || 1) * sceneScale;
|
const depth = Math.max((physical.depth || 1) * sceneScale, 0.18);
|
||||||
const centerIndex = Number(volume.center || 0);
|
const centerIndex = Number(volume.center || 0);
|
||||||
|
const total = Math.max(1, Number(volume.total || 1));
|
||||||
const indices = volume.indices || [];
|
const indices = volume.indices || [];
|
||||||
const centerPosition = indices.length ? Math.floor(indices.length / 2) : 0;
|
const centerPosition = indices.length ? Math.floor(indices.length / 2) : 0;
|
||||||
const textures = await Promise.all((volume.frames || []).map((frame) => loadTexture(frame)));
|
state.volumeScene = {
|
||||||
textures.forEach((texture, index) => {
|
width,
|
||||||
|
height,
|
||||||
|
depth,
|
||||||
|
total,
|
||||||
|
sceneScale,
|
||||||
|
rowSpacing: Number(volume.spacing?.row || 1),
|
||||||
|
columnSpacing: Number(volume.spacing?.column || 1),
|
||||||
|
sliceSpacing: Number(volume.spacing?.slice || 1),
|
||||||
|
};
|
||||||
|
|
||||||
|
const boxMesh = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(width, height, depth),
|
||||||
|
new THREE.MeshBasicMaterial({ color: 0x020617, transparent: true, opacity: 0.045, depthWrite: false }),
|
||||||
|
);
|
||||||
|
state.dicomGroup.add(boxMesh);
|
||||||
|
const edges = new THREE.LineSegments(
|
||||||
|
new THREE.EdgesGeometry(boxMesh.geometry),
|
||||||
|
new THREE.LineBasicMaterial({ color: 0x38bdf8, transparent: true, opacity: 0.46 }),
|
||||||
|
);
|
||||||
|
state.dicomGroup.add(edges);
|
||||||
|
|
||||||
|
const localStart = Number(volume.start ?? indices[0] ?? centerIndex);
|
||||||
|
const localEnd = Number(volume.end ?? indices[indices.length - 1] ?? centerIndex);
|
||||||
|
const rangeDepth = Math.max(0.015, Math.abs(sliceToSceneZ(localEnd) - sliceToSceneZ(localStart)));
|
||||||
|
const rangeBox = new THREE.LineSegments(
|
||||||
|
new THREE.EdgesGeometry(new THREE.BoxGeometry(width * 0.94, height * 0.94, rangeDepth)),
|
||||||
|
new THREE.LineBasicMaterial({ color: 0xfacc15, transparent: true, opacity: 0.56 }),
|
||||||
|
);
|
||||||
|
rangeBox.position.z = (sliceToSceneZ(localStart) + sliceToSceneZ(localEnd)) / 2;
|
||||||
|
state.dicomGroup.add(rangeBox);
|
||||||
|
|
||||||
|
const centerFrame = (volume.frames || [])[centerPosition];
|
||||||
|
if (centerFrame) {
|
||||||
|
const texture = await loadTexture(centerFrame);
|
||||||
texture.colorSpace = THREE.SRGBColorSpace;
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
const material = new THREE.MeshBasicMaterial({
|
const plane = new THREE.Mesh(
|
||||||
|
new THREE.PlaneGeometry(width, height),
|
||||||
|
new THREE.MeshBasicMaterial({
|
||||||
map: texture,
|
map: texture,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: index === centerPosition ? 0.78 : 0.14,
|
opacity: 0.42,
|
||||||
side: THREE.DoubleSide,
|
side: THREE.DoubleSide,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
});
|
}),
|
||||||
const plane = new THREE.Mesh(new THREE.PlaneGeometry(width, height), material);
|
);
|
||||||
const z = (Number(indices[index] ?? centerIndex) - centerIndex) * Number(volume.spacing?.slice || 1) * sceneScale;
|
plane.position.z = sliceToSceneZ(centerIndex) + 0.006;
|
||||||
plane.position.z = z;
|
|
||||||
state.dicomGroup.add(plane);
|
state.dicomGroup.add(plane);
|
||||||
});
|
}
|
||||||
const box = new THREE.BoxGeometry(width, height, Math.max(depth, 0.02));
|
|
||||||
const edges = new THREE.EdgesGeometry(box);
|
const pointPositions = [];
|
||||||
const lines = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0x1cd9c7, transparent: true, opacity: 0.42 }));
|
const pointColors = [];
|
||||||
state.dicomGroup.add(lines);
|
const frames = volume.frames || [];
|
||||||
|
for (const [index, frame] of frames.entries()) {
|
||||||
|
const image = await loadImageData(frame, 180);
|
||||||
|
const stride = Math.max(3, Math.round(Math.max(image.width, image.height) / 72));
|
||||||
|
const z = sliceToSceneZ(Number(indices[index] ?? centerIndex));
|
||||||
|
for (let y = 0; y < image.height; y += stride) {
|
||||||
|
for (let x = 0; x < image.width; x += stride) {
|
||||||
|
const offset = (y * image.width + x) * 4;
|
||||||
|
const lum = image.data[offset];
|
||||||
|
if (lum < 12) continue;
|
||||||
|
pointPositions.push((x / Math.max(image.width - 1, 1) - 0.5) * width);
|
||||||
|
pointPositions.push((0.5 - y / Math.max(image.height - 1, 1)) * height);
|
||||||
|
pointPositions.push(z);
|
||||||
|
const v = 0.16 + (lum / 255) * 0.72;
|
||||||
|
pointColors.push(v * 0.92, v, Math.min(1, v * 1.08));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pointPositions.length) {
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
geometry.setAttribute("position", new THREE.Float32BufferAttribute(pointPositions, 3));
|
||||||
|
geometry.setAttribute("color", new THREE.Float32BufferAttribute(pointColors, 3));
|
||||||
|
const points = new THREE.Points(
|
||||||
|
geometry,
|
||||||
|
new THREE.PointsMaterial({
|
||||||
|
size: 0.018,
|
||||||
|
vertexColors: true,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.34,
|
||||||
|
depthWrite: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
state.dicomGroup.add(points);
|
||||||
|
}
|
||||||
|
|
||||||
state.baseModelScale = sceneScale;
|
state.baseModelScale = sceneScale;
|
||||||
state.dicomSceneBox = new THREE.Box3().setFromObject(state.dicomGroup);
|
state.dicomSceneBox = new THREE.Box3().setFromObject(state.dicomGroup);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildStlModels(volume) {
|
async function buildStlModels(volume) {
|
||||||
clearGroup(state.rawModelGroup);
|
clearGroup(state.rawModelGroup);
|
||||||
|
state.modelMeshes = [];
|
||||||
const files = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id)));
|
const files = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id)));
|
||||||
if (!files.length) {
|
if (!files.length) {
|
||||||
applyPose();
|
applyPose();
|
||||||
|
resetMappingCanvas("当前没有可见 STL 构件");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const loader = new STLLoader();
|
const loader = new STLLoader();
|
||||||
const token = encodeURIComponent(state.token);
|
|
||||||
for (const [index, file] of files.entries()) {
|
for (const [index, file] of files.entries()) {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
ct_number: state.activeCase.ct_number,
|
ct_number: state.activeCase.ct_number,
|
||||||
@@ -816,13 +1221,17 @@ async function buildStlModels(volume) {
|
|||||||
});
|
});
|
||||||
const mesh = new THREE.Mesh(geometry, material);
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
mesh.name = file.segment_name || file.file_name;
|
mesh.name = file.segment_name || file.file_name;
|
||||||
|
mesh.userData.file = file;
|
||||||
|
mesh.userData.color = cssColor(index);
|
||||||
state.rawModelGroup.add(mesh);
|
state.rawModelGroup.add(mesh);
|
||||||
|
state.modelMeshes.push({ mesh, file, color: cssColor(index), index });
|
||||||
}
|
}
|
||||||
const bbox = new THREE.Box3().setFromObject(state.rawModelGroup);
|
const bbox = new THREE.Box3().setFromObject(state.rawModelGroup);
|
||||||
const center = new THREE.Vector3();
|
const center = new THREE.Vector3();
|
||||||
bbox.getCenter(center);
|
bbox.getCenter(center);
|
||||||
state.rawModelGroup.position.set(-center.x, -center.y, -center.z);
|
state.rawModelGroup.position.set(-center.x, -center.y, -center.z);
|
||||||
applyPose();
|
applyPose();
|
||||||
|
scheduleMappingDraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPose(poseInput = state.pose) {
|
function applyPose(poseInput = state.pose) {
|
||||||
@@ -835,7 +1244,13 @@ function applyPose(poseInput = state.pose) {
|
|||||||
);
|
);
|
||||||
state.modelGroup.position.set(Number(pose.translateX) || 0, Number(pose.translateY) || 0, Number(pose.translateZ) || 0);
|
state.modelGroup.position.set(Number(pose.translateX) || 0, Number(pose.translateY) || 0, Number(pose.translateZ) || 0);
|
||||||
const scale = Math.max(0.001, Number(pose.scale) || 1) * state.baseModelScale;
|
const scale = Math.max(0.001, Number(pose.scale) || 1) * state.baseModelScale;
|
||||||
state.modelGroup.scale.set(scale * (pose.flipX ? -1 : 1), scale * (pose.flipY ? -1 : 1), scale * (pose.flipZ ? -1 : 1));
|
state.modelGroup.scale.set(
|
||||||
|
scale * Math.max(0.001, Number(pose.scaleX) || 1) * (pose.flipX ? -1 : 1),
|
||||||
|
scale * Math.max(0.001, Number(pose.scaleY) || 1) * (pose.flipY ? -1 : 1),
|
||||||
|
scale * Math.max(0.001, Number(pose.scaleZ) || 1) * (pose.flipZ ? -1 : 1),
|
||||||
|
);
|
||||||
|
state.modelGroup.updateMatrixWorld(true);
|
||||||
|
scheduleMappingDraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
function fitCamera() {
|
function fitCamera() {
|
||||||
@@ -857,7 +1272,7 @@ function resetPose(kind) {
|
|||||||
if (kind === "rotation") {
|
if (kind === "rotation") {
|
||||||
state.pose = { ...state.pose, rotateX: 0, rotateY: 0, rotateZ: 0 };
|
state.pose = { ...state.pose, rotateX: 0, rotateY: 0, rotateZ: 0 };
|
||||||
} else if (kind === "transform") {
|
} else if (kind === "transform") {
|
||||||
state.pose = { ...state.pose, translateX: 0, translateY: 0, translateZ: 0, scale: 1 };
|
state.pose = { ...state.pose, translateX: 0, translateY: 0, translateZ: 0, scale: 1, scaleX: 1, scaleY: 1, scaleZ: 1 };
|
||||||
} else if (kind === "flip") {
|
} else if (kind === "flip") {
|
||||||
state.pose = { ...state.pose, flipX: false, flipY: false, flipZ: false };
|
state.pose = { ...state.pose, flipX: false, flipY: false, flipZ: false };
|
||||||
} else {
|
} else {
|
||||||
@@ -868,6 +1283,29 @@ function resetPose(kind) {
|
|||||||
markDirty();
|
markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stretchAxis(axis) {
|
||||||
|
if (!state.rawModelGroup?.children.length || !state.dicomSceneBox) {
|
||||||
|
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const axisKey = { x: "scaleX", y: "scaleY", z: "scaleZ" }[axis];
|
||||||
|
if (!axisKey) return;
|
||||||
|
const modelBox = new THREE.Box3().setFromObject(state.modelGroup);
|
||||||
|
const dicomSize = new THREE.Vector3();
|
||||||
|
const modelSize = new THREE.Vector3();
|
||||||
|
state.dicomSceneBox.getSize(dicomSize);
|
||||||
|
modelBox.getSize(modelSize);
|
||||||
|
const currentAxisScale = Math.max(0.001, Number(state.pose[axisKey]) || 1);
|
||||||
|
const targetSize = Math.max(0.001, dicomSize[axis]);
|
||||||
|
const currentSize = Math.max(0.001, modelSize[axis]);
|
||||||
|
const next = Math.max(0.2, Math.min(4, currentAxisScale * (targetSize / currentSize)));
|
||||||
|
state.pose[axisKey] = Number(next.toFixed(3));
|
||||||
|
applyPose();
|
||||||
|
markDirty("pose");
|
||||||
|
setAutoState(`${axis.toUpperCase()} 拉伸完成`, "ok");
|
||||||
|
$("autoResult").textContent = `${axis.toUpperCase()} 轴已按 DICOM 盒体粗略拉伸,系数 ${state.pose[axisKey]}`;
|
||||||
|
}
|
||||||
|
|
||||||
function suggestBoneSelection() {
|
function suggestBoneSelection() {
|
||||||
const boneKeys = ["rib", "vertebra", "sternum", "bone", "spine", "肋", "椎", "骨", "胸骨"];
|
const boneKeys = ["rib", "vertebra", "sternum", "bone", "spine", "肋", "椎", "骨", "胸骨"];
|
||||||
const next = new Set();
|
const next = new Set();
|
||||||
@@ -1088,11 +1526,58 @@ function wireEvents() {
|
|||||||
$("resetRotationBtn").addEventListener("click", () => resetPose("rotation"));
|
$("resetRotationBtn").addEventListener("click", () => resetPose("rotation"));
|
||||||
$("resetTransformBtn").addEventListener("click", () => resetPose("transform"));
|
$("resetTransformBtn").addEventListener("click", () => resetPose("transform"));
|
||||||
$("resetFlipBtn").addEventListener("click", () => resetPose("flip"));
|
$("resetFlipBtn").addEventListener("click", () => resetPose("flip"));
|
||||||
|
$("stretchXBtn").addEventListener("click", () => stretchAxis("x"));
|
||||||
|
$("stretchYBtn").addEventListener("click", () => stretchAxis("y"));
|
||||||
|
$("stretchZBtn").addEventListener("click", () => stretchAxis("z"));
|
||||||
$("suggestBoneBtn").addEventListener("click", suggestBoneSelection);
|
$("suggestBoneBtn").addEventListener("click", suggestBoneSelection);
|
||||||
$("autoCoarseBtn").addEventListener("click", runAutoCoarse);
|
$("autoCoarseBtn").addEventListener("click", runAutoCoarse);
|
||||||
$("autoFineBtn").addEventListener("click", runAutoFine);
|
$("autoFineBtn").addEventListener("click", runAutoFine);
|
||||||
$("dicomPreview").addEventListener("load", () => $("dicomLoading").classList.add("hidden"));
|
$("dicomPreview").addEventListener("load", () => {
|
||||||
$("dicomPreview").addEventListener("error", () => $("dicomLoading").classList.add("hidden"));
|
$("dicomLoading").classList.add("hidden");
|
||||||
|
scheduleMappingDraw();
|
||||||
|
});
|
||||||
|
$("dicomPreview").addEventListener("error", () => {
|
||||||
|
$("dicomLoading").classList.add("hidden");
|
||||||
|
resetMappingCanvas("DICOM Base Layer 加载失败");
|
||||||
|
});
|
||||||
|
$("mappingResetBtn").addEventListener("click", () => {
|
||||||
|
state.mappingViewport = { scale: 1, offsetX: 0, offsetY: 0 };
|
||||||
|
applyMappingTransform();
|
||||||
|
});
|
||||||
|
$("mappingViewport").addEventListener("wheel", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const factor = event.deltaY > 0 ? 0.9 : 1.1;
|
||||||
|
state.mappingViewport.scale = Math.max(0.45, Math.min(6, state.mappingViewport.scale * factor));
|
||||||
|
applyMappingTransform();
|
||||||
|
}, { passive: false });
|
||||||
|
$("mappingViewport").addEventListener("pointerdown", (event) => {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
state.mappingDrag = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
offsetX: state.mappingViewport.offsetX,
|
||||||
|
offsetY: state.mappingViewport.offsetY,
|
||||||
|
};
|
||||||
|
$("mappingViewport").classList.add("dragging");
|
||||||
|
$("mappingViewport").setPointerCapture(event.pointerId);
|
||||||
|
});
|
||||||
|
$("mappingViewport").addEventListener("pointermove", (event) => {
|
||||||
|
const drag = state.mappingDrag;
|
||||||
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||||
|
state.mappingViewport.offsetX = drag.offsetX + event.clientX - drag.startX;
|
||||||
|
state.mappingViewport.offsetY = drag.offsetY + event.clientY - drag.startY;
|
||||||
|
applyMappingTransform();
|
||||||
|
});
|
||||||
|
const stopMappingDrag = (event) => {
|
||||||
|
const drag = state.mappingDrag;
|
||||||
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||||
|
state.mappingDrag = null;
|
||||||
|
$("mappingViewport").classList.remove("dragging");
|
||||||
|
if ($("mappingViewport").hasPointerCapture(event.pointerId)) $("mappingViewport").releasePointerCapture(event.pointerId);
|
||||||
|
};
|
||||||
|
$("mappingViewport").addEventListener("pointerup", stopMappingDrag);
|
||||||
|
$("mappingViewport").addEventListener("pointercancel", stopMappingDrag);
|
||||||
document.querySelectorAll("[data-tool-tab]").forEach((button) => {
|
document.querySelectorAll("[data-tool-tab]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
state.activeToolTab = button.dataset.toolTab || "series";
|
state.activeToolTab = button.dataset.toolTab || "series";
|
||||||
@@ -1141,6 +1626,15 @@ function wireEvents() {
|
|||||||
updateDicomPreview();
|
updateDicomPreview();
|
||||||
});
|
});
|
||||||
$("sliceSlider").addEventListener("change", () => loadFusion());
|
$("sliceSlider").addEventListener("change", () => loadFusion());
|
||||||
|
$("mappingSliceSlider").addEventListener("input", () => {
|
||||||
|
const selected = currentSeries();
|
||||||
|
const max = Math.max(0, Number(selected?.count || 0) - 1);
|
||||||
|
state.sliceIndex = max - Number($("mappingSliceSlider").value || 0);
|
||||||
|
updateSliceControl();
|
||||||
|
renderDicomAnnotation();
|
||||||
|
updateDicomPreview();
|
||||||
|
});
|
||||||
|
$("mappingSliceSlider").addEventListener("change", () => loadFusion());
|
||||||
}
|
}
|
||||||
|
|
||||||
wireEvents();
|
wireEvents();
|
||||||
|
|||||||
@@ -36,9 +36,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
<button id="caseToggleBtn" class="icon-btn" type="button" title="收起/展开完整关联 CT">
|
|
||||||
<span></span><span></span><span></span>
|
|
||||||
</button>
|
|
||||||
<button id="relationBtn" class="ghost-btn accent" type="button">数据库关联可视化</button>
|
<button id="relationBtn" class="ghost-btn accent" type="button">数据库关联可视化</button>
|
||||||
<button id="viewerBtn" class="ghost-btn" type="button">DICOM 阅片系统</button>
|
<button id="viewerBtn" class="ghost-btn" type="button">DICOM 阅片系统</button>
|
||||||
<span id="dbStatus" class="status-pill">数据库</span>
|
<span id="dbStatus" class="status-pill">数据库</span>
|
||||||
@@ -52,10 +49,15 @@
|
|||||||
<main class="workspace">
|
<main class="workspace">
|
||||||
<aside class="case-panel panel">
|
<aside class="case-panel panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
|
<div class="panel-title-row">
|
||||||
<div>
|
<div>
|
||||||
<h2>完整关联 CT</h2>
|
<h2>完整关联 CT</h2>
|
||||||
<p>DICOM + STL</p>
|
<p>DICOM + STL</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="caseToggleBtn" class="icon-btn" type="button" title="收起/展开完整关联 CT">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<span id="caseCount">0</span>
|
<span id="caseCount">0</span>
|
||||||
</div>
|
</div>
|
||||||
<input id="caseSearch" class="search-input" placeholder="搜索 CT号 / 姓名 / 模型" />
|
<input id="caseSearch" class="search-input" placeholder="搜索 CT号 / 姓名 / 模型" />
|
||||||
@@ -175,6 +177,9 @@
|
|||||||
<button data-window="contrast">高对比</button>
|
<button data-window="contrast">高对比</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="viewer-tools">
|
<div class="viewer-tools">
|
||||||
|
<button id="stretchXBtn" class="ghost-btn">X拉伸</button>
|
||||||
|
<button id="stretchYBtn" class="ghost-btn">Y拉伸</button>
|
||||||
|
<button id="stretchZBtn" class="ghost-btn">Z拉伸</button>
|
||||||
<button id="fitBtn" class="ghost-btn">视角复位</button>
|
<button id="fitBtn" class="ghost-btn">视角复位</button>
|
||||||
<button id="saveBtn" class="primary-btn">保存配准</button>
|
<button id="saveBtn" class="primary-btn">保存配准</button>
|
||||||
<button id="statusBtn" class="state-btn">标为已配准</button>
|
<button id="statusBtn" class="state-btn">标为已配准</button>
|
||||||
@@ -198,24 +203,41 @@
|
|||||||
<section class="viewer-panel dicom-panel panel">
|
<section class="viewer-panel dicom-panel panel">
|
||||||
<div class="viewer-head">
|
<div class="viewer-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>DICOM 标注</h2>
|
<h2>逆向分割映射视图</h2>
|
||||||
<p id="dicomPanelMeta">当前序列切片</p>
|
<p id="dicomPanelMeta">Base DICOM · Overlay Label Map</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="viewer-tools">
|
||||||
|
<button id="mappingResetBtn" class="ghost-btn" type="button">位置重置</button>
|
||||||
<button id="openViewerBtn" class="ghost-btn" type="button">阅片系统</button>
|
<button id="openViewerBtn" class="ghost-btn" type="button">阅片系统</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="dicom-stage">
|
<div class="dicom-stage">
|
||||||
|
<div id="mappingViewport" class="mapping-viewport">
|
||||||
|
<div id="mappingLayer" class="mapping-layer">
|
||||||
<img id="dicomPreview" alt="DICOM 切片" />
|
<img id="dicomPreview" alt="DICOM 切片" />
|
||||||
<div class="crosshair horizontal"></div>
|
<canvas id="mappingCanvas"></canvas>
|
||||||
<div class="crosshair vertical"></div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mapping-slice-rail">
|
||||||
|
<input id="mappingSliceSlider" type="range" min="0" max="0" value="0" />
|
||||||
|
<span id="mappingSliceLabel">0 / 0</span>
|
||||||
|
</div>
|
||||||
<div class="dicom-overlay top-left" id="dicomInfoLeft">未选择序列</div>
|
<div class="dicom-overlay top-left" id="dicomInfoLeft">未选择序列</div>
|
||||||
<div class="dicom-overlay top-right" id="dicomInfoRight"></div>
|
<div class="dicom-overlay top-right" id="dicomInfoRight"></div>
|
||||||
<div class="dicom-overlay bottom-left" id="dicomInfoBottom">Img: -</div>
|
<div class="dicom-overlay bottom-left" id="dicomInfoBottom">Img: -</div>
|
||||||
<div id="dicomLoading" class="fusion-loading hidden">
|
<div id="dicomLoading" class="fusion-loading hidden">
|
||||||
<span>正在加载 DICOM 标注视图</span>
|
<span>正在加载逆向分割映射视图</span>
|
||||||
<div><i></i></div>
|
<div><i></i></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mapping-summary">
|
||||||
|
<div class="mapping-summary-head">
|
||||||
|
<span>Overlay Label Map</span>
|
||||||
|
<em id="mappingStats">0/0 构件 · 0 边 · 0 px</em>
|
||||||
|
</div>
|
||||||
<div id="annotationTags" class="annotation-tags"></div>
|
<div id="annotationTags" class="annotation-tags"></div>
|
||||||
|
<div id="mappingLegend" class="mapping-legend"></div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -115,13 +115,24 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workspace.case-collapsed {
|
.workspace.case-collapsed {
|
||||||
grid-template-columns: 0 minmax(0, 1fr);
|
grid-template-columns: 54px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace.case-collapsed .case-panel {
|
.workspace.case-collapsed .case-panel {
|
||||||
opacity: 0;
|
opacity: 1;
|
||||||
pointer-events: none;
|
pointer-events: auto;
|
||||||
transform: translateX(-18px);
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace.case-collapsed .case-panel > :not(.panel-head),
|
||||||
|
.workspace.case-collapsed .panel-head .panel-title-row > div,
|
||||||
|
.workspace.case-collapsed .panel-head #caseCount {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace.case-collapsed .case-panel .panel-head {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 9px 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
@@ -174,6 +185,19 @@ button {
|
|||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-title-row {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title-row .icon-btn {
|
||||||
|
width: 34px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.panel-head.compact {
|
.panel-head.compact {
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@@ -338,6 +362,24 @@ button {
|
|||||||
background: linear-gradient(180deg, rgba(20, 45, 88, 0.96), rgba(10, 18, 31, 0.96));
|
background: linear-gradient(180deg, rgba(20, 45, 88, 0.96), rgba(10, 18, 31, 0.96));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.series-card.skipped {
|
||||||
|
opacity: 0.54;
|
||||||
|
filter: grayscale(0.95);
|
||||||
|
background: rgba(8, 12, 18, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-card.skipped.active {
|
||||||
|
opacity: 0.78;
|
||||||
|
border-color: rgba(148, 163, 184, 0.5);
|
||||||
|
background: linear-gradient(180deg, rgba(34, 41, 53, 0.86), rgba(12, 17, 24, 0.9));
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-card.skipped .mini-tags i {
|
||||||
|
border-color: rgba(148, 163, 184, 0.48);
|
||||||
|
background: rgba(148, 163, 184, 0.12);
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
.case-card strong,
|
.case-card strong,
|
||||||
.series-card strong {
|
.series-card strong {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -617,6 +659,7 @@ button {
|
|||||||
.fusion-panel .viewer-head {
|
.fusion-panel .viewer-head {
|
||||||
align-content: center;
|
align-content: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-head h2 {
|
.viewer-head h2 {
|
||||||
@@ -855,7 +898,29 @@ button {
|
|||||||
background: #000;
|
background: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dicom-stage img {
|
.mapping-viewport {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 54px 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-viewport.dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-layer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dicom-stage img,
|
||||||
|
.dicom-stage canvas {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -864,6 +929,91 @@ button {
|
|||||||
image-rendering: auto;
|
image-rendering: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dicom-stage canvas {
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-slice-rail {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 6;
|
||||||
|
width: 54px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
border-left: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-slice-rail::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 26px;
|
||||||
|
bottom: 56px;
|
||||||
|
left: 50%;
|
||||||
|
width: 8px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(148, 163, 184, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mappingSliceSlider {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: min(66vh, 520px);
|
||||||
|
height: 34px;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mappingSliceSlider::-webkit-slider-runnable-track {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(148, 163, 184, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mappingSliceSlider::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
margin-top: -8.5px;
|
||||||
|
border: 3px solid #93c5fd;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #60a5fa;
|
||||||
|
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mappingSliceSlider::-moz-range-track {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(148, 163, 184, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mappingSliceSlider::-moz-range-thumb {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: 3px solid #93c5fd;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mappingSliceLabel {
|
||||||
|
position: absolute;
|
||||||
|
right: 9px;
|
||||||
|
bottom: 14px;
|
||||||
|
color: #c8f7ff;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
}
|
||||||
|
|
||||||
.crosshair {
|
.crosshair {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
@@ -916,13 +1066,12 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.annotation-tags {
|
.annotation-tags {
|
||||||
min-height: 48px;
|
min-height: 36px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border-top: 1px solid var(--line);
|
padding: 0;
|
||||||
padding: 8px 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.annotation-tags span {
|
.annotation-tags span {
|
||||||
@@ -936,6 +1085,78 @@ button {
|
|||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mapping-summary {
|
||||||
|
min-height: 122px;
|
||||||
|
max-height: 170px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: #070c14;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-summary-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
color: #dbeafe;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-summary-head em {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #c8f7ff;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-legend {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-legend .legend-item {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 12px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.035);
|
||||||
|
color: #b6c8dd;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-legend .legend-color {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-legend .legend-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-legend .legend-meta {
|
||||||
|
color: #9debf4;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.slice-row span {
|
.slice-row span {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
Reference in New Issue
Block a user