Spaces:
Running
Running
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Dual-View Mammogram Reporting Tool</title> | |
<script src="https://cdn.tailwindcss.com"></script> | |
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/pako@2.1.0/dist/pako.min.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/dicom-parser@1.8.15/dist/dicomParser.min.js"></script> | |
<style> | |
:root { | |
--primary-color: #2563eb; | |
--primary-hover: #1d4ed8; | |
--secondary-color: #64748b; | |
--accent-color: #f1f5f9; | |
--border-color: #e2e8f0; | |
--text-primary: #1e293b; | |
--text-secondary: #64748b; | |
} | |
.image-container { | |
position: relative; | |
overflow: hidden; | |
background-color: #000; | |
touch-action: none; | |
cursor: grab; | |
border-radius: 8px; | |
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); | |
} | |
.image-container.grabbing { | |
cursor: grabbing; | |
} | |
.image-canvas { | |
display: block; | |
max-width: 100%; | |
max-height: 100%; | |
margin: 0 auto; | |
} | |
.slider-container { | |
position: relative; | |
padding: 0 0.5rem; | |
margin-bottom: 0.75rem; | |
} | |
.slider-container label { | |
display: block; | |
margin-bottom: 0.25rem; | |
font-size: 0.75rem; | |
color: var(--text-secondary); | |
font-weight: 500; | |
} | |
.slider-value { | |
position: absolute; | |
right: 0.5rem; | |
top: 0; | |
font-size: 0.7rem; | |
color: var(--text-secondary); | |
background: var(--accent-color); | |
padding: 0.1rem 0.4rem; | |
border-radius: 4px; | |
} | |
.form-section { | |
margin-bottom: 1.25rem; | |
border: 1px solid var(--border-color); | |
border-radius: 0.75rem; | |
overflow: hidden; | |
background: white; | |
} | |
.form-section-title { | |
background-color: var(--accent-color); | |
padding: 0.75rem 1rem; | |
font-weight: 600; | |
border-bottom: 1px solid var(--border-color); | |
color: var(--text-primary); | |
font-size: 0.95rem; | |
} | |
.form-section-content { | |
padding: 1rem; | |
} | |
.table-container { | |
overflow-x: auto; | |
border-radius: 0.5rem; | |
border: 1px solid var(--border-color); | |
} | |
.table-auto { | |
min-width: 100%; | |
border-collapse: collapse; | |
} | |
.table-auto th, .table-auto td { | |
padding: 0.6rem; | |
border: 1px solid var(--border-color); | |
font-size: 0.875rem; | |
} | |
.table-auto th { | |
background-color: var(--accent-color); | |
font-weight: 600; | |
text-align: left; | |
color: var(--text-primary); | |
} | |
.dicom-tag { | |
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; | |
background-color: var(--accent-color); | |
padding: 0.2rem 0.4rem; | |
border-radius: 0.25rem; | |
font-size: 0.75rem; | |
color: var(--text-secondary); | |
} | |
.tab-button { | |
padding: 0.6rem 1.2rem; | |
border: 1px solid var(--border-color); | |
background-color: var(--accent-color); | |
cursor: pointer; | |
font-size: 0.875rem; | |
font-weight: 500; | |
color: var(--text-secondary); | |
transition: all 0.2s ease; | |
} | |
.tab-button:hover { | |
background-color: #e2e8f0; | |
} | |
.tab-button.active { | |
background-color: var(--primary-color); | |
color: white; | |
border-color: var(--primary-color); | |
} | |
.tab-content { | |
display: none; | |
} | |
.tab-content.active { | |
display: block; | |
} | |
.viewer-grid { | |
display: grid; | |
grid-template-columns: 1fr 1fr; | |
gap: 1.5rem; | |
margin-bottom: 2rem; | |
} | |
.viewer-panel { | |
background: white; | |
border-radius: 12px; | |
padding: 1.25rem; | |
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); | |
border: 1px solid var(--border-color); | |
} | |
.viewer-header { | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
margin-bottom: 1rem; | |
} | |
.viewer-title { | |
font-size: 1.1rem; | |
font-weight: 600; | |
color: var(--text-primary); | |
} | |
.image-tools { | |
display: grid; | |
grid-template-columns: repeat(4, 1fr); | |
gap: 0.5rem; | |
margin-bottom: 1rem; | |
} | |
.tool-btn { | |
padding: 0.5rem; | |
border: 1px solid var(--border-color); | |
background: white; | |
border-radius: 6px; | |
cursor: pointer; | |
font-size: 0.8rem; | |
font-weight: 500; | |
transition: all 0.2s ease; | |
} | |
.tool-btn:hover { | |
background: var(--accent-color); | |
border-color: var(--primary-color); | |
} | |
.tool-btn.active { | |
background: var(--primary-color); | |
color: white; | |
border-color: var(--primary-color); | |
} | |
.open-btn { | |
background: var(--primary-color); | |
color: white; | |
padding: 0.6rem 1.2rem; | |
border: none; | |
border-radius: 8px; | |
font-weight: 500; | |
cursor: pointer; | |
transition: background-color 0.2s ease; | |
} | |
.open-btn:hover { | |
background: var(--primary-hover); | |
} | |
@media (max-width: 1024px) { | |
.main-container { | |
flex-direction: column; | |
} | |
.viewer-grid { | |
grid-template-columns: 1fr; | |
} | |
} | |
body { | |
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; | |
} | |
h1, h2, h3 { | |
font-weight: 600; | |
} | |
</style> | |
</head> | |
<body class="bg-gray-50 min-h-screen"> | |
<div class="container mx-auto px-4 py-6"> | |
<h1 class="text-3xl font-bold text-gray-800 mb-6">Dual-View Mammogram Reporting Tool</h1> | |
<div class="main-container flex flex-col xl:flex-row gap-8"> | |
<!-- Left side - All four image viewers in grid --> | |
<div class="viewer-container flex-1"> | |
<div class="viewer-grid"> | |
<!-- Left CC View --> | |
<div class="viewer-panel"> | |
<div class="viewer-header"> | |
<h3 class="viewer-title">Left CC View</h3> | |
<button id="openBtnLeftCC" class="open-btn"> | |
Open Image | |
</button> | |
</div> | |
<div class="image-tools"> | |
<button class="tool-btn zoom-in-btn-left-cc">+ Zoom</button> | |
<button class="tool-btn zoom-out-btn-left-cc">- Zoom</button> | |
<button class="tool-btn pan-btn-left-cc">Pan</button> | |
<button class="tool-btn reset-btn-left-cc">Reset</button> | |
</div> | |
<div class="image-container mb-4" style="height: 280px;"> | |
<canvas id="canvasLeftCC" class="image-canvas"></canvas> | |
</div> | |
<div class="slider-container"> | |
<label for="levelSliderLeftCC">Window Level</label> | |
<span id="levelValueLeftCC" class="slider-value">2048</span> | |
<input type="range" id="levelSliderLeftCC" min="0" max="4095" value="2048" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"> | |
</div> | |
<div class="slider-container"> | |
<label for="widthSliderLeftCC">Window Width</label> | |
<span id="widthValueLeftCC" class="slider-value">4095</span> | |
<input type="range" id="widthSliderLeftCC" min="1" max="4095" value="4095" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"> | |
</div> | |
<div class="dicom-info mt-4 p-3 bg-gray-100 rounded hidden" id="dicomInfoLeftCC"> | |
<h3 class="font-semibold mb-2 text-sm">DICOM Information</h3> | |
<div class="grid grid-cols-2 gap-2 text-xs" id="dicomTagsLeftCC"></div> | |
</div> | |
</div> | |
<!-- Left MLO View --> | |
<div class="viewer-panel"> | |
<div class="viewer-header"> | |
<h3 class="viewer-title">Left MLO View</h3> | |
<button id="openBtnLeftMLO" class="open-btn"> | |
Open Image | |
</button> | |
</div> | |
<div class="image-tools"> | |
<button class="tool-btn zoom-in-btn-left-mlo">+ Zoom</button> | |
<button class="tool-btn zoom-out-btn-left-mlo">- Zoom</button> | |
<button class="tool-btn pan-btn-left-mlo">Pan</button> | |
<button class="tool-btn reset-btn-left-mlo">Reset</button> | |
</div> | |
<div class="image-container mb-4" style="height: 280px;"> | |
<canvas id="canvasLeftMLO" class="image-canvas"></canvas> | |
</div> | |
<div class="slider-container"> | |
<label for="levelSliderLeftMLO">Window Level</label> | |
<span id="levelValueLeftMLO" class="slider-value">2048</span> | |
<input type="range" id="levelSliderLeftMLO" min="0" max="4095" value="2048" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"> | |
</div> | |
<div class="slider-container"> | |
<label for="widthSliderLeftMLO">Window Width</label> | |
<span id="widthValueLeftMLO" class="slider-value">4095</span> | |
<input type="range" id="widthSliderLeftMLO" min="1" max="4095" value="4095" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"> | |
</div> | |
<div class="dicom-info mt-4 p-3 bg-gray-100 rounded hidden" id="dicomInfoLeftMLO"> | |
<h3 class="font-semibold mb-2 text-sm">DICOM Information</h3> | |
<div class="grid grid-cols-2 gap-2 text-xs" id="dicomTagsLeftMLO"></div> | |
</div> | |
</div> | |
<!-- Right CC View --> | |
<div class="viewer-panel"> | |
<div class="viewer-header"> | |
<h3 class="viewer-title">Right CC View</h3> | |
<button id="openBtnRightCC" class="open-btn"> | |
Open Image | |
</button> | |
</div> | |
<div class="image-tools"> | |
<button class="tool-btn zoom-in-btn-right-cc">+ Zoom</button> | |
<button class="tool-btn zoom-out-btn-right-cc">- Zoom</button> | |
<button class="tool-btn pan-btn-right-cc">Pan</button> | |
<button class="tool-btn reset-btn-right-cc">Reset</button> | |
</div> | |
<div class="image-container mb-4" style="height: 280px;"> | |
<canvas id="canvasRightCC" class="image-canvas"></canvas> | |
</div> | |
<div class="slider-container"> | |
<label for="levelSliderRightCC">Window Level</label> | |
<span id="levelValueRightCC" class="slider-value">2048</span> | |
<input type="range" id="levelSliderRightCC" min="0" max="4095" value="2048" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"> | |
</div> | |
<div class="slider-container"> | |
<label for="widthSliderRightCC">Window Width</label> | |
<span id="widthValueRightCC" class="slider-value">4095</span> | |
<input type="range" id="widthSliderRightCC" min="1" max="4095" value="4095" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"> | |
</div> | |
<div class="dicom-info mt-4 p-3 bg-gray-100 rounded hidden" id="dicomInfoRightCC"> | |
<h3 class="font-semibold mb-2 text-sm">DICOM Information</h3> | |
<div class="grid grid-cols-2 gap-2 text-xs" id="dicomTagsRightCC"></div> | |
</div> | |
</div> | |
<!-- Right MLO View --> | |
<div class="viewer-panel"> | |
<div class="viewer-header"> | |
<h3 class="viewer-title">Right MLO View</h3> | |
<button id="openBtnRightMLO" class="open-btn"> | |
Open Image | |
</button> | |
</div> | |
<div class="image-tools"> | |
<button class="tool-btn zoom-in-btn-right-mlo">+ Zoom</button> | |
<button class="tool-btn zoom-out-btn-right-mlo">- Zoom</button> | |
<button class="tool-btn pan-btn-right-mlo">Pan</button> | |
<button class="tool-btn reset-btn-right-mlo">Reset</button> | |
</div> | |
<div class="image-container mb-4" style="height: 280px;"> | |
<canvas id="canvasRightMLO" class="image-canvas"></canvas> | |
</div> | |
<div class="slider-container"> | |
<label for="levelSliderRightMLO">Window Level</label> | |
<span id="levelValueRightMLO" class="slider-value">2048</span> | |
<input type="range" id="levelSliderRightMLO" min="0" max="4095" value="2048" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"> | |
</div> | |
<div class="slider-container"> | |
<label for="widthSliderRightMLO">Window Width</label> | |
<span id="widthValueRightMLO" class="slider-value">4095</span> | |
<input type="range" id="widthSliderRightMLO" min="1" max="4095" value="4095" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"> | |
</div> | |
<div class="dicom-info mt-4 p-3 bg-gray-100 rounded hidden" id="dicomInfoRightMLO"> | |
<h3 class="font-semibold mb-2 text-sm">DICOM Information</h3> | |
<div class="grid grid-cols-2 gap-2 text-xs" id="dicomTagsRightMLO"></div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<!-- Right side - Reporting form --> | |
<div class="form-container bg-white rounded-xl shadow-md p-6 flex-1 xl:max-w-2xl overflow-y-auto" style="max-height: 100vh;"> | |
<div class="tabs flex border-b mb-4"> | |
<button class="tab-button active" data-tab="patient">Patient Info</button> | |
<button class="tab-button" data-tab="left">Left Breast</button> | |
<button class="tab-button" data-tab="right">Right Breast</button> | |
<button class="tab-button" data-tab="reasoning">Reasoning</button> | |
<button class="tab-button" data-tab="final">Final</button> | |
</div> | |
<!-- Patient Info --> | |
<div id="patient-tab" class="tab-content active"> | |
<div class="form-section"> | |
<div class="form-section-title">Patient Information</div> | |
<div class="form-section-content"> | |
<div class="mb-4"> | |
<label for="patientId" class="block text-sm font-medium text-gray-700 mb-1">Patient ID</label> | |
<input type="text" id="patientId" class="w-full px-3 py-2 border border-gray-300 rounded-md"> | |
</div> | |
<div class="mb-4"> | |
<label for="age" class="block text-sm font-medium text-gray-700 mb-1">Age</label> | |
<input type="text" id="age" class="w-full px-3 py-2 border border-gray-300 rounded-md"> | |
</div> | |
<div class="mb-4"> | |
<label for="density" class="block text-sm font-medium text-gray-700 mb-1">Breast Density</label> | |
<select id="density" class="w-full px-3 py-2 border border-gray-300 rounded-md"> | |
<option value=""></option> | |
<option value="A: Almost entirely fatty">A: Almost entirely fatty</option> | |
<option value="B: Scattered areas of fibroglandular density">B: Scattered areas of fibroglandular density</option> | |
<option value="C: Heterogeneously dense">C: Heterogeneously dense</option> | |
<option value="D: Extremely dense">D: Extremely dense</option> | |
</select> | |
</div> | |
</div> | |
</div> | |
</div> | |
<!-- Left Breast Findings --> | |
<div id="left-tab" class="tab-content"> | |
<div class="form-section"> | |
<div class="form-section-title">Left Breast Findings</div> | |
<div class="form-section-content"> | |
<div class="mb-4"> | |
<label for="leftLocation" class="block text-sm font-medium text-gray-700 mb-1">Lesion Location (M/W/Q)</label> | |
<input type="text" id="leftLocation" class="w-full px-3 py-2 border border-gray-300 rounded-md"> | |
</div> | |
<div class="mb-4"> | |
<label for="leftType" class="block text-sm font-medium text-gray-700 mb-1">Lesion Type</label> | |
<select id="leftType" class="w-full px-3 py-2 border border-gray-300 rounded-md"> | |
<option value=""></option> | |
<option value="Mass">Mass</option> | |
<option value="Calcification">Calcification</option> | |
<option value="Asymmetry">Asymmetry</option> | |
<option value="Architectural Distortion">Architectural Distortion</option> | |
<option value="Other">Other</option> | |
</select> | |
</div> | |
<div class="mb-4"> | |
<label for="leftShape" class="block text-sm font-medium text-gray-700 mb-1">Lesion Shape</label> | |
<input type="text" id="leftShape" class="w-full px-3 py-2 border border-gray-300 rounded-md"> | |
</div> | |
<div class="mb-4"> | |
<label for="leftMargins" class="block text-sm font-medium text-gray-700 mb-1">Lesion Margins</label> | |
<input type="text" id="leftMargins" class="w-full px-3 py-2 border border-gray-300 rounded-md"> | |
</div> | |
<div class="mb-4"> | |
<label for="leftAssoc" class="block text-sm font-medium text-gray-700 mb-1">Associated Findings</label> | |
<textarea id="leftAssoc" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-md"></textarea> | |
</div> | |
<div class="mb-4"> | |
<label for="leftImpression" class="block text-sm font-medium text-gray-700 mb-1">Initial Impression</label> | |
<textarea id="leftImpression" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-md"></textarea> | |
</div> | |
<div class="mb-4"> | |
<label for="leftAction" class="block text-sm font-medium text-gray-700 mb-1">Recommended Action</label> | |
<select id="leftAction" class="w-full px-3 py-2 border border-gray-300 rounded-md"> | |
<option value=""></option> | |
<option value="Assign BI-RADS">Assign BI-RADS</option> | |
<option value="Request additional view">Request additional view</option> | |
<option value="Request ultrasound">Request ultrasound</option> | |
<option value="Biopsy">Biopsy</option> | |
<option value="Short-term follow-up">Short-term follow-up</option> | |
</select> | |
</div> | |
<div class="mb-4"> | |
<label for="leftBirads" class="block text-sm font-medium text-gray-700 mb-1">Final BI-RADS Category</label> | |
<select id="leftBirads" class="w-full px-3 py-2 border border-gray-300 rounded-md"> | |
<option value=""></option> | |
<option value="1">1</option> | |
<option value="2">2</option> | |
<option value="3">3</option> | |
<option value="4">4</option> | |
<option value="5">5</option> | |
</select> | |
</div> | |
<div class="mb-4"> | |
<label for="leftRationale" class="block text-sm font-medium text-gray-700 mb-1">Rationale / Reasoning</label> | |
<textarea id="leftRationale" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-md"></textarea> | |
</div> | |
</div> | |
</div> | |
</div> | |
<!-- Right Breast Findings --> | |
<div id="right-tab" class="tab-content"> | |
<div class="form-section"> | |
<div class="form-section-title">Right Breast Findings</div> | |
<div class="form-section-content"> | |
<div class="mb-4"> | |
<label for="rightLocation" class="block text-sm font-medium text-gray-700 mb-1">Lesion Location (M/W/Q)</label> | |
<input type="text" id="rightLocation" class="w-full px-3 py-2 border border-gray-300 rounded-md"> | |
</div> | |
<div class="mb-4"> | |
<label for="rightType" class="block text-sm font-medium text-gray-700 mb-1">Lesion Type</label> | |
<select id="rightType" class="w-full px-3 py-2 border border-gray-300 rounded-md"> | |
<option value=""></option> | |
<option value="Mass">Mass</option> | |
<option value="Calcification">Calcification</option> | |
<option value="Asymmetry">Asymmetry</option> | |
<option value="Architectural Distortion">Architectural Distortion</option> | |
<option value="Other">Other</option> | |
</select> | |
</div> | |
<div class="mb-4"> | |
<label for="rightShape" class="block text-sm font-medium text-gray-700 mb-1">Lesion Shape</label> | |
<input type="text" id="rightShape" class="w-full px-3 py-2 border border-gray-300 rounded-md"> | |
</div> | |
<div class="mb-4"> | |
<label for="rightMargins" class="block text-sm font-medium text-gray-700 mb-1">Lesion Margins</label> | |
<input type="text" id="rightMargins" class="w-full px-3 py-2 border border-gray-300 rounded-md"> | |
</div> | |
<div class="mb-4"> | |
<label for="rightAssoc" class="block text-sm font-medium text-gray-700 mb-1">Associated Findings</label> | |
<textarea id="rightAssoc" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-md"></textarea> | |
</div> | |
<div class="mb-4"> | |
<label for="rightImpression" class="block text-sm font-medium text-gray-700 mb-1">Initial Impression</label> | |
<textarea id="rightImpression" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-md"></textarea> | |
</div> | |
<div class="mb-4"> | |
<label for="rightAction" class="block text-sm font-medium text-gray-700 mb-1">Recommended Action</label> | |
<select id="rightAction" class="w-full px-3 py-2 border border-gray-300 rounded-md"> | |
<option value=""></option> | |
<option value="Assign BI-RADS">Assign BI-RADS</option> | |
<option value="Request additional view">Request additional view</option> | |
<option value="Request ultrasound">Request ultrasound</option> | |
<option value="Biopsy">Biopsy</option> | |
<option value="Short-term follow-up">Short-term follow-up</option> | |
</select> | |
</div> | |
<div class="mb-4"> | |
<label for="rightBirads" class="block text-sm font-medium text-gray-700 mb-1">Final BI-RADS Category</label> | |
<select id="rightBirads" class="w-full px-3 py-2 border border-gray-300 rounded-md"> | |
<option value=""></option> | |
<option value="1">1</option> | |
<option value="2">2</option> | |
<option value="3">3</option> | |
<option value="4">4</option> | |
<option value="5">5</option> | |
</select> | |
</div> | |
<div class="mb-4"> | |
<label for="rightRationale" class="block text-sm font-medium text-gray-700 mb-1">Rationale / Reasoning</label> | |
<textarea id="rightRationale" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-md"></textarea> | |
</div> | |
</div> | |
</div> | |
</div> | |
<!-- Clinical Reasoning --> | |
<div id="reasoning-tab" class="tab-content"> | |
<div class="form-section"> | |
<div class="form-section-title">Clinical Reasoning Sequence</div> | |
<div class="form-section-content"> | |
<div class="table-container"> | |
<table class="table-auto"> | |
<thead> | |
<tr> | |
<th>Step</th> | |
<th>Action Taken</th> | |
<th>Reasoning</th> | |
</tr> | |
</thead> | |
<tbody id="reasoningTableBody"> | |
<!-- Rows will be added here --> | |
</tbody> | |
</table> | |
</div> | |
<button id="addReasoningStep" class="mt-4 bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"> | |
Add Step | |
</button> | |
</div> | |
</div> | |
</div> | |
<!-- Final Recommendation --> | |
<div id="final-tab" class="tab-content"> | |
<div class="form-section"> | |
<div class="form-section-title">Final Recommendation</div> | |
<div class="form-section-content"> | |
<div class="mb-4"> | |
<label for="overallAssessment" class="block text-sm font-medium text-gray-700 mb-1">Overall Assessment</label> | |
<select id="overallAssessment" class="w-full px-3 py-2 border border-gray-300 rounded-md"> | |
<option value=""></option> | |
<option value="Benign">Benign</option> | |
<option value="Suspicion of Malignancy">Suspicion of Malignancy</option> | |
</select> | |
</div> | |
<div class="mb-4"> | |
<label for="nextStep" class="block text-sm font-medium text-gray-700 mb-1">Recommended Next Step</label> | |
<textarea id="nextStep" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-md"></textarea> | |
</div> | |
</div> | |
</div> | |
<div class="mt-6"> | |
<button id="saveReport" class="w-full bg-green-500 hover:bg-green-600 text-white px-4 py-3 rounded-lg font-semibold"> | |
Save Report | |
</button> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<script> | |
// Global variables | |
let pixelArray1 = null, pixelArray2 = null; | |
let dicomData1 = null, dicomData2 = null; | |
let patientInfoLoaded = false; | |
let currentZoom1 = 1, currentZoom2 = 1; | |
let isPanning1 = false, isPanning2 = false; | |
let panStartX1 = 0, panStartY1 = 0, panStartX2 = 0, panStartY2 = 0; | |
let offsetX1 = 0, offsetY1 = 0, offsetX2 = 0, offsetY2 = 0; | |
// DOM elements | |
const canvas1 = document.getElementById('canvas1'); | |
const canvas2 = document.getElementById('canvas2'); | |
const ctx1 = canvas1.getContext('2d'); | |
const ctx2 = canvas2.getContext('2d'); | |
// Initialize canvas sizes | |
function initCanvasSize(canvas, container) { | |
const containerWidth = container.clientWidth; | |
const containerHeight = container.clientHeight; | |
canvas.width = containerWidth; | |
canvas.height = containerHeight; | |
} | |
// Initialize canvas sizes on load | |
document.addEventListener('DOMContentLoaded', () => { | |
const container1 = document.querySelector('#canvas1').parentElement; | |
const container2 = document.querySelector('#canvas2').parentElement; | |
initCanvasSize(canvas1, container1); | |
initCanvasSize(canvas2, container2); | |
// Set up event listeners for window resize | |
window.addEventListener('resize', () => { | |
initCanvasSize(canvas1, container1); | |
initCanvasSize(canvas2, container2); | |
if (pixelArray1) updateImageDisplay(1); | |
if (pixelArray2) updateImageDisplay(2); | |
}); | |
// Set up tab switching | |
document.querySelectorAll('.tab-button').forEach(button => { | |
button.addEventListener('click', () => { | |
const tabId = button.getAttribute('data-tab'); | |
// Update active tab button | |
document.querySelectorAll('.tab-button').forEach(btn => { | |
btn.classList.remove('active'); | |
}); | |
button.classList.add('active'); | |
// Update active tab content | |
document.querySelectorAll('.tab-content').forEach(content => { | |
content.classList.remove('active'); | |
}); | |
document.getElementById(`${tabId}-tab`).classList.add('active'); | |
}); | |
}); | |
// Set up sliders | |
setupSliders(1); | |
setupSliders(2); | |
// Set up image open buttons | |
document.getElementById('openBtn1').addEventListener('click', () => openImageFile(1)); | |
document.getElementById('openBtn2').addEventListener('click', () => openImageFile(2)); | |
// Set up zoom/pan buttons for viewer 1 | |
document.querySelector('.zoom-in-btn1').addEventListener('click', () => zoomImage(1, 1.2)); | |
document.querySelector('.zoom-out-btn1').addEventListener('click', () => zoomImage(1, 0.8)); | |
document.querySelector('.pan-btn1').addEventListener('click', () => togglePanMode(1)); | |
document.querySelector('.reset-btn1').addEventListener('click', () => resetView(1)); | |
// Set up zoom/pan buttons for viewer 2 | |
document.querySelector('.zoom-in-btn2').addEventListener('click', () => zoomImage(2, 1.2)); | |
document.querySelector('.zoom-out-btn2').addEventListener('click', () => zoomImage(2, 0.8)); | |
document.querySelector('.pan-btn2').addEventListener('click', () => togglePanMode(2)); | |
document.querySelector('.reset-btn2').addEventListener('click', () => resetView(2)); | |
// Set up mouse events for panning | |
setupPanningEvents(1); | |
setupPanningEvents(2); | |
// Set up reasoning table | |
document.getElementById('addReasoningStep').addEventListener('click', addReasoningStep); | |
// Set up save report button | |
document.getElementById('saveReport').addEventListener('click', saveReport); | |
}); | |
function setupSliders(viewId) { | |
const levelSlider = document.getElementById(`levelSlider${viewId}`); | |
const widthSlider = document.getElementById(`widthSlider${viewId}`); | |
const levelValue = document.getElementById(`levelValue${viewId}`); | |
const widthValue = document.getElementById(`widthValue${viewId}`); | |
levelSlider.addEventListener('input', () => { | |
levelValue.textContent = levelSlider.value; | |
updateImageDisplay(viewId); | |
}); | |
widthSlider.addEventListener('input', () => { | |
widthValue.textContent = widthSlider.value; | |
updateImageDisplay(viewId); | |
}); | |
} | |
function setupPanningEvents(viewId) { | |
const canvas = document.getElementById(`canvas${viewId}`); | |
const panBtn = document.querySelector(`.pan-btn${viewId}`); | |
let isDragging = false; | |
let lastX = 0, lastY = 0; | |
// Pan with mouse drag | |
canvas.addEventListener('mousedown', (e) => { | |
isDragging = true; | |
lastX = e.clientX; | |
lastY = e.clientY; | |
canvas.style.cursor = 'grabbing'; | |
}); | |
canvas.addEventListener('mousemove', (e) => { | |
if (!isDragging) return; | |
const dx = e.clientX - lastX; | |
const dy = e.clientY - lastY; | |
lastX = e.clientX; | |
lastY = e.clientY; | |
if (viewId === 1 && pixelArray1) { | |
offsetX1 += dx; | |
offsetY1 += dy; | |
updateImageDisplay(1); | |
} else if (viewId === 2 && pixelArray2) { | |
offsetX2 += dx; | |
offsetY2 += dy; | |
updateImageDisplay(2); | |
} | |
}); | |
canvas.addEventListener('mouseup', () => { | |
isDragging = false; | |
canvas.style.cursor = 'grab'; | |
}); | |
canvas.addEventListener('mouseleave', () => { | |
isDragging = false; | |
canvas.style.cursor = 'default'; | |
}); | |
// Zoom with mouse wheel | |
canvas.addEventListener('wheel', (e) => { | |
e.preventDefault(); | |
const delta = -e.deltaY; | |
const zoomFactor = delta > 0 ? 1.1 : 0.9; | |
// Get mouse position relative to canvas | |
const rect = canvas.getBoundingClientRect(); | |
const mouseX = e.clientX - rect.left; | |
const mouseY = e.clientY - rect.top; | |
// Calculate image position before zoom | |
const imgX = (mouseX - (viewId === 1 ? offsetX1 : offsetX2)) / (viewId === 1 ? currentZoom1 : currentZoom2); | |
const imgY = (mouseY - (viewId === 1 ? offsetY1 : offsetY2)) / (viewId === 1 ? currentZoom1 : currentZoom2); | |
// Apply zoom | |
if (viewId === 1) { | |
currentZoom1 *= zoomFactor; | |
// Adjust offset to zoom toward mouse position | |
offsetX1 = mouseX - imgX * currentZoom1; | |
offsetY1 = mouseY - imgY * currentZoom1; | |
} else { | |
currentZoom2 *= zoomFactor; | |
offsetX2 = mouseX - imgX * currentZoom2; | |
offsetY2 = mouseY - imgY * currentZoom2; | |
} | |
updateImageDisplay(viewId); | |
}); | |
// Set cursor style | |
canvas.style.cursor = 'grab'; | |
} | |
function togglePanMode(viewId) { | |
if (viewId === 1) { | |
isPanning1 = !isPanning1; | |
document.querySelector('.pan-btn1').textContent = isPanning1 ? 'Panning (Click to stop)' : 'Pan'; | |
document.querySelector('.pan-btn1').classList.toggle('bg-blue-500', isPanning1); | |
document.querySelector('.pan-btn1').classList.toggle('text-white', isPanning1); | |
} else { | |
isPanning2 = !isPanning2; | |
document.querySelector('.pan-btn2').textContent = isPanning2 ? 'Panning (Click to stop)' : 'Pan'; | |
document.querySelector('.pan-btn2').classList.toggle('bg-blue-500', isPanning2); | |
document.querySelector('.pan-btn2').classList.toggle('text-white', isPanning2); | |
} | |
} | |
function zoomImage(viewId, factor) { | |
if (viewId === 1) { | |
currentZoom1 *= factor; | |
updateImageDisplay(1); | |
} else { | |
currentZoom2 *= factor; | |
updateImageDisplay(2); | |
} | |
} | |
function resetView(viewId) { | |
if (viewId === 1) { | |
currentZoom1 = 1; | |
offsetX1 = 0; | |
offsetY1 = 0; | |
updateImageDisplay(1); | |
} else { | |
currentZoom2 = 1; | |
offsetX2 = 0; | |
offsetY2 = 0; | |
updateImageDisplay(2); | |
} | |
} | |
function openImageFile(viewId) { | |
const input = document.createElement('input'); | |
input.type = 'file'; | |
input.accept = '.dcm,.npy,.png,.jpg,.jpeg,.bmp'; | |
input.onchange = e => { | |
const file = e.target.files[0]; | |
if (!file) return; | |
const reader = new FileReader(); | |
const extension = file.name.split('.').pop().toLowerCase(); | |
reader.onload = function(event) { | |
try { | |
let pixelArray = null; | |
let dicomData = null; | |
if (extension === 'dcm') { | |
// Parse DICOM file | |
const arrayBuffer = event.target.result; | |
const byteArray = new Uint8Array(arrayBuffer); | |
dicomData = dicomParser.parseDicom(byteArray); | |
// Extract pixel data | |
const pixelDataElement = dicomData.elements.x7fe00010; | |
if (!pixelDataElement) throw new Error("No pixel data found in DICOM file"); | |
const pixelData = dicomParser.explicitElementToString(dicomData, pixelDataElement); | |
const bitsAllocated = dicomData.int16('x00280100') || 16; | |
const bitsStored = dicomData.int16('x00280101') || bitsAllocated; | |
const highBit = dicomData.int16('x00280102') || (bitsStored - 1); | |
const pixelRepresentation = dicomData.int16('x00280103') || 0; | |
const rows = dicomData.int16('x00280010'); | |
const columns = dicomData.int16('x00280011'); | |
// Get rescale parameters if present | |
const rescaleIntercept = dicomData.floatString('x00281052') || 0; | |
const rescaleSlope = dicomData.floatString('x00281053') || 1; | |
// Convert pixel data to array | |
if (bitsAllocated === 16) { | |
pixelArray = new Uint16Array(rows * columns); | |
for (let i = 0; i < pixelArray.length; i++) { | |
const offset = i * 2; | |
let value = (pixelData.charCodeAt(offset) << 8) + pixelData.charCodeAt(offset + 1); | |
// Handle signed data | |
if (pixelRepresentation === 1 && (value & (1 << highBit))) { | |
value |= ~((1 << (highBit + 1)) - 1); | |
} | |
// Apply rescale | |
value = value * rescaleSlope + rescaleIntercept; | |
pixelArray[i] = value; | |
} | |
} else if (bitsAllocated === 8) { | |
pixelArray = new Uint8Array(rows * columns); | |
for (let i = 0; i < pixelArray.length; i++) { | |
let value = pixelData.charCodeAt(i); | |
// Handle signed data | |
if (pixelRepresentation === 1 && (value & (1 << highBit))) { | |
value |= ~((1 << (highBit + 1)) - 1); | |
} | |
// Apply rescale | |
value = value * rescaleSlope + rescaleIntercept; | |
pixelArray[i] = value; | |
} | |
} else { | |
throw new Error(`Unsupported bits allocated: ${bitsAllocated}`); | |
} | |
pixelArray = pixelArrayTo2D(pixelArray, columns, rows); | |
// Get window center/width from DICOM if available | |
try { | |
const windowCenter = dicomData.floatString('x00281050'); | |
const windowWidth = dicomData.floatString('x00281051'); | |
if (windowCenter && windowWidth) { | |
// Store for later use in setupViewerWithImage | |
dicomData.windowCenter = windowCenter; | |
dicomData.windowWidth = windowWidth; | |
} | |
} catch (e) { | |
// Ignore if windowing parameters not found | |
} | |
// Populate patient info if not already loaded | |
if (!patientInfoLoaded) { | |
populatePatientInfo(dicomData); | |
patientInfoLoaded = true; | |
} | |
// Show DICOM info | |
showDicomInfo(viewId, dicomData); | |
} else if (extension === 'npy') { | |
// For demo purposes, we'll just create a random array | |
// In a real app, you'd need a proper NPY parser | |
const size = 1024; | |
pixelArray = new Array(size); | |
for (let i = 0; i < size; i++) { | |
pixelArray[i] = new Array(size); | |
for (let j = 0; j < size; j++) { | |
pixelArray[i][j] = Math.floor(Math.random() * 4096); | |
} | |
} | |
} else if (['png', 'jpg', 'jpeg', 'bmp'].includes(extension)) { | |
// Load image file | |
const img = new Image(); | |
img.onload = function() { | |
// Draw image to canvas and get pixel data | |
const tempCanvas = document.createElement('canvas'); | |
tempCanvas.width = img.width; | |
tempCanvas.height = img.height; | |
const tempCtx = tempCanvas.getContext('2d'); | |
tempCtx.drawImage(img, 0, 0); | |
const imageData = tempCtx.getImageData(0, 0, img.width, img.height); | |
const data = imageData.data; | |
// Convert to grayscale 2D array | |
pixelArray = new Array(img.height); | |
for (let y = 0; y < img.height; y++) { | |
pixelArray[y] = new Array(img.width); | |
for (let x = 0; x < img.width; x++) { | |
const i = (y * img.width + x) * 4; | |
const r = data[i]; | |
const g = data[i + 1]; | |
const b = data[i + 2]; | |
pixelArray[y][x] = Math.round(0.299 * r + 0.587 * g + 0.114 * b); | |
} | |
} | |
// Store and display | |
if (viewId === 1) { | |
pixelArray1 = pixelArray; | |
dicomData1 = dicomData; | |
} else { | |
pixelArray2 = pixelArray; | |
dicomData2 = dicomData; | |
} | |
setupViewerWithImage(viewId); | |
}; | |
img.src = event.target.result; | |
return; | |
} else { | |
alert(`Unsupported file format: ${extension}`); | |
return; | |
} | |
// Store the pixel array and dicom data | |
if (viewId === 1) { | |
pixelArray1 = pixelArray; | |
dicomData1 = dicomData; | |
} else { | |
pixelArray2 = pixelArray; | |
dicomData2 = dicomData; | |
} | |
setupViewerWithImage(viewId); | |
} catch (error) { | |
alert(`Error loading file: ${error.message}`); | |
console.error(error); | |
} | |
}; | |
if (extension === 'dcm') { | |
reader.readAsArrayBuffer(file); | |
} else { | |
reader.readAsDataURL(file); | |
} | |
}; | |
input.click(); | |
} | |
function pixelArrayTo2D(pixelArray, width, height) { | |
const result = new Array(height); | |
for (let y = 0; y < height; y++) { | |
result[y] = new Array(width); | |
for (let x = 0; x < width; x++) { | |
result[y][x] = pixelArray[y * width + x]; | |
} | |
} | |
return result; | |
} | |
function populatePatientInfo(dicomData) { | |
document.getElementById('patientId').value = dicomData.string('x00100020') || ''; | |
// Try to get age from PatientAge tag (0010,1010) | |
const age = dicomData.string('x00101010'); | |
if (age) { | |
// DICOM age format is 3 digits followed by D, M, or Y (e.g., "045Y") | |
const ageValue = parseInt(age.substring(0, 3)); | |
const ageUnit = age.substring(3); | |
if (!isNaN(ageValue)) { | |
if (ageUnit === 'Y') { | |
document.getElementById('age').value = ageValue; | |
} else if (ageUnit === 'M') { | |
document.getElementById('age').value = Math.floor(ageValue / 12); | |
} else if (ageUnit === 'D') { | |
document.getElementById('age').value = Math.floor(ageValue / 365); | |
} | |
} | |
} | |
} | |
function showDicomInfo(viewId, dicomData) { | |
const container = document.getElementById(`dicomInfo${viewId}`); | |
const tagsContainer = document.getElementById(`dicomTags${viewId}`); | |
container.classList.remove('hidden'); | |
tagsContainer.innerHTML = ''; | |
// List of DICOM tags to display | |
const tagsToShow = [ | |
{ tag: 'x00080008', name: 'Image Type' }, | |
{ tag: 'x00080016', name: 'SOP Class UID' }, | |
{ tag: 'x00080018', name: 'SOP Instance UID' }, | |
{ tag: 'x00080020', name: 'Study Date' }, | |
{ tag: 'x00080030', name: 'Study Time' }, | |
{ tag: 'x00080050', name: 'Accession Number' }, | |
{ tag: 'x00080060', name: 'Modality' }, | |
{ tag: 'x00080070', name: 'Manufacturer' }, | |
{ tag: 'x00081030', name: 'Study Description' }, | |
{ tag: 'x0008103e', name: 'Series Description' }, | |
{ tag: 'x00100010', name: 'Patient Name' }, | |
{ tag: 'x00100020', name: 'Patient ID' }, | |
{ tag: 'x00100030', name: 'Patient Birth Date' }, | |
{ tag: 'x00100040', name: 'Patient Sex' }, | |
{ tag: 'x00101010', name: 'Patient Age' }, | |
{ tag: 'x00101020', name: 'Patient Size' }, | |
{ tag: 'x00101030', name: 'Patient Weight' }, | |
{ tag: 'x0020000d', name: 'Study Instance UID' }, | |
{ tag: 'x0020000e', name: 'Series Instance UID' }, | |
{ tag: 'x00200010', name: 'Study ID' }, | |
{ tag: 'x00200011', name: 'Series Number' }, | |
{ tag: 'x00200013', name: 'Instance Number' }, | |
{ tag: 'x00280010', name: 'Rows' }, | |
{ tag: 'x00280011', name: 'Columns' }, | |
{ tag: 'x00280100', name: 'Bits Allocated' }, | |
{ tag: 'x00280101', name: 'Bits Stored' }, | |
{ tag: 'x00280102', name: 'High Bit' }, | |
{ tag: 'x00280103', name: 'Pixel Representation' }, | |
{ tag: 'x00281050', name: 'Window Center' }, | |
{ tag: 'x00281051', name: 'Window Width' }, | |
{ tag: 'x00281052', name: 'Rescale Intercept' }, | |
{ tag: 'x00281053', name: 'Rescale Slope' }, | |
{ tag: 'x00282110', name: 'Lossy Image Compression' }, | |
{ tag: 'x00282112', name: 'Lossy Image Compression Ratio' } | |
]; | |
tagsToShow.forEach(tagInfo => { | |
try { | |
const value = dicomData.string(tagInfo.tag); | |
if (value) { | |
const div = document.createElement('div'); | |
div.innerHTML = `<span class="font-medium">${tagInfo.name}:</span> <span class="dicom-tag">${value}</span>`; | |
tagsContainer.appendChild(div); | |
} | |
} catch (e) { | |
// Tag not found, skip | |
} | |
}); | |
} | |
function setupViewerWithImage(viewId) { | |
const pixelArray = viewId === 1 ? pixelArray1 : pixelArray2; | |
const dicomData = viewId === 1 ? dicomData1 : dicomData2; | |
if (!pixelArray) return; | |
// Get min and max pixel values | |
let pixelMin = Infinity, pixelMax = -Infinity; | |
for (let y = 0; y < pixelArray.length; y++) { | |
for (let x = 0; x < pixelArray[y].length; x++) { | |
const val = pixelArray[y][x]; | |
if (val < pixelMin) pixelMin = val; | |
if (val > pixelMax) pixelMax = val; | |
} | |
} | |
// Set window level/width from DICOM or use defaults | |
let wc, ww; | |
if (dicomData) { | |
try { | |
wc = dicomData.floatString('x00281050') || pixelMin + (pixelMax - pixelMin) / 2; | |
ww = dicomData.floatString('x00281051') || pixelMax - pixelMin; | |
} catch (e) { | |
wc = pixelMin + (pixelMax - pixelMin) / 2; | |
ww = pixelMax - pixelMin; | |
} | |
} else { | |
wc = pixelMin + (pixelMax - pixelMin) / 2; | |
ww = pixelMax - pixelMin; | |
} | |
// Update sliders | |
const levelSlider = document.getElementById(`levelSlider${viewId}`); | |
const widthSlider = document.getElementById(`widthSlider${viewId}`); | |
const levelValue = document.getElementById(`levelValue${viewId}`); | |
const widthValue = document.getElementById(`widthValue${viewId}`); | |
levelSlider.min = pixelMin; | |
levelSlider.max = pixelMax; | |
levelSlider.value = wc; | |
levelValue.textContent = wc; | |
widthSlider.min = 1; | |
widthSlider.max = pixelMax - pixelMin > 0 ? pixelMax - pixelMin : 1; | |
widthSlider.value = ww; | |
widthValue.textContent = ww; | |
// Reset view | |
if (viewId === 1) { | |
currentZoom1 = 1; | |
offsetX1 = 0; | |
offsetY1 = 0; | |
} else { | |
currentZoom2 = 1; | |
offsetX2 = 0; | |
offsetY2 = 0; | |
} | |
updateImageDisplay(viewId); | |
} | |
function updateImageDisplay(viewId) { | |
const pixelArray = viewId === 1 ? pixelArray1 : pixelArray2; | |
if (!pixelArray) return; | |
const canvas = viewId === 1 ? canvas1 : canvas2; | |
const ctx = viewId === 1 ? ctx1 : ctx2; | |
const levelSlider = document.getElementById(`levelSlider${viewId}`); | |
const widthSlider = document.getElementById(`widthSlider${viewId}`); | |
const level = parseFloat(levelSlider.value); | |
const width = parseFloat(widthSlider.value); | |
// Calculate window min/max | |
const windowMin = level - width / 2; | |
const windowMax = level + width / 2; | |
// Create image data | |
const height = pixelArray.length; | |
const imgWidth = pixelArray[0].length; | |
// Create a temporary canvas for the original image | |
const tempCanvas = document.createElement('canvas'); | |
tempCanvas.width = imgWidth; | |
tempCanvas.height = height; | |
const tempCtx = tempCanvas.getContext('2d'); | |
const imageData = tempCtx.createImageData(imgWidth, height); | |
// Apply windowing to the pixel data | |
for (let y = 0; y < height; y++) { | |
for (let x = 0; x < imgWidth; x++) { | |
let pixelValue = pixelArray[y][x]; | |
// Apply windowing | |
if (windowMax > windowMin) { | |
pixelValue = Math.max(windowMin, Math.min(pixelValue, windowMax)); | |
pixelValue = ((pixelValue - windowMin) / (windowMax - windowMin)) * 255; | |
} else { | |
pixelValue = 0; | |
} | |
const i = (y * imgWidth + x) * 4; | |
imageData.data[i] = pixelValue; // R | |
imageData.data[i + 1] = pixelValue; // G | |
imageData.data[i + 2] = pixelValue; // B | |
imageData.data[i + 3] = 255; // A | |
} | |
} | |
// Put the image data to the temporary canvas | |
tempCtx.putImageData(imageData, 0, 0); | |
// Clear the main canvas | |
ctx.clearRect(0, 0, canvas.width, canvas.height); | |
// Calculate scaled dimensions based on zoom | |
const zoom = viewId === 1 ? currentZoom1 : currentZoom2; | |
const offsetX = viewId === 1 ? offsetX1 : offsetX2; | |
const offsetY = viewId === 1 ? offsetY1 : offsetY2; | |
const scaledWidth = imgWidth * zoom; | |
const scaledHeight = height * zoom; | |
// Calculate position to center the image | |
let x = (canvas.width - scaledWidth) / 2 + offsetX; | |
let y = (canvas.height - scaledHeight) / 2 + offsetY; | |
// Draw the scaled image with panning | |
ctx.drawImage(tempCanvas, 0, 0, imgWidth, height, x, y, scaledWidth, scaledHeight); | |
} | |
function addReasoningStep() { | |
const tbody = document.getElementById('reasoningTableBody'); | |
const row = document.createElement('tr'); | |
const stepCell = document.createElement('td'); | |
stepCell.textContent = tbody.children.length + 1; | |
const actionCell = document.createElement('td'); | |
const actionInput = document.createElement('input'); | |
actionInput.type = 'text'; | |
actionInput.className = 'w-full px-2 py-1 border border-gray-300 rounded'; | |
actionCell.appendChild(actionInput); | |
const reasoningCell = document.createElement('td'); | |
const reasoningInput = document.createElement('input'); | |
reasoningInput.type = 'text'; | |
reasoningInput.className = 'w-full px-2 py-1 border border-gray-300 rounded'; | |
reasoningCell.appendChild(reasoningInput); | |
row.appendChild(stepCell); | |
row.appendChild(actionCell); | |
row.appendChild(reasoningCell); | |
tbody.appendChild(row); | |
} | |
function saveReport() { | |
if (!pixelArray1 && !pixelArray2) { | |
alert('Please open at least one image file before saving.'); | |
return; | |
} | |
const reportData = { | |
patient_info: { | |
patient_id: document.getElementById('patientId').value, | |
age: document.getElementById('age').value, | |
breast_density: document.getElementById('density').value | |
}, | |
left_breast_findings: { | |
location: document.getElementById('leftLocation').value, | |
type: document.getElementById('leftType').value, | |
shape: document.getElementById('leftShape').value, | |
margins: document.getElementById('leftMargins').value, | |
associated_findings: document.getElementById('leftAssoc').value, | |
initial_impression: document.getElementById('leftImpression').value, | |
recommended_action: document.getElementById('leftAction').value, | |
birads_category: document.getElementById('leftBirads').value, | |
rationale: document.getElementById('leftRationale').value | |
}, | |
right_breast_findings: { | |
location: document.getElementById('rightLocation').value, | |
type: document.getElementById('rightType').value, | |
shape: document.getElementById('rightShape').value, | |
margins: document.getElementById('rightMargins').value, | |
associated_findings: document.getElementById('rightAssoc').value, | |
initial_impression: document.getElementById('rightImpression').value, | |
recommended_action: document.getElementById('rightAction').value, | |
birads_category: document.getElementById('rightBirads').value, | |
rationale: document.getElementById('rightRationale').value | |
}, | |
clinical_reasoning: [], | |
final_recommendation: { | |
overall_assessment: document.getElementById('overallAssessment').value, | |
recommended_next_step: document.getElementById('nextStep').value | |
} | |
}; | |
// Add reasoning steps | |
const reasoningRows = document.querySelectorAll('#reasoningTableBody tr'); | |
reasoningRows.forEach(row => { | |
const cells = row.querySelectorAll('td'); | |
reportData.clinical_reasoning.push({ | |
step: cells[0].textContent, | |
action: cells[1].querySelector('input').value, | |
reasoning: cells[2].querySelector('input').value | |
}); | |
}); | |
// Create JSON blob | |
const blob = new Blob([JSON.stringify(reportData, null, 2)], { type: 'application/json' }); | |
const url = URL.createObjectURL(blob); | |
// Create download link | |
const a = document.createElement('a'); | |
a.href = url; | |
a.download = 'mammogram_report.json'; | |
document.body.appendChild(a); | |
a.click(); | |
// Clean up | |
setTimeout(() => { | |
document.body.removeChild(a); | |
URL.revokeObjectURL(url); | |
}, 0); | |
alert('Report saved successfully!'); | |
} | |
</script> | |
<p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=mabrokma/mindslabmamo" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> | |
</html> |