import * as THREE from "three"; import { STLLoader } from "./vendor/STLLoader.js"; import { OrbitControls } from "./vendor/OrbitControls.js"; const $ = (id) => document.getElementById(id); const BODY_LABELS = { head_neck: "头颈部", chest: "胸部", upper_abdomen: "上腹部", lower_abdomen: "下腹部", pelvis: "盆腔", }; const DEFAULT_POSE = { rotateX: 0, rotateY: 0, rotateZ: 0, translateX: 0, translateY: 0, translateZ: 0, scale: 1, flipX: false, flipY: false, flipZ: false, }; const POSE_CONTROLS = [ { key: "rotateX", label: "旋转 X", min: -180, max: 180, step: 1, suffix: "°" }, { key: "rotateY", label: "旋转 Y", min: -180, max: 180, step: 1, suffix: "°" }, { key: "rotateZ", label: "旋转 Z", min: -180, max: 180, step: 1, suffix: "°" }, { key: "translateX", label: "平移 X", min: -4, max: 4, step: 0.01, suffix: "" }, { key: "translateY", label: "平移 Y", min: -4, max: 4, step: 0.01, suffix: "" }, { key: "translateZ", label: "平移 Z", min: -4, max: 4, step: 0.01, suffix: "" }, { key: "scale", label: "缩放", min: 0.2, max: 3, step: 0.01, suffix: "x" }, ]; const STL_COLORS = [0x18d8c9, 0x3f82ff, 0xf5b84b, 0xf87171, 0x9bd76f, 0xd1a6ff, 0x66e0ff, 0xff9f7a]; const state = { token: localStorage.getItem("dicom_upp_registration_token") || "", user: null, links: { pacs_viewer_url: "http://127.0.0.1:8107", relation_viewer_url: "http://127.0.0.1:8108" }, statusFilter: "", partFilter: "", search: "", cases: [], activeCase: null, series: [], selectedSeriesUid: "", stlFiles: [], selectedStlIds: new Set(), algorithmModel: "", registrationStatus: "unregistered", pose: { ...DEFAULT_POSE }, windowMode: "default", sliceIndex: 0, dirty: false, saving: false, fusionVersion: 0, volumeMeta: null, sceneReady: false, renderer: null, camera: null, scene: null, controls: null, dicomGroup: null, modelGroup: null, rawModelGroup: null, baseModelScale: 1, resizeObserver: null, }; function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } function cls(value) { return value ? "active" : ""; } function setTopLoading(visible) { $("topLoading").classList.toggle("hidden", !visible); } function setFusionLoading(visible, text = "正在构建融合视图") { $("fusionLoading").classList.toggle("hidden", !visible); $("fusionLoading").querySelector("span").textContent = text; } function setSaveState(text, tone = "") { const el = $("saveState"); el.textContent = text; el.className = tone; } function markDirty() { state.dirty = true; setSaveState("未保存", "warn"); } function resetDirty() { state.dirty = false; setSaveState("已保存", "ok"); } async function api(path, options = {}) { const headers = new Headers(options.headers || {}); if (state.token) headers.set("Authorization", `Bearer ${state.token}`); if (options.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json"); const response = await fetch(path, { ...options, headers }); if (response.status === 401) { logout(false); throw new Error("登录已过期"); } if (!response.ok) { let message = response.statusText; try { const payload = await response.json(); message = payload.detail || message; } catch { message = await response.text(); } throw new Error(message || "请求失败"); } const type = response.headers.get("content-type") || ""; return type.includes("application/json") ? response.json() : response.text(); } function loginVisible(visible) { $("loginOverlay").classList.toggle("hidden", !visible); } async function login(event) { event.preventDefault(); $("loginError").textContent = ""; try { const payload = await api("/api/auth/login", { method: "POST", body: JSON.stringify({ username: $("username").value.trim(), password: $("password").value }), }); state.token = payload.token; state.user = payload; localStorage.setItem("dicom_upp_registration_token", state.token); loginVisible(false); await bootstrap(); } catch (error) { $("loginError").textContent = error.message; } } function logout(clearToken = true) { if (clearToken) localStorage.removeItem("dicom_upp_registration_token"); state.token = ""; state.user = null; loginVisible(true); $("userBadge").textContent = "未登录"; } async function bootstrap() { setTopLoading(true); try { const me = await api("/api/auth/me"); state.user = me; $("userBadge").textContent = `${me.username} · ${me.role || "管理员"}`; loginVisible(false); try { initScene(); } catch (error) { $("fusionStatus").textContent = `WebGL 初始化失败:${error.message}`; console.warn(error); } buildPoseControls(); await loadStatus(); await loadCases(); } catch (error) { console.warn(error); if (!state.token) logout(false); else { $("dbStatus").textContent = error.message || "启动失败"; $("dbStatus").className = "status-pill offline"; loginVisible(false); } } finally { setTopLoading(false); } } async function loadStatus() { try { const payload = await api("/api/status"); state.links = payload.links || state.links; const db = payload.database || {}; $("dbStatus").textContent = db.ok ? `数据库已连接 · ${payload.counts?.complete_cases || 0}` : "数据库异常"; $("dbStatus").className = `status-pill ${db.ok ? "online" : "offline"}`; } catch (error) { $("dbStatus").textContent = "数据库异常"; $("dbStatus").className = "status-pill offline"; } } async function loadCases(selectFirst = true) { setTopLoading(true); try { const params = new URLSearchParams(); if (state.search) params.set("q", state.search); if (state.statusFilter) params.set("status", state.statusFilter); if (state.partFilter) params.set("body_part", state.partFilter); const rows = await api(`/api/cases?${params.toString()}`); state.cases = rows; renderCases(); if (selectFirst && !state.activeCase && rows.length) { await selectCase(rows[0].ct_number, rows[0].algorithm_model || "未指定模型"); } else if (state.activeCase && !rows.some((row) => sameCase(row, state.activeCase))) { state.activeCase = null; clearDetail(); } } finally { setTopLoading(false); } } function sameCase(a, b) { return normalize(a?.ct_number) === normalize(b?.ct_number) && (a?.algorithm_model || "未指定模型") === (b?.algorithm_model || "未指定模型"); } function normalize(value) { return String(value || "").trim().toUpperCase(); } function fmtDateTime(date, time) { const d = String(date || "").replace(/^(\d{4})(\d{2})(\d{2})$/, "$1-$2-$3"); const t = fmtTime(time); return [d, t].filter(Boolean).join(" "); } function fmtTime(value) { const raw = String(value || "").replace(/[^\d.]/g, ""); if (!raw) return ""; const main = raw.split(".")[0].padEnd(6, "0").slice(0, 6); return `${main.slice(0, 2)}:${main.slice(2, 4)}:${main.slice(4, 6)}`; } function statusText(value) { return value === "registered" ? "已配准" : "未配准"; } function bodyPartTags(row) { const labels = Array.isArray(row?.body_part_labels) ? row.body_part_labels : []; const keys = Array.isArray(row?.body_part_keys) ? row.body_part_keys : []; if (labels.length) return labels; return keys.map((key) => BODY_LABELS[key]).filter(Boolean); } function renderCases() { $("caseCount").textContent = state.cases.length; const active = state.activeCase; $("caseList").innerHTML = state.cases.length ? state.cases .map((row) => { const tags = bodyPartTags(row).slice(0, 3); const status = row.registration_status || "unregistered"; const selected = active && sameCase(row, active); const title = escapeHtml(row.ct_number || row.ct_key); const patient = escapeHtml(row.patient_name || row.upp_patient_name || "-"); const meta = `${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)} 个`; return ` `; }) .join("") : `
没有符合条件的完整关联 CT
`; $("caseList").querySelectorAll(".case-card").forEach((button) => { button.addEventListener("click", () => selectCase(button.dataset.ct, button.dataset.algorithm || "未指定模型")); }); } function clearDetail() { state.series = []; state.stlFiles = []; state.selectedStlIds = new Set(); state.selectedSeriesUid = ""; $("activeTitle").textContent = "未选择 CT"; $("activeMeta").textContent = "从左侧选择一个完整关联检查"; $("activeTags").innerHTML = ""; $("seriesList").innerHTML = `
未选择 CT
`; $("stlList").innerHTML = `
未选择 STL
`; $("modelRail").innerHTML = ""; clearFusion(); } async function confirmChange() { if (!state.dirty) return true; return window.confirm("当前配准参数还未保存,确定切换吗?"); } async function selectCase(ctNumber, algorithmModel = "未指定模型") { if (!ctNumber || !(await confirmChange())) return; setTopLoading(true); setFusionLoading(true, "正在读取 CT 关联数据"); try { const params = new URLSearchParams({ algorithm_model: algorithmModel || "未指定模型" }); const [detail, seriesPayload, stlPayload, registration] = await Promise.all([ api(`/api/cases/${encodeURIComponent(ctNumber)}?${params.toString()}`), api(`/api/cases/${encodeURIComponent(ctNumber)}/series`), api(`/api/cases/${encodeURIComponent(ctNumber)}/stl?${params.toString()}`), api(`/api/registrations/${encodeURIComponent(ctNumber)}?${params.toString()}`), ]); state.activeCase = detail; state.algorithmModel = detail.algorithm_model || algorithmModel || "未指定模型"; state.series = seriesPayload.series || []; state.stlFiles = stlPayload.files || []; state.registrationStatus = registration.registration_status || "unregistered"; state.pose = { ...DEFAULT_POSE, ...(registration.transform || {}) }; state.sliceIndex = 0; $("notes").value = registration.notes || ""; const savedIds = new Set((registration.selected_stl_files || []).map((item) => Number(item.id)).filter(Number.isFinite)); state.selectedStlIds = savedIds.size ? savedIds : pickDefaultStlIds(state.stlFiles); state.selectedSeriesUid = registration.series_instance_uid || pickDefaultSeries(state.series)?.series_uid || ""; const selected = currentSeries(); state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0; renderCases(); renderActiveHeader(); renderSeries(); renderStl(); renderPoseControls(); resetDirty(); await loadFusion(); } catch (error) { $("fusionStatus").textContent = error.message; clearFusion(); } finally { setTopLoading(false); setFusionLoading(false); } } function pickDefaultSeries(series) { return [...series] .filter((item) => Number(item.count || 0) > 1) .sort((a, b) => Number(b.count || 0) - Number(a.count || 0))[0] || series[0] || null; } function pickDefaultStlIds(files) { const priority = ["liver", "portal_vein", "liver_artery", "liver_vein", "bile_duct", "pancreas", "spleen", "vertebrae"]; const ranked = [...files].sort((a, b) => { const af = String(a.family || a.segment_name || a.file_name || "").toLowerCase(); const bf = String(b.family || b.segment_name || b.file_name || "").toLowerCase(); const ai = priority.findIndex((key) => af === key || af.includes(key)); const bi = priority.findIndex((key) => bf === key || bf.includes(key)); const ar = ai < 0 ? 999 : ai; const br = bi < 0 ? 999 : bi; if (ar !== br) return ar - br; return Number(a.id || 0) - Number(b.id || 0); }); return new Set(ranked.slice(0, Math.min(6, ranked.length)).map((file) => Number(file.id))); } function currentSeries() { return state.series.find((item) => item.series_uid === state.selectedSeriesUid) || null; } function renderActiveHeader() { const row = state.activeCase; if (!row) return; $("activeTitle").textContent = `${row.ct_number} · ${row.algorithm_model || "未指定模型"}`; $("activeMeta").textContent = `${row.patient_name || row.upp_patient_name || "-"} · ${fmtDateTime(row.study_date, row.study_time) || "检查时间未知"} · ${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)} 个`; const tags = [ `${statusText(state.registrationStatus)}`, ...bodyPartTags(row).map((tag) => `${escapeHtml(tag)}`), ]; $("activeTags").innerHTML = tags.join(""); $("statusBtn").textContent = state.registrationStatus === "registered" ? "标为未配准" : "标为已配准"; $("statusBtn").classList.toggle("unregistered", state.registrationStatus !== "registered"); } function renderSeries() { $("seriesCount").textContent = state.series.length; $("seriesList").innerHTML = state.series.length ? state.series .map((item) => { const active = item.series_uid === state.selectedSeriesUid; const labels = (item.annotation_labels || []).map((label) => `${escapeHtml(label)}`).join(""); const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean); return ` `; }) .join("") : `
未读取到 DICOM 序列
`; $("seriesList").querySelectorAll(".series-card").forEach((button) => { button.addEventListener("click", () => selectSeries(button.dataset.series)); }); updateSliceControl(); } async function selectSeries(uid) { if (!uid || uid === state.selectedSeriesUid) return; if (!(await confirmChange())) return; state.selectedSeriesUid = uid; const selected = currentSeries(); state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0; markDirty(); renderSeries(); await loadFusion(); } function renderStl() { $("stlCount").textContent = state.stlFiles.length; const models = [...new Set(state.stlFiles.map((item) => item.algorithm_model || state.algorithmModel || "未指定模型"))]; $("modelRail").innerHTML = models.map((model) => `${escapeHtml(model)}`).join(""); $("stlList").innerHTML = state.stlFiles.length ? state.stlFiles .map((file, index) => { const checked = state.selectedStlIds.has(Number(file.id)); return ` `; }) .join("") : `
当前算法模型没有 STL 文件
`; $("stlList").querySelectorAll("input[data-stl]").forEach((input) => { input.addEventListener("change", async () => { const id = Number(input.dataset.stl); if (input.checked) state.selectedStlIds.add(id); else state.selectedStlIds.delete(id); markDirty(); renderStl(); await loadFusion(); }); }); } function cssColor(index) { return `#${STL_COLORS[index % STL_COLORS.length].toString(16).padStart(6, "0")}`; } function formatBytes(value) { const n = Number(value || 0); if (!n) return "大小未知"; if (n > 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; return `${(n / 1024).toFixed(1)} KB`; } function buildPoseControls() { $("poseGrid").innerHTML = POSE_CONTROLS.map( (item) => `
`, ).join(""); $("poseGrid").querySelectorAll(".pose-control").forEach((row) => { const key = row.dataset.pose; const range = row.querySelector("input[type='range']"); const number = row.querySelector("input[type='number']"); const sync = (value, source) => { const numeric = Number(value); state.pose[key] = Number.isFinite(numeric) ? numeric : DEFAULT_POSE[key]; if (source !== range) range.value = state.pose[key]; if (source !== number) number.value = state.pose[key]; applyPose(); markDirty(); }; range.addEventListener("input", () => sync(range.value, range)); number.addEventListener("input", () => sync(number.value, number)); }); document.querySelectorAll("[data-flip]").forEach((button) => { button.addEventListener("click", () => { const key = button.dataset.flip; state.pose[key] = !state.pose[key]; renderPoseControls(); applyPose(); markDirty(); }); }); } function renderPoseControls() { POSE_CONTROLS.forEach((item) => { const row = $(`poseGrid`).querySelector(`[data-pose="${item.key}"]`); if (!row) return; const value = Number(state.pose[item.key] ?? DEFAULT_POSE[item.key]); row.querySelector("input[type='range']").value = value; row.querySelector("input[type='number']").value = Number.isInteger(value) ? value : value.toFixed(2); }); document.querySelectorAll("[data-flip]").forEach((button) => { button.classList.toggle("active", Boolean(state.pose[button.dataset.flip])); }); renderActiveHeader(); } function updateSliceControl() { const selected = currentSeries(); const count = Number(selected?.count || 0); $("sliceSlider").max = Math.max(0, count - 1); $("sliceSlider").value = Math.min(state.sliceIndex, Math.max(0, count - 1)); $("sliceLabel").textContent = count ? `切片 ${Number($("sliceSlider").value) + 1} / ${count}` : "切片 0 / 0"; } function initScene() { if (state.sceneReady) return; const viewport = $("fusionViewport"); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); renderer.setClearColor(0x000000, 1); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); viewport.appendChild(renderer.domElement); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(38, 1, 0.01, 1000); camera.position.set(0, -8.5, 4.4); camera.up.set(0, 0, 1); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.target.set(0, 0, 0); const ambient = new THREE.AmbientLight(0xffffff, 0.82); const directional = new THREE.DirectionalLight(0xffffff, 1.8); directional.position.set(3.5, -4, 6); scene.add(ambient, directional); const dicomGroup = new THREE.Group(); const modelGroup = new THREE.Group(); const rawModelGroup = new THREE.Group(); modelGroup.add(rawModelGroup); scene.add(dicomGroup, modelGroup); Object.assign(state, { renderer, camera, scene, controls, dicomGroup, modelGroup, rawModelGroup, sceneReady: true }); const resize = () => { const rect = viewport.getBoundingClientRect(); if (!rect.width || !rect.height) return; renderer.setSize(rect.width, rect.height, false); camera.aspect = rect.width / rect.height; camera.updateProjectionMatrix(); }; state.resizeObserver = new ResizeObserver(resize); state.resizeObserver.observe(viewport); resize(); const animate = () => { requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); }; animate(); } function clearGroup(group) { if (!group) return; while (group.children.length) { const child = group.children.pop(); child.traverse?.((node) => { node.geometry?.dispose?.(); if (node.material) { if (Array.isArray(node.material)) node.material.forEach((material) => material.dispose?.()); else node.material.dispose?.(); } node.material?.map?.dispose?.(); }); } } function clearFusion() { clearGroup(state.dicomGroup); clearGroup(state.rawModelGroup); state.volumeMeta = null; $("fusionMeta").textContent = "DICOM - · STL -"; $("fusionStatus").textContent = "等待选择 DICOM 与 STL"; updateSliceControl(); } async function loadFusion() { const version = ++state.fusionVersion; const selected = currentSeries(); if (!state.sceneReady) { $("fusionStatus").textContent = "当前浏览器未能启用 WebGL,无法显示三维融合视图"; return; } if (!state.activeCase || !selected) { clearFusion(); return; } setFusionLoading(true, "正在按需加载 DICOM 切片与 STL"); updateSliceControl(); try { const params = new URLSearchParams({ ct_number: state.activeCase.ct_number, series_uid: selected.series_uid, center_index: String(state.sliceIndex), window: state.windowMode, radius: "24", }); const volume = await api(`/api/dicom/fusion-volume?${params.toString()}`); if (version !== state.fusionVersion) return; state.volumeMeta = volume; await buildDicomVolume(volume); if (version !== state.fusionVersion) return; await buildStlModels(volume); if (version !== state.fusionVersion) return; fitCamera(); $("fusionStatus").textContent = `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)} 张`; $("fusionMeta").textContent = `DICOM ${volume.indices?.length || 0} 张局部体 · STL ${state.selectedStlIds.size} 个`; } catch (error) { clearFusion(); $("fusionStatus").textContent = error.message; } finally { if (version === state.fusionVersion) setFusionLoading(false); } } async function loadTexture(url) { return new Promise((resolve, reject) => { new THREE.TextureLoader().load(url, resolve, undefined, reject); }); } async function buildDicomVolume(volume) { clearGroup(state.dicomGroup); 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 sceneScale = 5.2 / maxPhysical; const width = (physical.width || 1) * sceneScale; const height = (physical.height || 1) * sceneScale; const depth = (physical.depth || 1) * sceneScale; const centerIndex = Number(volume.center || 0); const indices = volume.indices || []; const centerPosition = indices.length ? Math.floor(indices.length / 2) : 0; const textures = await Promise.all((volume.frames || []).map((frame) => loadTexture(frame))); textures.forEach((texture, index) => { texture.colorSpace = THREE.SRGBColorSpace; const material = new THREE.MeshBasicMaterial({ map: texture, transparent: true, opacity: index === centerPosition ? 0.78 : 0.14, side: THREE.DoubleSide, 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 = z; state.dicomGroup.add(plane); }); const box = new THREE.BoxGeometry(width, height, Math.max(depth, 0.02)); const edges = new THREE.EdgesGeometry(box); const lines = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0x1cd9c7, transparent: true, opacity: 0.42 })); state.dicomGroup.add(lines); state.baseModelScale = sceneScale; } async function buildStlModels(volume) { clearGroup(state.rawModelGroup); const files = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id))); if (!files.length) { applyPose(); return; } const loader = new STLLoader(); const token = encodeURIComponent(state.token); for (const [index, file] of files.entries()) { const params = new URLSearchParams({ ct_number: state.activeCase.ct_number, file_id: String(file.id), algorithm_model: state.algorithmModel, access_token: state.token, }); const geometry = await loader.loadAsync(`/api/stl/file?${params.toString()}`); geometry.computeVertexNormals(); geometry.computeBoundingBox(); const material = new THREE.MeshStandardMaterial({ color: STL_COLORS[index % STL_COLORS.length], metalness: 0.08, roughness: 0.5, transparent: true, opacity: 0.58, side: THREE.DoubleSide, depthWrite: false, }); const mesh = new THREE.Mesh(geometry, material); mesh.name = file.segment_name || file.file_name; state.rawModelGroup.add(mesh); } const bbox = new THREE.Box3().setFromObject(state.rawModelGroup); const center = new THREE.Vector3(); bbox.getCenter(center); state.rawModelGroup.position.set(-center.x, -center.y, -center.z); applyPose(); } function applyPose() { if (!state.modelGroup) return; const pose = { ...DEFAULT_POSE, ...state.pose }; state.modelGroup.rotation.set( THREE.MathUtils.degToRad(Number(pose.rotateX) || 0), THREE.MathUtils.degToRad(Number(pose.rotateY) || 0), THREE.MathUtils.degToRad(Number(pose.rotateZ) || 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; state.modelGroup.scale.set(scale * (pose.flipX ? -1 : 1), scale * (pose.flipY ? -1 : 1), scale * (pose.flipZ ? -1 : 1)); } function fitCamera() { if (!state.camera || !state.controls) return; const box = new THREE.Box3(); box.expandByObject(state.dicomGroup); box.expandByObject(state.modelGroup); const size = new THREE.Vector3(); const center = new THREE.Vector3(); box.getSize(size); box.getCenter(center); const distance = Math.max(size.x, size.y, size.z, 1) * 1.8; state.camera.position.set(center.x, center.y - distance, center.z + distance * 0.45); state.controls.target.copy(center); state.controls.update(); } async function saveRegistration(nextStatus = null) { if (!state.activeCase || !currentSeries() || state.saving) return; state.saving = true; $("saveBtn").disabled = true; $("statusBtn").disabled = true; setSaveState("保存中", "warn"); const selectedSeries = currentSeries(); const status = nextStatus || state.registrationStatus; const selectedFiles = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id))); try { await api("/api/registrations", { method: "POST", body: JSON.stringify({ ct_number: state.activeCase.ct_number, algorithm_model: state.algorithmModel, registration_status: status, series_instance_uid: selectedSeries.series_uid, series_description: selectedSeries.description || "", selected_stl_files: selectedFiles.map((file) => ({ id: file.id, file_name: file.file_name, segment_name: file.segment_name, family: file.family, category: file.category, algorithm_model: file.algorithm_model, })), transform: state.pose, module_styles: Object.fromEntries(selectedFiles.map((file, index) => [file.file_name, { color: cssColor(index), opacity: 0.58 }])), dicom_reference: { series_uid: selectedSeries.series_uid, series_number: selectedSeries.series_number, description: selectedSeries.description, slice_index: state.sliceIndex, slice_count: selectedSeries.count, window: state.windowMode, volume: state.volumeMeta?.physicalSize || null, }, model_reference: { algorithm_model: state.algorithmModel, selected_count: selectedFiles.length, families: [...new Set(selectedFiles.map((file) => file.family).filter(Boolean))], }, notes: $("notes").value, }), }); state.registrationStatus = status; if (state.activeCase) state.activeCase.registration_status = status; const existing = state.cases.find((row) => sameCase(row, state.activeCase)); if (existing) existing.registration_status = status; resetDirty(); renderCases(); renderActiveHeader(); } catch (error) { setSaveState(error.message, "error"); } finally { state.saving = false; $("saveBtn").disabled = false; $("statusBtn").disabled = false; } } function sameHostUrl(raw, targetPort) { try { const url = new URL(raw); const here = new URL(window.location.href); if (["127.0.0.1", "localhost", "0.0.0.0"].includes(url.hostname)) url.hostname = here.hostname; if (targetPort) url.port = String(targetPort); return url.toString(); } catch { const here = new URL(window.location.href); here.port = String(targetPort || here.port); here.pathname = "/"; here.search = ""; return here.toString(); } } function openLinkedApp(kind) { const ct = state.activeCase?.ct_number || ""; const model = state.algorithmModel || ""; if (kind === "viewer") { const url = new URL(sameHostUrl(state.links.pacs_viewer_url, 8107)); if (ct) url.searchParams.set("ct_number", ct); window.open(url.toString(), "_blank"); } else { const url = new URL(sameHostUrl(state.links.relation_viewer_url, 8108)); if (ct) url.searchParams.set("ct_number", ct); if (model) url.searchParams.set("algorithm_model", model); window.open(url.toString(), "_blank"); } } function wireEvents() { $("loginForm").addEventListener("submit", login); $("logoutBtn").addEventListener("click", () => logout()); $("refreshBtn").addEventListener("click", async () => { await loadStatus(); await loadCases(false); }); $("relationBtn").addEventListener("click", () => openLinkedApp("relation")); $("viewerBtn").addEventListener("click", () => openLinkedApp("viewer")); $("fitBtn").addEventListener("click", fitCamera); $("saveBtn").addEventListener("click", () => saveRegistration()); $("statusBtn").addEventListener("click", () => saveRegistration(state.registrationStatus === "registered" ? "unregistered" : "registered")); $("notes").addEventListener("input", markDirty); $("caseSearch").addEventListener("input", () => { state.search = $("caseSearch").value.trim(); window.clearTimeout(state.searchTimer); state.searchTimer = window.setTimeout(() => loadCases(false), 240); }); document.querySelectorAll(".filter[data-status]").forEach((button) => { button.addEventListener("click", async () => { state.statusFilter = button.dataset.status || ""; document.querySelectorAll(".filter[data-status]").forEach((item) => item.classList.toggle("active", item === button)); await loadCases(false); }); }); document.querySelectorAll(".filter[data-part]").forEach((button) => { button.addEventListener("click", async () => { state.partFilter = button.dataset.part || ""; document.querySelectorAll(".filter[data-part]").forEach((item) => item.classList.toggle("active", item === button)); await loadCases(false); }); }); document.querySelectorAll(".segmented button").forEach((button) => { button.addEventListener("click", async () => { state.windowMode = button.dataset.window || "default"; document.querySelectorAll(".segmented button").forEach((item) => item.classList.toggle("active", item === button)); await loadFusion(); }); }); $("sliceSlider").addEventListener("input", () => { state.sliceIndex = Number($("sliceSlider").value || 0); updateSliceControl(); }); $("sliceSlider").addEventListener("change", () => loadFusion()); } wireEvents(); if (state.token) { bootstrap(); } else { loginVisible(true); }