PanTS_Search / 1012.html
jen900704's picture
Upload 2 files
6cdab19 verified
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>CT Finder · Search + Viewer (Dark)</title>
<style>
:root {
--bg: #0e1a2b;
--panel: #12243b;
--panel-soft: #132a45;
--ink: #e8eff9;
--sub: #a9b7cc;
--line: #243a57;
--brand: #2a6cff;
--brand-ink: #fff;
--accent: #febb02;
--good: #16a34a;
--bad: #ef4444;
--control-h: 44px
}
* {
box-sizing: border-box
}
body {
margin: 0;
font: 14px/1.55 Inter, system-ui, Segoe UI, Arial;
background: var(--bg);
color: var(--ink)
}
.hero {
background: #0a1b32;
padding: 22px 16px 18px
}
.container {
max-width: 1200px;
margin: 0 auto;
position: relative
}
.segment {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-right: 1px solid var(--line)
}
.segment:last-child {
border-right: none
}
.segment label {
font-weight: 800;
color: var(--sub);
min-width: 28px
}
.input,
.fakeInput {
width: 100%;
height: var(--control-h);
display: flex;
align-items: center;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel-soft);
color: var(--ink)
}
.input::placeholder {
color: #7e90ab
}
.inputWrap {
position: relative;
width: 100%;
display: flex;
align-items: center;
}
.clearBtn {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #9fb3d9;
font-size: 20px;
cursor: pointer;
line-height: 1;
opacity: 0;
transition: opacity 0.2s ease;
}
.clearBtn:hover {
color: #fff;
}
.inputWrap:has(input:not(:placeholder-shown)) .clearBtn {
opacity: 1; /* 有輸入時才顯示 ✕ */
}
.btnSearch {
height: var(--control-h);
align-self: center;
border-radius: 10px;
background: var(--brand);
color: var(--brand-ink);
border: none;
font-weight: 900;
padding: 0 22px;
min-width: 132px;
cursor: pointer;
margin: 0 2px
}
.pop {
position: relative
}
.popBtn {
width: 100%;
text-align: left;
cursor: pointer
}
.popPanel {
position: absolute;
top: calc(var(--control-h) + 10px);
left: 0;
z-index: 50;
display: none;
width: 520px;
max-width: 92vw;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
box-shadow: 0 10px 28px rgba(0, 0, 0, .35);
padding: 14px
}
.pop.open .popPanel {
display: block
}
.group {
margin-bottom: 12px
}
.groupTitle {
font-weight: 400;
margin-bottom: 6px;
color: var(--ink)
}
.chips {
display: flex;
gap: 6px;
flex-wrap: wrap
}
.chip {
padding: 6px 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: #0f223b;
color: var(--ink);
cursor: pointer
}
.chip.active {
outline: 2px solid #6ea8ff
}
.main {
max-width: 1200px;
margin: 16px auto;
display: grid;
gap: 16px
}
@media(min-width:1100px) {
.main {
grid-template-columns: 300px 1fr
}
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px
}
.filters {
padding: 16px 18px;
display: none;
min-width: 340px;
}
.filters.show {
display: block
}
.secTitle {
font-weight: 900;
margin: 2px 0 8px
}
.fset {
margin-bottom: 14px
}
.optRow {
display: flex;
align-items: center;
gap: 8px;
margin: 6px 0
}
.optRow input {
transform: translateY(1px)
}
.count {
color: var(--sub);
font-size: 12px
}
.showMore {
color: #8fb3ff;
background: transparent;
border: none;
cursor: pointer;
padding: 0 2px;
font-weight: 700
}
.badge {
display: inline-block;
border: 1px solid var(--line);
border-radius: 999px;
padding: 2px 8px;
font-size: 12px;
margin-left: 8px;
color: var(--sub)
}
label {
color: var(--ink)
}
/* Browse */
.recBar {
padding: 10px 12px;
position: relative
}
.recTitle {
font-weight: 900;
margin: 4px 0 8px
}
.recViewport {
position: relative
}
.recScroll {
display: flex;
gap: 12px;
overflow: auto;
padding-bottom: 8px;
scroll-snap-type: x mandatory;
scrollbar-width: none
}
.recScroll::-webkit-scrollbar {
display: none
}
.recCard {
min-width: 320px;
scroll-snap-align: start;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--panel);
overflow: hidden
}
.recThumb {
width: 100%;
aspect-ratio: 16/9;
background: #0f223b;
object-fit: cover
}
.recBody {
padding: 10px
}
.recMeta {
color: var(--sub);
font-size: 12px
}
.btn {
border: 1px solid var(--line);
background: var(--panel-soft);
color: var(--ink);
border-radius: 10px;
padding: 8px 12px;
cursor: pointer;
width: 100%;
margin-top: 8px
}
.recCtrl {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
border: 1px solid var(--line);
background: var(--panel-soft);
color: var(--ink);
border-radius: 999px;
display: grid;
place-items: center;
cursor: pointer;
opacity: .9
}
.recPrev {
left: -6px
}
.recNext {
right: -6px
}
.recPlay {
position: absolute;
right: 44px;
top: -6px;
width: 32px;
height: 32px;
border: 1px solid var(--line);
background: var(--panel-soft);
border-radius: 999px;
display: grid;
place-items: center;
cursor: pointer;
font-size: 14px
}
/* Results */
.resultsHead {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px
}
.counter {
font-weight: 900
}
.select {
border: 1px solid var(--line);
border-radius: 10px;
padding: 8px 10px;
background: var(--panel-soft);
color: var(--ink)
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
padding: 12px
}
.card {
border: 1px solid var(--line);
border-radius: 14px;
background: var(--panel);
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 6px 14px rgba(0, 0, 0, .25)
}
.card:hover {
box-shadow: 0 10px 24px rgba(0, 0, 0, .35);
transform: translateY(-1px)
}
.thumb {
aspect-ratio: 4/3;
width: 100%;
object-fit: cover;
background: #0f223b
}
.body {
padding: 10px
}
.titleRow {
display: flex;
align-items: center;
justify-content: space-between;
margin: 4px 0 6px
}
.caseLink {
font-weight: 900;
font-size: 16px;
color: #9ec5ff;
text-decoration: none;
letter-spacing: .2px
}
.caseLink:hover {
text-decoration: underline
}
.keyRow {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin: 6px 0 2px
}
.kv {
display: inline-flex;
gap: 6px;
align-items: center;
font-size: 13px
}
.kv .k {
color: var(--sub);
text-transform: capitalize;
letter-spacing: .4px;
}
.kv .v {
font-weight: 900
}
.tag {
font-size: 12px;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid transparent
}
.tag.ok {
color: var(--good);
background: rgba(22, 163, 74, .12);
border-color: rgba(34, 197, 94, .25)
}
.tag.bad {
color: var(--bad);
background: rgba(239, 68, 68, .12);
border-color: rgba(239, 68, 68, .25)
}
/* Preparing page */
.prepPage {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background: #000;
z-index: 1200;
pointer-events: auto
}
.prepPage.show {
display: flex
}
.prepBox {
text-align: center;
color: #cfd7ff
}
.prepTitle {
margin-top: 6px;
font-weight: 800
}
.prepHint {
margin-top: 4px;
color: #8ea6ff
}
/* Viewer */
.viewer {
position: fixed;
inset: 0;
background: #000;
color: #e8edf8;
z-index: 100;
display: none
}
.viewer.show {
display: block
}
.viewer.compact .v-sidebar,
.viewer.compact .v-card {
display: none
}
.v-toolbar {
position: fixed;
top: 12px;
left: 12px;
display: flex;
gap: 10px;
z-index: 1000;
pointer-events: auto
}
.iconBtn {
width: 40px;
height: 40px;
border: 1px solid #2b3146;
background: #0f1324;
color: #e8edf8;
border-radius: 12px;
display: grid;
place-items: center;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, .35)
}
.v-sidebar {
position: fixed;
top: 64px;
left: 0;
bottom: 0;
width: 260px;
background: #0f1324;
border-right: 1px solid #2b3146;
padding: 12px;
overflow: auto;
z-index: 200
}
.v-sidebar h3 {
margin: 2px 0 10px;
font-size: 16px
}
.toggleAll {
width: 100%;
background: #161a2e;
border: 1px solid #2b3146;
border-radius: 10px;
color: #e8edf8;
padding: 8px 10px;
margin-bottom: 8px;
cursor: pointer
}
.v-card {
position: fixed;
top: 60px;
left: 12px;
width: 300px;
background: #0f1324;
border: 1px solid #2b3146;
border-radius: 12px;
padding: 10px;
z-index: 300;
max-height: calc(100vh - 96px);
overflow: auto
}
.v-actions {
display: flex;
gap: 10px;
margin-top: 12px;
justify-content: space-between
}
.v-stage {
margin-left: 260px;
height: 100vh;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 12px;
padding: 72px 16px 16px 276px
}
.viewer.compact .v-stage {
margin-left: 0;
padding: 12px
}
.v-view {
position: relative;
border: none;
border-radius: 12px;
background: #000;
overflow: hidden
}
.v-view h4 {
display: none
}
.v-view img {
width: 100%;
height: 100%;
object-fit: contain;
background: #000
}
.center {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #cfd7ff
}
.center .mini {
width: 160px;
height: 100px;
background: #dcd7ce;
border-radius: 10px;
margin-bottom: 8px
}
.hidden {
display: none !important
}
/* modal */
.modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .55);
display: none;
align-items: center;
justify-content: center;
z-index: 1300
}
.modal.show {
display: flex
}
.modalBox {
width: min(720px, 92vw);
max-height: 80vh;
overflow: auto;
background: #0f1324;
color: #e8edf8;
border: 1px solid #2b3146;
border-radius: 12px;
padding: 14px;
box-shadow: 0 12px 32px rgba(0, 0, 0, .45)
}
.modalHead {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px
}
.iconBtn.sm {
width: 32px;
height: 32px;
border-radius: 10px
}
/* Class Map list style */
.cm {
display: flex;
flex-direction: column;
gap: 8px
}
.cm-head {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 800;
margin-bottom: 4px
}
.cm-group {
border: 1px solid #2b3146;
border-radius: 10px;
background: #0f1324;
overflow: hidden
}
.cm-row {
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
cursor: default
}
.cm-row .chev {
width: 18px;
text-align: center;
opacity: .9
}
.cm-row .title {
flex: 1;
font-weight: 700
}
.cm-row input[type=checkbox] {
accent-color: #6ea8ff
}
.cm-items {
padding: 8px 10px 10px 36px;
display: grid;
grid-template-columns: 1fr;
gap: 4px;
max-height: 240px;
overflow: auto;
border-top: 1px solid #202844
}
.cm-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 4px;
border-radius: 8px
}
.cm-item:hover {
background: #121837
}
.cm-item input[type=checkbox] {
accent-color: #6ea8ff;
transform: translateY(1px)
}
.cm-item .name {
font-size: 13px;
letter-spacing: .2px
}
.cm-group.closed .cm-items {
display: none
}
.v-sidebar::-webkit-scrollbar,
.cm-items::-webkit-scrollbar {
width: 10px;
height: 10px
}
.v-sidebar::-webkit-scrollbar-thumb,
.cm-items::-webkit-scrollbar-thumb {
background: #1c243a;
border-radius: 8px;
border: 2px solid #0f1324
}
.v-sidebar::-webkit-scrollbar-track,
.cm-items::-webkit-scrollbar-track {
background: transparent
}
/* tools */
.toolBtn {
width: 44px;
height: 44px;
border: 1px solid #2b3146;
background: #0f1324;
color: #e8edf8;
border-radius: 12px;
display: grid;
place-items: center;
cursor: pointer
}
.toolBtn svg {
width: 22px;
height: 22px
}
/* === Unified typography === */
.groupTitle,
.optRow label,
.secTitle {
font-weight: 400;
font-size: 14px;
color: var(--ink);
text-transform: capitalize; /* 首字母大寫 */
letter-spacing: 0.2px;
}
.chip,
.btnSearch,
.recTitle,
.counter,
.kv .v {
font-weight: 700;
font-size: 14px;
letter-spacing: 0.1px;
}
.optRow label {
display: flex;
align-items: center;
gap: 6px;
}
#tumorChips .chip[data-tumor="0"].active{
background: rgba(22,163,74,.12);
color: var(--good);
}
.recCtrl[disabled] { opacity: .45; cursor: not-allowed; }
/* === UI 修正 2025-10-16 === */
/* Hero section 單層深藍背景 */
.hero {
background: var(--bg);
padding: 22px 16px 18px;
}
/* Search Rail 調整:Search 按鈕固定在右邊 */
.searchRail {
display: flex;
align-items: center;
gap: 8px;
background: var(--panel);
border-radius: 14px;
padding: 6px 10px;
box-shadow: 0 0 0 3px var(--accent) inset;
}
/* 讓 ID 與 STA 部分撐滿 */
.searchRail .segment {
flex: 1;
border-right: none;
padding: 4px 6px;
}
/* Search 按鈕右側固定 */
.searchRail .btnSearch {
margin-left: auto;
height: var(--control-h);
border-radius: 10px;
background: var(--brand);
color: var(--brand-ink);
border: none;
font-weight: 900;
padding: 0 24px;
cursor: pointer;
}
/* Tumor chips 無外框扁平風格 */
#tumorChips .chip[data-tumor="1"],
#tumorChips .chip[data-tumor="0"] {
background: transparent;
border: none;
color: var(--ink);
font-weight: 600;
cursor: pointer;
}
#tumorChips .chip[data-tumor="1"].active {
color: var(--bad);
}
#tumorChips .chip[data-tumor="0"].active {
color: var(--good);
}
/* 任何沒打開的覆蓋層都徹底關掉 */
#prepPage:not(.show),
.modal[aria-hidden="true"] {
display: none !important;
pointer-events: none !important;
opacity: 0 !important;
}
/* 只保留最深的深藍色背景,不要多層 */
.pageBgTop, .hero::before, .hero::after {
display: none !important;
}
/* 確保 popPanel 只蓋在 STA 區塊內,不會擋到右側 Search 按鈕 */
.searchRail { position: relative; }
.pop { position: relative; }
.pop .popPanel {
position: absolute;
z-index: 1000;
pointer-events: auto;
}
/* 避免有東西全寬蓋住黃框(保險) */
.hero, .hero .container, .searchRail {
z-index: 1;
}
/* --- Tumor/No tumor 當成按鈕(pill) --- */
#tumorChips .chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid rgba(148,163,184,.35);
background: rgba(12,26,45,.35);
color: #e8eff9;
font-weight: 600;
cursor: pointer;
box-shadow: none;
transition: background .2s ease, border-color .2s ease, color .2s ease, transform .02s ease;
user-select: none;
}
#tumorChips .chip:hover {
background: rgba(34,73,130,.35);
border-color: rgba(148,163,184,.6);
}
#tumorChips .chip.active {
background: rgba(40,90,150,.9);
border-color: rgba(148,163,184,.9);
color: #fff;
}
#tumorChips .chip:active { transform: translateY(0); } /* 不縮放,避免「彈跳」感 */
/* =====================================================
2) Tumor / No tumor 文字(Results + Browse)
===================================================== */
.tag.ok,
.tag.bad{
background: none !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
font-weight: 700;
font-size: 1.08rem; /* 再大一點 */
letter-spacing: .2px;
line-height: 1.2;
}
.tag.ok{ color: #22c55e !important; } /* 綠 */
.tag.bad{ color: #f87171 !important; } /* 紅 */
/* =====================================================
3) Results / Browse 縮圖 → 正方形 + 置中裁切
===================================================== */
.card .thumb,
.recScroll .card .thumb,
.recScroll .recThumb{
width: 100%;
aspect-ratio: 1 / 1; /* ✅ 正方形 */
object-fit: cover;
display: block;
background: #0f223b;
border-radius: 12px; /* 卡片整體圓角一致 */
}
/* =====================================================
4) Browse:與 Results 卡片統一、水平滑動穩定
===================================================== */
.recViewport{
position: relative; /* 供控制鍵定位 */
}
.recScroll{
display: flex;
gap: 12px;
overflow-x: auto;
overflow-y: hidden; /* ✅ 關掉垂直捲動,避免高度跳動 */
scroll-behavior: smooth;
scrollbar-gutter: stable both-edges; /* ✅ 捲軸溝槽固定,不抖動 */
padding: 2px 2px 8px;
}
.recScroll .card{
flex: 0 0 340px;
min-width: 340px;
position: relative;
z-index: 1;
}
/* =====================================================
5) Browse 控制鍵:固定在最上層且不跳動
===================================================== */
#recPrev, #recNext, #recPlay{
position: absolute !important;
z-index: 9999 !important; /* ✅ 永遠在最上層 */
background: rgba(12,26,45,.85);
border: none;
color: #e8eff9;
width: 38px;
height: 38px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0,0,0,.25);
transition: background .2s ease, opacity .12s ease; /* ← 不再縮放 */
pointer-events: auto;
}
#recPrev:hover, #recNext:hover, #recPlay:hover{
background: rgba(34,73,130,.95);
}
#recPrev:active, #recNext:active, #recPlay:active{
opacity: .9; /* ← 取代 transform: scale(.96) */
}
/* 位置:左右置中,暫停放右上角 */
#recPrev{ left: 8px; top: 50%; transform: translateY(-50%); }
#recNext{ right: 8px; top: 50%; transform: translateY(-50%); }
#recPlay{ right: 12px; top: 10px; transform: none; }
/* 讓搜尋區與彈窗永遠在最上層 */
.hero,
.searchRail,
.pop { position: relative; z-index: 10010; }
/* 彈出面板本身層級再墊高 */
.pop .popPanel { position: absolute; z-index: 10020; }
/* 確保彈窗不被父層裁切 */
.hero, .searchRail, .pop { overflow: visible; }
/* 降低 Browse 控制鍵層級(仍高於卡片即可)*/
#recPrev, #recNext, #recPlay { z-index: 9000 !important; }
/* 確保 Viewer 永遠在最上層 */
#viewer {
position: fixed; /* 脫離文流,覆蓋整個視窗 */
inset: 0; /* top/right/bottom/left = 0 */
z-index: 10000; /* 比任何 header/toolbar 都高 */
display: none; /* 你原本用 .show 控制顯示即可 */
}
#viewer.show { display: block; }
/* 準備中遮罩若有用到,也要更高層級 */
#prepPage, #reportModal {
position: fixed;
inset: 0;
z-index: 10001; /* 蓋過 viewer 內容 */
}
/* 若有頂部固定/黏著的 header,給它較低層級 */
#masthead, .topbar, .searchBarWrap {
z-index: 100; /* 低於 viewer */
}
/* 避免 header 在 viewer 開啟時可點選/聚焦 */
body.viewer-open #masthead,
body.viewer-open .topbar,
body.viewer-open .searchBarWrap {
pointer-events: none; /* 防止點到背後的搜尋列 */
}
.searchBarWrap.hidden,
#masthead.hidden { display: none !important; }
/* Viewer 開啟時,整個搜尋列隱藏 */
body.viewer-open .hero {
display: none !important;
}
/* 安全:把任何置頂工具列層級壓低,viewer 永遠在最上層 */
#masthead, .topbar, .searchBarWrap { z-index: 100; }
#viewer { position: fixed; inset: 0; z-index: 10000; }
/* 小字提示:Multi-Select */
.hint {
display: inline-block;
margin-left: .5rem;
padding: 2px 6px;
font-size: 12px;
letter-spacing: .2px;
border-radius: 999px;
background: rgba(255,255,255,.08);
color: #aab7c7;
vertical-align: middle;
}
/* 讓 chips 看起來「平面、不像按鈕」 */
.chipArea.flat .chip {
border: 0 !important;
box-shadow: none !important;
background: transparent !important;
color: #c9d6e2;
padding: 6px 12px;
line-height: 1.1;
transition: background .15s ease, color .15s ease;
}
.chipArea.flat .chip:hover {
background: rgba(255,255,255,.06) !important;
}
.chipArea.flat .chip.active {
background: rgba(59,130,246,.25) !important; /* 類 Tumor 的選取感 */
color: #e6f0ff !important;
}
/* 讓整條變成 flex,第三個子節點(Search)自動靠右 */
.searchRail {
display: flex;
align-items: center;
gap: 8px;
}
/* 兩個 segment 各自撐滿可用寬度,但不把按鈕擠走 */
.searchRail > .segment {
flex: 1 1 0;
min-width: 0; /* 防止內部內容把按鈕擠出畫面 */
border-right: none; /* 已改成 flex,不需要分隔線 */
padding: 4px 6px;
}
/* Search 按鈕固定靠最右邊 */
.searchRail > .btnSearch {
margin-left: auto; /* 關鍵:把按鈕推到最右 */
}
/* 讓 popPanel 以第二段為定位容器,不會蓋到右邊按鈕 */
#segSTA { position: relative; }
#segSTA .popPanel {
left: 0; /* 也可以改成 right:0; 視你要靠左/靠右貼齊 */
right: auto;
}
#cards.centerOne{
display: flex;
justify-content: center;
align-items: flex-start;
gap: 24px;
}
#cards.centerOne .card{
max-width: 560px;
width: 100%;
}
.optRow.disabled { opacity: .45; pointer-events: none; }
.optRow.disabled .count { opacity: .8; }
</style>
</head>
<body>
<button id="homeBtn" class="home-btn" title="Home" aria-label="Home">
<!-- 內嵌 SVG,不用額外圖檔 -->
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true">
<path d="M12 3l9 8h-3v9h-5v-6H11v6H6v-9H3l9-8z"/>
</svg>
</button>
<!-- ✅ Search Section -->
<section class="hero">
<div class="container">
<div class="searchRail">
<!-- ID -->
<div class="segment pop" id="popID">
<label>ID</label>
<div class="inputWrap">
<input id="q" class="input popBtn" placeholder="Search Case ID or keyword…" autocomplete="off">
<button id="clearBtn" class="clearBtn" title="Clear" aria-label="Clear">×</button>
</div>
<div class="popPanel" style="width:520px">
<div class="group">
<div class="groupTitle">Recommended IDs</div>
<div id="idReco" class="chips"></div>
</div>
<div class="group">
<div class="groupTitle">Recently viewed</div>
<div id="idRecent" class="chips">
<span class="recMeta">No recent</span>
</div>
</div>
</div>
</div>
<!-- STA -->
<div class="segment pop" id="popSTA">
<button class="fakeInput popBtn" type="button">
<span id="staSummary">Any tumor · Any sex · Any age</span>
</button>
<div class="popPanel" style="max-width:560px">
<div class="group">
<div class="groupTitle">Tumor</div>
<div id="tumorChips" class="chips">
<button class="chip" data-tumor="">Any</button>
<button class="chip flat" data-tumor="1">Tumor</button>
<button class="chip flat" data-tumor="0">No tumor</button>
</div>
</div>
<div class="group">
<div class="groupTitle">Sex <span class="hint">Multi-Select</span></div>
<div id="sexChips" class="chipArea flat">
<button class="chip" data-sex="">Any</button>
<button class="chip" data-sex="M">Male</button>
<button class="chip" data-sex="F">Female</button>
<button class="chip" data-sex="U">Unknown</button>
</div>
</div>
<div class="group">
<div class="groupTitle">Age <span class="hint">Multi-Select</span></div>
<div id="ageChips" class="chipArea flat"></div>
</div>
</div> <!-- /popPanel -->
</div> <!-- /segment popSTA -->
<!-- ✅ Search button fixed at right -->
<button id="searchBtn" class="btnSearch" type="button">Search</button>
</div> <!-- /.searchRail -->
</div>
</section>
<style>
/* === 修正版 Search Rail 樣式 === */
/* Flex 佈局讓 Search 固定右側 */
.searchRail {
display: flex;
align-items: center;
gap: 8px;
background: var(--panel);
border-radius: 14px;
padding: 6px 10px;
box-shadow: 0 0 0 3px var(--accent) inset;
position: relative;
overflow: visible;
}
/* Segment 撐滿 */
.searchRail > .segment {
flex: 1 1 0;
min-width: 0;
border-right: none;
padding: 4px 6px;
}
/* ✅ Search 按鈕靠最右邊 */
.searchRail > .btnSearch {
margin-left: auto;
height: var(--control-h);
border-radius: 10px;
background: var(--brand);
color: var(--brand-ink);
border: none;
font-weight: 900;
padding: 0 24px;
cursor: pointer;
}
/* PopPanel 定位於 STA 下方,不覆蓋 Search */
#popSTA { position: relative; }
#popSTA .popPanel {
position: absolute;
top: calc(var(--control-h) + 10px);
left: 0;
z-index: 1000;
pointer-events: auto;
}
/* Hero 保留最上層 */
.hero, .hero .container, .searchRail {
z-index: 10;
position: relative;
}
/* 確保樣式一致 */
.fakeInput {
width: 100%;
height: var(--control-h);
display: flex;
align-items: center;
padding: 10px 12px;
border-radius: 10px;
background: var(--panel-soft);
color: var(--ink);
border: 1px solid var(--line);
cursor: pointer;
}
.btnSearch:hover {
background: #3b7aff;
transition: background .2s ease;
}
#cards.centerOne{
display: flex;
justify-content: center;
align-items: flex-start;
gap: 24px;
}
#cards.centerOne .card{
max-width: 560px;
width: 100%;
}
.home-btn{
position: fixed; top: 25px; left: 40px;
width: 44px; height: 44px;
display: grid; place-items: center;
background: #2a3342; color: #e6ecff;
border: 1px solid rgba(255,255,255,.12);
border-radius: 12px;
box-shadow: 0 6px 16px rgba(0,0,0,.25);
cursor: pointer; z-index: 9999;
}
.home-btn:hover{ filter: brightness(1.08); }
.home-btn:active{ transform: translateY(1px); }
</style>
<style>
/* === 讓整體左右空白變窄,Advanced / Results 同步變寬 === */
.main, .content, .twoCols {
display: grid;
grid-template-columns: 380px minmax(0, 1fr); /* 左側 Advanced 380px,可依需要改 */
gap: 20px; /* 中間距離稍微縮小 */
max-width: 100%;
width: 100%;
margin: 0 auto;
padding-left: 20px; /* 左右外邊界都縮小 */
padding-right: 20px;
}
/* Advanced(左側) */
#filters, .facetPanel {
width: 100%;
max-width: 400px;
}
/* Results(右側卡片區) */
#resultsPanel {
width: 100%;
margin-left: 0;
}
/* 卡片區:桌機 3 欄、平板 2 欄、手機 1 欄;支援單筆置中 */
#cards{
display: grid;
grid-template-columns: repeat(3, minmax(320px, 1fr));
gap: 24px;
align-items: stretch;
}
/* 窄一點的螢幕:2 欄(多筆結果時仍用 grid) */
@media (max-width: 1200px){
#cards{ grid-template-columns: repeat(2, minmax(300px, 1fr)); gap: 20px; }
}
/* 手機:1 欄 */
@media (max-width: 700px){
#cards{ grid-template-columns: 1fr; gap: 16px; }
}
/* 縮圖高度 */
.card .thumb{
height: 320px;
object-fit: cover;
}
/* 寬螢幕:左右內距 & 卡片稍微加大 */
@media (min-width: 1600px){
.main, .content, .twoCols{
grid-template-columns: 420px minmax(0, 1fr);
padding-left: 40px;
padding-right: 40px;
}
#cards{
grid-template-columns: repeat(3, minmax(360px, 1fr));
gap: 26px;
}
.card .thumb{ height: 340px; }
}
/* 在較小螢幕下自動收窄左側 Advanced 與右側內容 */
@media (max-width: 1300px) {
.main, .content, .twoCols {
grid-template-columns: 340px minmax(0, 1fr);
padding-left: 16px;
padding-right: 16px;
}
}
/* 如果你仍然保留了 panel 外框,想要更平面可關掉樣式(沒有就忽略這段)*/
.filters.panel, #resultsPanel.panel {
background: transparent;
border: 0;
box-shadow: none;
}
/* === Facet List(Advanced)右側數字對齊 === */
.fset .optRow{
display: flex; /* label 在左、count 在最右 */
align-items: center;
gap: 8px;
padding: 2px 0;
}
/* 讓標籤占滿中間空間,避免擠到 count */
.fset .optRow > label{
display: flex;
align-items: center;
gap: 8px;
flex: 1 1 auto; /* 撐滿中間 */
min-width: 0; /* 允許省略號 */
}
/* 勾選框微調垂直對齊 */
.fset .optRow > label input[type="checkbox"]{
transform: translateY(1px);
}
/* 長標籤省略號處理(多國名稱不會把數字擠下去) */
.fset .optRow > label strong{
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
/* 數字固定在最右且等寬對齊 */
.fset .optRow .count{
margin-left: auto; /* 推到最右 */
flex: 0 0 auto;
min-width: 3ch; /* 固定最小寬(2~3 位數不抖動) */
text-align: right;
color: var(--sub);
font-variant-numeric: tabular-nums; /* 等寬數字 */
opacity: .95;
}
/* 小一點的項目行距,整體更緊實 */
.fset .optRow + .optRow{ margin-top: 2px; }
/* 「Show more」與行文對齊 */
.fset .showMore{
margin-top: 6px;
padding: 0;
align-self: flex-start;
}
/* === Age Chips:增加間距、可自動換行 === */
#ageChips {
display: flex;
flex-wrap: wrap; /* ✅ 超過寬度就自動換行 */
gap: 10px 12px; /* ✅ 垂直間距10px、水平間距12px */
margin-top: 6px;
align-items: flex-start; /* 第一行與第二行頂齊 */
justify-content: flex-start;
}
#ageChips .chip {
flex: 0 0 auto; /* 不壓縮、不撐滿 */
padding: 8px 14px;
border-radius: 999px;
border: 1px solid rgba(148,163,184,.35);
background: rgba(12,26,45,.35);
color: #e8eff9;
font-weight: 600;
cursor: pointer;
transition: background .2s ease, border-color .2s ease, color .2s ease;
}
#ageChips .chip:hover {
background: rgba(34,73,130,.35);
border-color: rgba(148,163,184,.6);
}
#ageChips .chip.active {
background: rgba(40,90,150,.9);
border-color: rgba(148,163,184,.9);
color: #fff;
}
#cards.centerOne{
display: flex;
justify-content: center;
align-items: flex-start;
gap: 24px;
}
#cards.centerOne .card{
max-width: 560px;
width: 100%;
padding-left: 50%;
}
/* 群組標題 & Advanced 要粗體 */
.secTitle { font-weight: 800; }
.groupTitle { font-weight: 700; }
/* 所有選項(含 Any)一律一般字重 */
.optRow label,
#ct_phase_opts label,
#manufacturer_opts label,
#model_opts label,
#type_opts label,
#nat_opts label,
#year_opts label {
font-weight: 400;
}
/* 動態清單裡的 <strong> 取消粗體,讓它跟 Any 一樣 */
.optRow label strong,
#ct_phase_opts label strong,
#manufacturer_opts label strong,
#model_opts label strong,
#type_opts label strong,
#nat_opts label strong,
#year_opts label strong {
font-weight: 400;
}
.optRow label .lbl { font-weight: 400; }
.secTitle { font-weight: 800; }
.groupTitle { font-weight: 700; }
:root {
--single-results-offset: 22vw; /* 卡片偏移量 */
}
/* ✅ Results: 靠左貼主體,不再跟著偏移 */
.resultsHead.single {
margin-left: -10vw; /* 可調整成 -2vw / -3vw 取決於視覺需求 */
padding-left: 16px; /* 對齊內容區整體的 padding */
text-align: left;
width: auto;
}
/* ✅ 結果容器:只偏移卡片區域 */
#resultsPanel.single {
margin-left: var(--single-results-offset);
width: calc(100% - var(--single-results-offset));
}
/* ✅ 單筆卡片置中 */
#cards.centerOne {
display: flex;
justify-content: center;
align-items: flex-start;
gap: 24px;
}
#cards.centerOne .card {
flex: 0 0 340px;
max-width: 340px;
width: 100%;
padding-left: 0 !important;
}
/* ✅ 窄螢幕時回歸滿寬 */
@media (max-width: 900px) {
.resultsHead.single {
margin-left: 0;
padding-left: 10px;
}
#resultsPanel.single {
margin-left: 0;
width: 100%;
}
}
/* 統一卡片字型與大小 */
.card {
font-family: "Segoe UI", "Inter", "Helvetica Neue", Arial, sans-serif;
font-size: 15px;
line-height: 1.4;
color: #e2e8f0;
font-weight: 500;
}
/* Case ID 標題 */
.card .titleRow a {
font-weight: 700;
font-size: 15px;
color: #60a5fa;
text-decoration: none;
}
.card .titleRow a:hover {
color: #93c5fd;
}
/* Sex / Age 等欄位 */
.card .keyRow {
font-size: 15px;
color: #cbd5e1;
}
.card .keyRow span.k {
font-weight: 600;
}
.card .keyRow span.v {
font-weight: 500;
}
/* Tumor 標籤 — 同樣大小、不放大 */
.card .tag.ok,
.card .tag.bad {
font-size: 15px;
font-weight: 600;
}
.card .tag.ok {
color: #22c55e; /* 綠色 */
}
.card .tag.bad {
color: #ef4444; /* 紅色 */
}
</style>
<!-- Browse -->
<section class="panel container recBar" id="recBar">
<div class="recTitle">Browse</div>
<div class="recViewport">
<button class="recCtrl recPrev" id="recPrev" title="Previous"></button>
<button class="recCtrl recNext" id="recNext" title="Next"></button>
<button class="recPlay" id="recPlay" title="Pause/Play"></button>
<div id="recScroll" class="recScroll"></div>
</div>
</section>
<!-- Main -->
<section class="main container">
<aside id="filters" class="filters panel">
<div class="secTitle">Advanced</div>
<!-- CT phase -->
<div class="fset" id="fs_ct">
<div class="groupTitle">CT phase</div>
<div class="optRow">
<label><input type="checkbox" data-k="ct_phase" value="" checked> Any</label>
</div>
<div id="ct_phase_opts"></div>
<button class="showMore" data-target="ct_phase_opts" data-limit="12">Show more</button>
</div>
<!-- Manufacturer -->
<div class="fset" id="fs_mfr">
<div class="groupTitle">Manufacturer</div>
<div class="optRow">
<label><input type="checkbox" data-k="manufacturer" value="" checked> Any</label>
</div>
<div id="manufacturer_opts"></div>
<button class="showMore" data-target="manufacturer_opts" data-limit="12">Show more</button>
</div>
<!-- Model -->
<div class="fset" id="fs_model">
<div class="groupTitle">Model</div>
<div class="optRow">
<label><input type="checkbox" data-k="model" value="" checked> Any</label>
</div>
<div id="model_opts"></div>
<button class="showMore" data-target="model_opts" data-limit="12">Show more</button>
</div>
<!-- Study type -->
<div class="fset" id="fs_type">
<div class="groupTitle">Study type</div>
<div class="optRow">
<label><input type="checkbox" data-k="study_type" value="" checked> Any</label>
</div>
<div id="type_opts"></div>
<button class="showMore" data-target="type_opts" data-limit="12">Show more</button>
</div>
<!-- Site nationality -->
<div class="fset" id="fs_nat">
<div class="groupTitle">Site nationality</div>
<div class="optRow">
<label><input type="checkbox" data-k="site_nat" value="" checked> Any</label>
</div>
<div id="nat_opts"></div>
<button class="showMore" data-target="nat_opts" data-limit="12">Show more</button>
</div>
<!-- Study year -->
<div class="fset" id="fs_year">
<div class="groupTitle">Study year</div>
<div class="optRow">
<label><input type="checkbox" data-k="year" value="" checked> Any</label>
</div>
<div id="year_opts"></div>
<button class="showMore" data-target="year_opts" data-limit="20">Show more</button>
</div>
</aside>
<section id="resultsPanel" class="panel" style="display:none">
<div class="resultsHead" style="display:none">
<div id="counter" class="counter">Results: 0 cases</div>
<select id="sortBy" class="select">
<option value="quality">Quality (high → low)</option>
<option value="spacing_asc">Spacing (low → high)</option>
<option value="shape_desc">Shape score (high → low)</option>
<option value="age_asc">Age (young → old)</option>
<option value="age_desc">Age (old → young)</option>
<option value="id_asc">ID (low → high)</option>
<option value="id_desc">ID (high → low)</option>
</select>
</div>
<div id="cards" class="cards" aria-live="polite" style="display:none"></div>
</section>
</section>
<!-- Preparing Page -->
<div id="prepPage" class="prepPage" aria-hidden="true">
<div class="prepBox">
<img id="prepImg" src="" alt=""
style="width:180px;height:110px;border-radius:12px;background:#dcd7ce;display:block;margin:0 auto 12px;">
<div class="prepTitle">Preparing data...</div>
<div class="prepHint">‹ pancreas ›</div>
</div>
</div>
<!-- Viewer -->
<section id="viewer" class="viewer" aria-hidden="true">
<div class="v-toolbar">
<button class="iconBtn" id="btnBack" title="Back to search"></button>
<button class="iconBtn" id="btnGear" title="Viewer settings">⚙️</button>
<button class="iconBtn" id="btnClassMap" style="display:none" aria-hidden="true">🗺️</button>
</div>
<aside class="v-sidebar" id="vSidebar" style="display:none">
<div class="cm">
<div class="cm-head">
<h3 style="margin:0">Organs</h3>
<button class="toggleAll" id="toggleAll">Toggle all</button>
</div>
<div id="cmRoot"></div>
</div>
</aside>
<div class="v-card" id="vCard" style="display:none">
<h5 id="caseTitle" style="margin:2px 0 8px">Case ID: —</h5>
<div class="row"><label style="min-width:95px;color:#9aa3b2">Label Opacity</label><input id="op"
type="range" min="0" max="100" value="60"><span id="opv" class="value">60</span></div>
<div class="row"><label style="min-width:95px;color:#9aa3b2">Level</label><input id="lvl" type="range"
min="-200" max="200" value="50"><span id="lvlv" class="value">50</span></div>
<div class="row"><label style="min-width:95px;color:#9aa3b2">Window</label><input id="win" type="range"
min="100" max="2000" value="400"><span id="winv" class="value">400</span></div>
<button id="openClassMap" class="btn" style="width:100%;margin-top:10px">Class Map</button>
<div class="v-actions">
<button id="zoomIn" class="toolBtn" title="Zoom in"></button>
<button id="zoomOut" class="toolBtn" title="Zoom out"></button>
<button id="download" class="toolBtn" title="Download"></button>
<button id="report" class="toolBtn" title="Report"></button>
</div>
</div>
<main class="v-stage">
<section class="v-view">
<h4>Axial</h4><img id="axial" alt="axial">
</section>
<section class="v-view">
<h4>Sagittal</h4><img id="sagittal" alt="sagittal">
</section>
<section class="v-view">
<h4>Coronal</h4><img id="coronal" alt="coronal">
</section>
<section class="v-view">
<h4>3D</h4>
<div class="center" id="prep">
<div class="mini"></div>
<div>Preparing data…</div>
<div style="color:#8ea6ff;margin-top:4px">‹ pancreas ›</div>
</div>
</section>
</main>
</section>
<!-- Report Modal -->
<div id="reportModal" class="modal" aria-hidden="true">
<div class="modalBox">
<div class="modalHead">
<h3 style="margin:0">Case Report</h3>
<button id="rpClose" class="iconBtn sm"></button>
</div>
<div class="modalBody">
<p style="color:#9aa3b2">Report content goes here…</p>
</div>
</div>
</div>
<script>
// 顯示「Results: …」/ 設定總數
function setResultsLoading(on = true) {
const el = document.querySelector('#resCount, .resultsHead .counter');
if (!el) return;
el.textContent = on ? 'Results: …' : el.textContent;
}
function setResultsCount(n) {
const el = document.querySelector('#resCount, .resultsHead .counter');
if (!el) return;
el.textContent = `Results: ${n} ${n === 1 ? 'case' : 'cases'}`;
}
/* ===== helpers ===== */
// ✅ 安全版本(避免重複宣告)
window._showEl = window._showEl || function (sel, on) {
const n = typeof sel === 'string' ? document.querySelector(sel) : sel;
if (!n) return;
if (on) n.style.removeProperty('display');
else n.style.display = 'none';
};
// --- Profile helpers (HuggingFace) ---
function pad8(n){
const s = String(n ?? '').replace(/\D/g, '');
return s ? s.padStart(8, '0') : '';
}
function profileURL(idNum){
const p = pad8(idNum);
if (!p) return '';
return `https://huggingface.co/datasets/BodyMaps/iPanTSMini/resolve/main/profile_only/PanTS_${p}/profile.jpg`;
}
function extractNumFromId(idStr){
const m = String(idStr || '').match(/\d+/);
return m ? Number(m[0]) : NaN;
}
const $ = s => document.querySelector(s), $$ = s => Array.from(document.querySelectorAll(s));
const svg = (t, b = '#0f223b', f = '#94a3b8') => 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300"><rect width="400" height="300" fill="${b}"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="Arial" font-size="18" fill="${f}">${t}</text></svg>`);
const vsvg = t => 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300"><rect width="400" height="300" fill="#0a0d18"/><text x="50%" y="50%" fill="#9aa3b2" text-anchor="middle" dominant-baseline="middle" font-family="Arial" font-size="16">${t}</text></svg>`);
const state = {
q: '',
sex: [], // ['M','F','UNKNOWN']
tumor: '',
age_from: '',
age_to: '',
age_bin: [], // ['0-9','10-19',...,'90-99','UNKNOWN']
sort_by: 'quality',
ct_phase: [],
manufacturer: [],
model: [],
study_type: [],
site_nat: [],
year: [],
per_page: 10000,
page: 1
};
let ALL_ITEMS = [], lastFetched = [];
let HAS_SEARCHED = false;
/* ---- UI helpers (safe, no re-declare) ---- */
window._toggleEl = window._toggleEl || function (selOrNode, on) {
const n = (typeof selOrNode === 'string') ? document.querySelector(selOrNode) : selOrNode;
if (!n) return;
if (on) n.style.removeProperty('display'); else n.style.display = 'none';
};
/* ---- Panels / Results refresh ---- */
function updatePanels() {
const searched = !!HAS_SEARCHED;
const count = searched ? (Number(window.LAST_TOTAL ?? (lastFetched?.length || 0)) || 0) : 0;
// 想要 0 筆也顯示 Results 標題:head、panel 都跟著 searched 顯示
const showHead = searched; // 0 筆也顯示「Results: 0 cases」
const showPanel = searched; // 0 筆保留右側結果面板的容器(不塞卡片)
const showCards = searched && count > 0; // 只有有結果才顯示卡片
const showBrowse = !showCards; // 只要沒有卡片(含 0 筆)就顯示 Browse
// 更新數字
if (typeof setResultsCount === 'function') {
setResultsCount(count);
} else {
const counterEl = document.getElementById('counter') || document.querySelector('.counter');
if (counterEl) counterEl.textContent = `Results: ${count} ${count === 1 ? 'case' : 'cases'}`;
}
// 顯示/隱藏主要區塊(注意:head/panel 永遠跟 searched,同步;cards 依 count)
window._toggleEl = window._toggleEl || function (selOrNode, on) {
const n = (typeof selOrNode === 'string') ? document.querySelector(selOrNode) : selOrNode;
if (!n) return; if (on) n.style.removeProperty('display'); else n.style.display = 'none';
};
window._toggleEl('.resultsHead', showHead);
window._toggleEl('#resultsPanel', showPanel);
window._toggleEl('#cards', showCards);
window._toggleEl('#recBar', showBrowse);
// Advanced 面板:多筆才顯示
const filt = document.getElementById('filters');
if (filt) {
const showFilter = showCards && count > 1;
filt.classList.toggle('show', showFilter);
window._toggleEl(filt, showFilter);
}
// 單筆視覺
const mainEl = document.querySelector('.main') || document.querySelector('.content') || document.querySelector('.twoCols');
const panelEl = document.getElementById('resultsPanel');
const cardsEl = document.getElementById('cards');
if (showCards && count === 1) {
mainEl?.classList.add('singleResult');
panelEl?.classList.add('spanAll');
cardsEl?.classList.add('centerOne');
} else {
mainEl?.classList.remove('singleResult');
panelEl?.classList.remove('spanAll');
cardsEl?.classList.remove('centerOne');
}
// 排序下拉(單筆時關閉;0 筆也關)
const sortSel = document.getElementById('sortBy');
if (sortSel) {
const wrap = sortSel.closest('.sortWrap') || sortSel;
if (count <= 1) {
wrap.style.display = 'none';
sortSel.disabled = true;
if (state.sort_by !== 'quality') state.sort_by = 'quality';
} else {
wrap.style.display = '';
sortSel.disabled = false;
}
}
}
function wirePopSafe(root) {
if (!root) return;
const btn = root.querySelector('.popBtn');
if (!btn) return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
root.classList.toggle('open');
$$('.pop.open').forEach(p => { if (p !== root) p.classList.remove('open'); });
});
root.querySelector('.popPanel')?.addEventListener('click', (e) => e.stopPropagation());
document.addEventListener('click', (e) => { if (!root.contains(e.target)) root.classList.remove('open'); });
}
wirePopSafe(document.getElementById('popID'));
wirePopSafe(document.getElementById('popSTA'));
async function fetchJSON(u) {
const r = await fetch(u, { cache: 'no-store' });
if (!r.ok) throw new Error(await r.text());
return r.json();
}
/* quality helpers */
function idNum(x) { const m = String(x.case_id || x['PanTS ID'] || x.id).match(/\d+/) || [0]; return Number(m[0]); }
function isComplete(it) {
const sexOK = it.sex === 'M' || it.sex === 'F';
const ageOK = Number.isFinite(it.age) && it.age > 0;
const tumorOK = it.tumor === 0 || it.tumor === 1;
const spOK = Number.isFinite(it.spacing_sum) && it.spacing_sum > 0;
const shOK = Number.isFinite(it.shape_sum) && it.shape_sum > 0;
return sexOK && ageOK && tumorOK && spOK && shOK;
}
/* quality helpers(當前檔案已存在 isComplete, idNum 等) */
function compareQuality(a, b) {
// 1) 完整度優先(age/sex/tumor/spacing/shape 都有值)
const ca = isComplete(a), cb = isComplete(b);
if (ca !== cb) return cb - ca;
// 2) 以視野大小為主(shape_sum 越大越好)
const shA = a.shape_sum ?? -1, shB = b.shape_sum ?? -1;
if (shA !== shB) return shB - shA;
// 3) 解析度:spacing 總和越小越好(xyz pixel spacing 相加)
const spA = a.spacing_sum ?? 1e9, spB = b.spacing_sum ?? 1e9;
if (spA !== spB) return spA - spB;
// 4) 最後用 ID 讓排序穩定
return idNum(a) - idNum(b);
}
/* Browse */
let recTimer = null, recPlaying = true;
async function initBrowse() {
try {
if (recTimer) { clearInterval(recTimer); recTimer = null; }
const bar = $('#recScroll');
if (!bar) return;
bar.innerHTML = '';
let source = [];
if (ALL_ITEMS && ALL_ITEMS.length) {
source = ALL_ITEMS;
} else {
const r = await fetchJSON('/api/random?n=120&k=240&scope=filtered');
source = r.items || [];
}
// ====== 常數 ======
const MIN_SHAPE_SUM = 750;
const MAX_SPACING_SUM = 6.0;
const MIN_Z = 220;
const MAX_Z_SPACING = 1.2;
const MIN_XY_MIN = 360;
const POS_HINTS = ['chest','thorax','abdomen','abdo','pelvis','cap','tap','soft','venous','portal','contrast'];
const NEG_HINTS = ['spine','cervical','thoracic','lumbar','vertebra','scolio','bone','bony','osseous','orthopedic','skeletal','mip','recon','localizer','scout'];
// ⛔ 明確排除名單
const BAD_IDS = ['PanTS_00006171', 'PanTS_00009810', 'PanTS_00009809'];
// ---- helpers ----
const idOf = (it) => String(it?.id || it?.case_id || it?.name || it?.['PanTS ID'] || '');
const toNum = (v) => { const n = Number(v); return Number.isFinite(n) ? n : null; };
const parseTriplet = (raw) => {
if (!raw) return null;
if (Array.isArray(raw) && raw.length >= 3) {
const a = [toNum(raw[0]), toNum(raw[1]), toNum(raw[2])];
return a.every(x=>x!==null) ? a : null;
}
const s = String(raw).trim();
if (!s) return null;
const parts = s.replace(/[\[\]\(\)\{\}]/g,' ')
.replace(/[xX,;|]/g,' ')
.split(/\s+/).filter(Boolean).map(Number);
if (parts.length >= 3 && parts.slice(0,3).every(Number.isFinite)) return parts.slice(0,3);
return null;
};
const shapeXYZ = (it) => parseTriplet(it.shape ?? it.shape_str ?? it.shapeXYZ);
const spacingXYZ = (it) => parseTriplet(it.spacing ?? it.spacing_str ?? it.spacingXYZ);
// 偏好(主過濾)
const prefer = (it) => {
if (!isComplete(it)) return false;
if (BAD_IDS.includes(idOf(it))) return false; // ⛔ 直接擋
const st = String(it.study_type || '').toLowerCase();
if (NEG_HINTS.some(k => st.includes(k))) return false;
const shp = shapeXYZ(it), spc = spacingXYZ(it);
if (!shp || !spc) return false;
const [sx, sy, sz] = shp;
const [spx, spy, spz] = spc;
if (![sx,sy,sz,spx,spy,spz].every(Number.isFinite)) return false;
const xyOK = Math.min(sx, sy) >= MIN_XY_MIN;
const zOK = sz >= MIN_Z;
const zSpOK = spz <= MAX_Z_SPACING;
const shapeOK = (it.shape_sum ?? (sx+sy+sz)) >= MIN_SHAPE_SUM;
const spacingOK = (it.spacing_sum ?? (spx+spy+spz)) <= MAX_SPACING_SUM;
return xyOK && zOK && zSpOK && shapeOK && spacingOK;
};
// 排序
const scorePos = (it) => {
const st = String(it.study_type || '').toLowerCase();
return POS_HINTS.reduce((s, k) => s + (st.includes(k) ? 1 : 0), 0);
};
const byCuratedQuality = (a, b) => {
const [ , , za] = shapeXYZ(a) || [null,null,null];
const [ , , zb] = shapeXYZ(b) || [null,null,null];
const [ , , zsa]= spacingXYZ(a) || [null,null,null];
const [ , , zsb]= spacingXYZ(b) || [null,null,null];
if (zsa !== zsb) return (zsa ?? 1e9) - (zsb ?? 1e9); // spacingZ 小優先
if (za !== zb ) return (zb ?? -1) - (za ?? -1); // shapeZ 大其次
const shA = (a.shape_sum ?? ((shapeXYZ(a)||[0,0,0]).reduce((p,c)=>p+c,0)));
const shB = (b.shape_sum ?? ((shapeXYZ(b)||[0,0,0]).reduce((p,c)=>p+c,0)));
if (shA !== shB) return shB - shA;
const spA = (a.spacing_sum ?? ((spacingXYZ(a)||[1e9,1e9,1e9]).reduce((p,c)=>p+c,0)));
const spB = (b.spacing_sum ?? ((spacingXYZ(b)||[1e9,1e9,1e9]).reduce((p,c)=>p+c,0)));
if (spA !== spB) return spA - spB;
const sA = scorePos(a), sB = scorePos(b);
if (sA !== sB) return sB - sA;
return idNum(a) - idNum(b);
};
// 嚴格過濾;不足則 fallback(仍排除 BAD_IDS)
const filtered = source.filter(prefer);
const fallback = () => {
const loose = source.filter(it => {
if (!isComplete(it)) return false;
if (BAD_IDS.includes(idOf(it))) return false; // ⛔ 仍擋
const st = String(it.study_type || '').toLowerCase();
if (NEG_HINTS.some(k => st.includes(k))) return false;
const shp = shapeXYZ(it), spc = spacingXYZ(it);
if (!shp || !spc) return false;
const [sx, sy, sz] = shp;
const [ , , spz ] = spc;
const xyOK = Math.min(sx, sy) >= (MIN_XY_MIN - 20);
const zOK = sz >= (MIN_Z - 20);
const zSpOK = spz <= (MAX_Z_SPACING + 0.3);
const shapeOK = (it.shape_sum ?? (sx+sy+sz)) >= (MIN_SHAPE_SUM - 80);
const spacingOK = (it.spacing_sum ?? (spc[0]+spc[1]+spc[2])) <= (MAX_SPACING_SUM + 1.0);
return xyOK && zOK && zSpOK && shapeOK && spacingOK;
});
return loose.length ? loose : source.filter(it => isComplete(it) && !BAD_IDS.includes(idOf(it)));
};
const ranked = (filtered.length ? filtered : fallback()).sort(byCuratedQuality);
// 最後再保險一次:把 BAD_IDS 剔除
const best = ranked.filter(it => !BAD_IDS.includes(idOf(it))).slice(0, 20);
if (!best.length) {
const empty = document.createElement('article');
empty.className = 'card inRec';
empty.innerHTML = `
<div class="body" style="padding:12px 14px">
<div class="titleRow"><span class="recId" style="font-weight:900">No items</span></div>
<div class="keyRow" style="margin-top:6px;color:#93a4b8">Try adjusting filters, then Search</div>
</div>`;
bar.appendChild(empty);
$('#recPrev')?.setAttribute('disabled', 'disabled');
$('#recNext')?.setAttribute('disabled', 'disabled');
$('#recPlay')?.setAttribute('disabled', 'disabled');
return;
} else {
$('#recPrev')?.removeAttribute('disabled');
$('#recNext')?.removeAttribute('disabled');
$('#recPlay')?.removeAttribute('disabled');
}
const frag = document.createDocumentFragment();
best.forEach(it => { const card = makeCard(it); card.classList.add('inRec'); frag.appendChild(card); });
bar.appendChild(frag);
const vp = document.querySelector('.recViewport');
const firstCard = bar.querySelector('.card') || bar.querySelector('.recCard');
if (vp && firstCard) {
const h = Math.ceil(firstCard.getBoundingClientRect().height);
vp.style.minHeight = h + 'px';
}
const scroller = bar;
const gap = 12;
let step = firstCard ? (firstCard.getBoundingClientRect().width + gap) : 340;
const atEnd = () => (scroller.scrollLeft + scroller.clientWidth) >= (scroller.scrollWidth - 2);
const tick = () => { if (atEnd()) scroller.scrollTo({ left: 0, behavior: 'smooth' }); else scroller.scrollBy({ left: step, behavior: 'smooth' }); };
$('#recPrev').onclick = (e) => { e?.preventDefault?.(); e?.stopPropagation?.(); scroller.scrollBy({ left: -step, behavior: 'smooth' }); };
$('#recNext').onclick = (e) => { e?.preventDefault?.(); e?.stopPropagation?.(); scroller.scrollBy({ left: +step, behavior: 'smooth' }); };
$('#recPlay').onclick = (e) => {
e?.preventDefault?.(); e?.stopPropagation?.();
recPlaying = !recPlaying;
$('#recPlay').textContent = recPlaying ? '⏸' : '▶';
if (recPlaying) startAuto(); else stopAuto();
};
function startAuto() { stopAuto(); recTimer = setInterval(tick, 3500); }
function stopAuto() { if (recTimer) { clearInterval(recTimer); recTimer = null; } }
$('#recPlay').textContent = recPlaying ? '⏸' : '▶';
if (recPlaying) startAuto();
if (window._recResizeHandler) window.removeEventListener('resize', window._recResizeHandler);
window._recResizeHandler = () => {
const first = bar.querySelector('.card') || bar.querySelector('.recCard');
step = first ? (first.getBoundingClientRect().width + gap) : 340;
};
window.addEventListener('resize', window._recResizeHandler, { passive: true });
['recPrev','recNext','recPlay'].forEach((id) => {
const btn = document.getElementById(id);
if (!btn) return;
btn.tabIndex = 0;
if (!btn.getAttribute('aria-label')) {
btn.setAttribute('aria-label', id === 'recPrev' ? 'Previous' : id === 'recNext' ? 'Next' : 'Pause/Play');
}
btn.addEventListener('mousedown', (e) => e.preventDefault());
btn.addEventListener('keydown', (e) => {
const k = e.key;
const shouldClick =
k === 'Enter' || k === ' ' ||
(id === 'recPrev' && k === 'ArrowLeft') ||
(id === 'recNext' && k === 'ArrowRight') ||
(id === 'recPlay' && (k === 'k' || k === 'K'));
if (shouldClick) { e.preventDefault(); e.stopPropagation(); btn.click(); }
});
});
} catch (e) {
console.warn('initBrowse failed:', e);
}
}
const FACET_PAIRS = [
['ct_phase_opts', 'ct_phase'],
['manufacturer_opts', 'manufacturer'],
['model_opts', 'model'],
['type_opts', 'study_type'],
['nat_opts', 'site_nat'],
['year_opts', 'year'],
];
/* Lists / facets */
const SPLIT_RE = /[;,|/、,·・]+/;
const NAT_MAP = { 'USA': 'US', 'U.S.': 'US', 'U S A': 'US', 'GB': 'UK', 'U.K.': 'UK', 'N/A': 'NA', 'NULL': 'NA' };
const normToken = s => (s ?? '').toString().trim().toUpperCase();
const mapNat = code => NAT_MAP[normToken(code)] ?? normToken(code);
const splitTokens = (raw, mapper = x => x) => String(raw ?? '').split(SPLIT_RE).map(t => mapper(normToken(t))).filter(Boolean);
const pickField = (obj, cands) => { for (const k of cands) { if (Object.prototype.hasOwnProperty.call(obj, k)) { const v = obj[k]; if (v == null) continue; const s = String(v).trim(); if (s !== '' && s.toLowerCase() !== 'unknown') return v; } } return ''; };
/* --- 建立清單(初次) --- */
function buildFacetList(container, key, rows, hasLabel = false){
const box = document.getElementById(container);
if (!box) return;
box.innerHTML = '';
const validRows = (rows || [])
.filter(r => (r.count ?? 0) > 0)
.sort((a,b) => (b.count??0)-(a.count??0));
validRows.forEach(r => {
const text = hasLabel ? (r.label || r.value) : r.value;
const d = document.createElement('div');
d.className = 'optRow';
d.dataset.k = key;
d.dataset.v = String(r.value);
d.innerHTML = `<label><input type="checkbox" data-k="${key}" value="${r.value}"> <span class="lbl">${text}</span></label> <span class="count">${r.count ?? 0}</span>`;
box.appendChild(d);
});
const fset = box.closest('.fset');
if (fset) fset.style.display = box.children.length ? '' : 'none';
}
/* --- Show more / less --- */
function wireShowMore(){
document.querySelectorAll('.showMore').forEach(btn=>{
const targetId = btn.dataset.target;
const limit = parseInt(btn.dataset.limit || '12', 10);
const box = document.getElementById(targetId);
if (!box) return;
const apply = () => {
const rows = Array.from(box.querySelectorAll('.optRow'))
.filter(r => r.dataset.hiddenByCount !== '1'); // 被 count=0 隱藏的不列入分頁
const expanded = box.classList.contains('expanded');
rows.forEach((row, idx) => row.style.display = (expanded || idx < limit) ? 'flex' : 'none');
btn.style.display = (rows.length > limit) ? '' : 'none';
btn.textContent = expanded ? 'Show less' : 'Show more';
};
btn.addEventListener('click', () => { box.classList.toggle('expanded'); apply(); });
box._applyPager = apply; // 提供外部(更新數字後)重算
apply();
});
}
function updateFacetCounts(countPayload = {}) {
FACET_PAIRS.forEach(([containerId, key]) => {
const box = document.getElementById(containerId);
if (!box) return;
const rows = Array.from(box.querySelectorAll('.optRow'));
const mp = Object.create(null);
(countPayload[key] || []).forEach(r => { mp[String(r.value)] = r.count || 0; });
rows.forEach(row => {
const input = row.querySelector('input[type=checkbox]');
const val = row.dataset.v || (input ? input.value : '');
const cnt = (val in mp) ? mp[val] : 0;
// 更新數字
const badge = row.querySelector('.count');
if (badge) badge.textContent = cnt;
// 規則:count=0 時若尚未勾選 → disabled;已勾選則保持可用
const checked = !!(input && input.checked);
if (input) {
input.disabled = (!checked && cnt === 0);
row.classList.toggle('disabled', input.disabled);
}
// 不隱藏,全部保留顯示(若你想隱藏 0,可以把下一行改成 row.style.display = (checked || cnt>0) ? 'flex' : 'none';
row.style.display = (checked || cnt > 0) ? 'flex' : 'none';
row.dataset.hiddenByCount = (checked || cnt > 0) ? '0' : '1';
});
// 小計:整組是否全為 0 且無勾選?若是,也保留顯示(不折疊),便於使用者理解為「目前條件下無資料」
const fset = box.closest('.fset');
if (fset) fset.style.display = rows.length ? '' : 'none';
// 讓「Show more/less」在更新後重算一次可見列數
box._applyPager?.();
});
}
/* --- 收集 Advanced 勾選 → state(Any 與其他互斥) --- */
function collectAdvanced() {
const pickVals = k =>
Array.from(document.querySelectorAll(`#filters input[type=checkbox][data-k="${k}"]:checked`))
.map(i => String(i.value)).filter(v => v !== '');
// 同一組內處理「Any」互斥
const enforceAny = (k) => {
const list = Array.from(document.querySelectorAll(`#filters input[type=checkbox][data-k="${k}"]`));
const any = list.find(x => x.value === '');
const restChecked = list.some(x => x.value !== '' && x.checked);
if (any) any.checked = !restChecked;
};
['ct_phase','manufacturer','model','study_type','site_nat','year'].forEach(enforceAny);
state.ct_phase = pickVals('ct_phase');
state.manufacturer = pickVals('manufacturer');
state.model = pickVals('model');
state.study_type = pickVals('study_type');
state.site_nat = pickVals('site_nat');
state.year = pickVals('year');
updateSTASummary?.();
// 勾選變動後:即時計算 facet(依當前全部條件),並在已搜尋下觸發結果刷新
refreshFacetsDebounced(0);
window.HAS_SEARCHED = true; // 第一次按 advanced 也當作已搜尋
run();
}
/* --- 把 state 轉成查詢字串(facets / search 共用) --- */
function qsFromState() {
const p = new URLSearchParams();
if (state.q) p.set('caseid', state.q);
if (Array.isArray(state.sex) && state.sex.length) state.sex.forEach(v => p.append('sex[]', v));
if (state.tumor !== '') p.set('tumor', state.tumor);
if (Array.isArray(state.age_bin) && state.age_bin.length) {
state.age_bin.forEach(v => p.append('age_bin[]', v));
} else {
if (state.age_from) p.set('age_from', state.age_from);
if (state.age_to) p.set('age_to', state.age_to);
}
['ct_phase', 'manufacturer', 'model', 'study_type', 'site_nat', 'year']
.forEach(k => (state[k] || []).forEach(v => p.append(k + '[]', v)));
if (state.sort_by) p.set('sort_by', state.sort_by);
if (state.page) p.set('page', state.page);
if (state.per_page) p.set('per_page', state.per_page);
return p.toString();
}
/* === 一次性初始化:建立 Advanced 清單、綁定事件 === */
(async function primeUI() {
// 1) 先拉全集 facets(guarantee=1)來「建清單」
let f0 = { facets: {} };
try {
f0 = await fetchJSON('/api/facets?fields=ct_phase,manufacturer,model,study_type,site_nat,year&top_k=999&guarantee=1');
} catch (e) {
console.warn('facets fetch failed:', e);
}
const fx = f0?.facets || {};
// 2) 依 fx 建出各組清單(只做一次)
try {
if (document.getElementById('ct_phase_opts')) buildFacetList('ct_phase_opts', 'ct_phase', fx.ct_phase || []);
if (document.getElementById('manufacturer_opts')) buildFacetList('manufacturer_opts', 'manufacturer', fx.manufacturer || []);
if (document.getElementById('model_opts')) buildFacetList('model_opts', 'model',
(fx.model || []).map(r => ({ value: r.value, label: r.label ?? r.value, count: r.count })), true);
if (document.getElementById('type_opts')) buildFacetList('type_opts', 'study_type', fx.study_type || []);
if (document.getElementById('nat_opts')) buildFacetList('nat_opts', 'site_nat',
(fx.site_nat || []).map(r => ({ value: r.value, label: r.label ?? r.value, count: r.count })), true);
if (document.getElementById('year_opts')) buildFacetList('year_opts', 'year',
(fx.year || []).map(r => ({ value: String(r.value), count: r.count })));
} catch (e) {
console.warn('build facet list failed:', e);
}
// 3) Show more/less 分頁器掛上
wireShowMore?.();
// 4) 先把「全集」的數字畫上(頁面初始狀態)
updateFacetCounts(fx);
// 5) 綁 Advanced 勾選事件(「Any」互斥 + live counts + 若已搜尋則刷新結果)
const filtersEl = document.getElementById('filters');
if (filtersEl) {
filtersEl.addEventListener('change', e => {
const cb = e.target?.closest?.('input[type=checkbox][data-k]');
if (!cb) return;
const key = cb.dataset.k;
const isAny = (cb.value === '');
// 「Any」與其他互斥
if (isAny) {
document.querySelectorAll(`#filters input[type=checkbox][data-k="${key}"]`)
.forEach(x => x.checked = (x === cb));
} else {
const any = document.querySelector(`#filters input[type=checkbox][data-k="${key}"][value=""]`);
if (any) any.checked = false;
}
collectAdvanced(); // 內部會 debounced 觸發 refreshFacets(),並在 HAS_SEARCHED 時 run()
});
}
})();
/* Recent helpers(唯一版本) */
function saveRecent(id) {
try {
const key = 'recentIds';
const cur = JSON.parse(localStorage.getItem(key) || '[]');
const idStr = String(id || '').trim();
if (!idStr) return;
const next = [idStr, ...cur.filter(x => x !== idStr)].slice(0, 12);
localStorage.setItem(key, JSON.stringify(next));
} catch (e) {
console.warn('saveRecent failed', e);
}
}
/* Results render */
const cardsEl = $('#cards');
let rendered = 0;
const BATCH = 60;
let current = [];
function sorter(items) {
const s = state.sort_by || 'quality';
if (s === 'spacing_asc') return items.slice().sort((a, b) => (a.spacing_sum ?? 1e9) - (b.spacing_sum ?? 1e9));
if (s === 'shape_desc') return items.slice().sort((a, b) => (b.shape_sum ?? -1) - (a.shape_sum ?? -1));
if (s === 'age_asc') return items.slice().sort((a, b) => (a.age ?? 1e9) - (b.age ?? 1e9));
if (s === 'age_desc') return items.slice().sort((a, b) => (b.age ?? -1) - (a.age ?? -1));
if (s === 'id_asc') return items.slice().sort((a, b) => idNum(a) - idNum(b));
if (s === 'id_desc') return items.slice().sort((a, b) => idNum(b) - idNum(a));
return items.slice().sort(compareQuality);
}
/* 控制排序下拉的顯示/隱藏(只有 1 筆或 0 筆就藏起來) */
function updateSortVisibility() {
const sel = document.getElementById('sortBy');
if (!sel) return;
const wrap = sel.closest('.sortWrap') || sel;
const count = Array.isArray(lastFetched) ? lastFetched.length : 0;
const enable = count > 1;
if (!enable) {
wrap.style.display = 'none';
sel.disabled = true;
// 回到預設排序,避免顯示誤導
if (state.sort_by !== 'quality') state.sort_by = 'quality';
if (sel.value !== 'quality') sel.value = 'quality';
} else {
wrap.style.display = '';
sel.disabled = false;
}
}
function makeCard(it) {
const id = String(it.case_id || it['PanTS ID'] || it.id || '');
const sex = it.sex || '—';
const age = Number.isFinite(it.age) ? `${it.age}y` : '—';
const tumor = (it.tumor === 1 ? 'Tumor' : (it.tumor === 0 ? 'No tumor' : '—'));
const thumbURL = (typeof profileURL === 'function' && typeof idNum === 'function')
? profileURL(idNum(it))
: null;
const wrap = document.createElement('article');
wrap.className = 'card';
wrap.innerHTML = `
<img class="thumb" src="${thumbURL || svg('2D Image')}"
onerror="this.src='${svg('2D Image')}'" alt="">
<div class="body">
<div class="titleRow">
<a href="javascript:void(0)" class="caseLink" data-id="${id}">
${id.replace(/^Case\\s*/, '')}
</a>
</div>
<div class="keyRow">
<span class="kv"><span class="k">Sex</span><span class="v">${sex}</span></span>
<span class="kv"><span class="k">Age</span><span class="v">${age}</span></span>
<span class="kv"><span class="tag ${tumor === 'No tumor' ? 'ok' : 'bad'}">${tumor}</span></span>
</div>
</div>`;
const open = () => { saveRecent(id); openViewer(id); };
wrap.querySelector('.caseLink')?.addEventListener('click', open);
wrap.querySelector('.thumb')?.addEventListener('click', open);
return wrap;
}
function renderMore() {
if (rendered >= current.length) return;
const fr = document.createDocumentFragment();
const end = Math.min(rendered + BATCH, current.length);
for (let i = rendered; i < end; i++) fr.appendChild(makeCard(current[i]));
cardsEl.appendChild(fr);
rendered = end;
}
window.addEventListener('scroll', () => {
const near = (window.innerHeight + window.scrollY) > (document.body.offsetHeight - 800);
if (near) renderMore();
});
// === 全域搜尋狀態(若已存在就沿用)===
window.state = window.state || {
q: '',
sex: [], // ['M','F'] 之一或多選
tumor: '', // '' | '1' | '0'
age_bin: [], // 例如 ['20-29','30-39'] 或 ['UNKNOWN']
age_from: '', // 自由輸入數字字串
age_to: '',
ct_phase: [],
manufacturer: [],
model: [],
study_type: [],
site_nat: [],
year: [],
sort_by: '', // 'quality' 等
page: 1,
per_page: 10000
};
// === 把 state 轉成查詢字串,供 facets / search 用 ===
function qsFromState() {
const p = new URLSearchParams();
if (state.q) p.set('caseid', state.q);
// sex 多選
if (Array.isArray(state.sex) && state.sex.length) {
state.sex.forEach(v => p.append('sex[]', v));
}
// tumor
if (state.tumor !== '') p.set('tumor', state.tumor);
// age:優先 bins,否則 from/to
if (Array.isArray(state.age_bin) && state.age_bin.length) {
state.age_bin.forEach(v => p.append('age_bin[]', v));
} else {
if (state.age_from) p.set('age_from', state.age_from);
if (state.age_to) p.set('age_to', state.age_to);
}
// 進階 facets 多選
['ct_phase','manufacturer','model','study_type','site_nat','year']
.forEach(k => (state[k] || []).forEach(v => p.append(k + '[]', v)));
if (state.sort_by) p.set('sort_by', state.sort_by);
if (state.page) p.set('page', String(state.page));
if (state.per_page) p.set('per_page', String(state.per_page));
return p.toString();
}
/* === Facet counts:依目前條件即時重算(Booking 風格)=== */
let _facetTimer = null;
function refreshFacetsDebounced(delay = 120){
clearTimeout(_facetTimer);
_facetTimer = setTimeout(() => refreshFacets().catch(console.warn), delay);
}
async function refreshFacets() {
const qs = qsFromState(); // 帶上目前所有條件
const url = '/api/facets?fields=ct_phase,manufacturer,model,study_type,site_nat,year&top_k=999&guarantee=0&' + qs;
const f = await fetchJSON(url);
updateFacetCounts(f?.facets || {});
}
/* ---- 搜尋執行(統一入口) ---- */
function triggerSearch() {
const qBox = document.getElementById('q');
state.q = (qBox?.value || '').trim();
HAS_SEARCHED = true;
// 關掉彈窗
document.getElementById('popID')?.classList.remove('open');
document.getElementById('popSTA')?.classList.remove('open');
run();
}
// ===== Recommended IDs =====
let ID_RECO = []; // 快取推薦
let ID_RECO_AT = 0; // 最近抓取時間戳(毫秒)
const RECO_TTL = 60*1000; // 1 分鐘內不重抓
function dedup(arr) {
const seen = new Set(); const out = [];
for (const x of arr) { const k = String(x); if (!seen.has(k)) { seen.add(k); out.push(k); } }
return out;
}
// 取出最近瀏覽
function getRecentIds() {
try { return JSON.parse(localStorage.getItem('recentIds')||'[]'); } catch { return []; }
}
// 生成 chip 按鈕
function makeIdChip(id) {
const b = document.createElement('button');
b.className = 'chip';
b.textContent = id;
b.addEventListener('click', () => {
const q = document.getElementById('q');
if (q) q.value = id;
state.q = id;
HAS_SEARCHED = true;
run();
document.getElementById('popID')?.classList.remove('open');
});
return b;
}
// 繪製「最近瀏覽」
function renderRecent() {
const box = document.getElementById('idRecent');
if (!box) return;
const r = getRecentIds();
box.innerHTML = '';
if (!r.length) { box.innerHTML = '<span class="recMeta">No recent</span>'; return; }
dedup(r).slice(0, 12).forEach(id => box.appendChild(makeIdChip(id)));
}
// 抓取推薦(有快取)
async function fetchRecommended() {
const now = Date.now();
if (ID_RECO.length && (now - ID_RECO_AT) < RECO_TTL) return ID_RECO;
try {
// 以「品質」做推薦,最多 12 筆;你也可以換成其他排序
const url = '/api/search?per_page=10000&sort_by=quality';
const data = await fetchJSON(url);
const items = Array.isArray(data?.items) ? data.items : [];
ID_RECO = items
.map(it => String(it.case_id || it['PanTS ID'] || it.id || ''))
.filter(Boolean);
ID_RECO_AT = now;
} catch (e) {
console.warn('fetchRecommended failed', e);
ID_RECO = [];
}
return ID_RECO;
}
// 繪製「Recommended IDs」
async function renderIdRecommendations() {
const box = document.getElementById('idReco');
if (!box) return;
box.innerHTML = '<span class="recMeta">Loading…</span>';
// 先抓推薦;抓不到就用最近瀏覽頂上
let list = await fetchRecommended();
if (!list || !list.length) list = getRecentIds();
box.innerHTML = '';
if (!list.length) {
box.innerHTML = '<span class="recMeta">No suggestions</span>';
return;
}
dedup(list).slice(0, 12).forEach(id => box.appendChild(makeIdChip(id)));
}
// ===== 事件:開啟彈窗時重新渲染 =====
(function bindIdPop() {
const wrap = document.getElementById('popID');
const input = document.getElementById('q');
if (!wrap || !input) return;
// 打開彈窗
const open = async () => {
wrap.classList.add('open');
await renderIdRecommendations();
renderRecent();
};
// 關閉彈窗
const close = () => wrap.classList.remove('open');
// 聚焦/點擊打開,按 Esc 關閉
input.addEventListener('focus', open);
input.addEventListener('click', open);
input.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
// 點擊外面區域就關閉
document.addEventListener('click', (e) => {
if (!wrap.contains(e.target)) close();
});
})();
/* ---- Search 按鈕 ---- */
document.getElementById('searchBtn')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// 統一走主流程,避免重複請求 & 數字閃爍
triggerSearch();
});
/* ---- 在輸入框按 Enter 搜尋、按 Esc 清空並搜尋全部 ---- */
const qInput = document.getElementById('q');
qInput?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
triggerSearch();
} else if (e.key === 'Escape') {
qInput.value = '';
triggerSearch(); // 空字串 → 搜尋全部
}
});
/* ---- Search run ---- */
async function run() {
// 1) 同步查詢字串
const qBox = document.getElementById('q');
const query = (qBox?.value || '').trim();
state.q = query;
// 2) 先刷新 facets(照你原流程)
await refreshFacets();
// 3) 打第一次 /api/search
const p = new URLSearchParams(qsFromState());
p.set('per_page', '10000');
let data = await fetchJSON('/api/search?' + p.toString());
lastFetched = (data.items || []).filter(Boolean);
// 4) 僅在「沒有任何條件且 0 筆」時才 fallback 抓全量
const hasAnyFilter = (() => {
const arrLen = k => Array.isArray(state[k]) ? state[k].length : 0;
return !!(state.q ||
arrLen('sex') || state.tumor !== '' ||
state.age_from || state.age_to || arrLen('age_bin') ||
arrLen('ct_phase') || arrLen('manufacturer') || arrLen('model') ||
arrLen('study_type') || arrLen('site_nat') || arrLen('year'));
})();
if (!hasAnyFilter && query === '' && lastFetched.length === 0) {
data = await fetchJSON('/api/search?per_page=10000&sort_by=id');
lastFetched = (data.items || []).filter(Boolean);
}
// 5) 前端自訂過濾(保留你的原本邏輯)
const norm = s => String(s ?? '').trim().toLowerCase();
lastFetched = lastFetched.filter(it => {
const hasAll = (selected, value) => selected.length === 0 || selected.some(v => norm(v) === norm(value));
const overlap = (selected, tokens) => {
if (selected.length === 0) return true;
const sel = selected.map(norm);
return tokens.some(t => sel.includes(norm(t)));
};
const model = pickField(it, ['manufacturer model', 'Manufacturer model', 'model', 'Model']);
if (!hasAll(state.model, model)) return false;
const types = splitTokens(pickField(it, ['study type', 'Study type', 'study_type', 'type', 'Type']));
const nats = splitTokens(pickField(it, ['site nationality', 'Site nationality', 'site_nat', 'nationality', 'country', 'Country']), mapNat);
const year = pickField(it, ['study year', 'Study year', 'year', 'study_year', 'Year']);
return overlap(state.study_type, types) &&
overlap(state.site_nat, nats) &&
hasAll(state.year, year);
});
// 6) 以「前端可見數」為準覆寫 LAST_TOTAL,避免 9901
window.LAST_TOTAL = lastFetched.length;
// 7) 更新 UI + 渲染
HAS_SEARCHED = true; // 確保顯示結果區
updatePanels();
renderAfterFetch(lastFetched);
}
/* =========================
STA / Recent + Age 多選
========================= */
// --- Age:多選 + Unknown(外觀改成與 Tumor 一致的平面 pill) ---
function renderAgeChips(list) {
const w = $('#ageChips');
if (!w) return;
// 確保外觀 class 存在(平面、不像按鈕)
w.classList.add('chipArea', 'flat');
w.innerHTML = '';
const bins = Array.isArray(list) && list.length
? list.slice()
: ['0-9','10-19','20-29','30-39','40-49','50-59','60-69','70-79','80-89','90-99'];
// Any
const anyBtn = document.createElement('button');
anyBtn.className = 'chip';
anyBtn.id = 'ageAny';
anyBtn.textContent = 'Any'; // ← 原本是 "Any age"
anyBtn.addEventListener('click', () => {
state.age_bin = [];
state.age_from = '';
state.age_to = '';
$$('#ageChips .chip').forEach(x => x.classList.remove('active'));
anyBtn.classList.add('active');
updateSTASummary();
// 不 auto-search;按下 Search 才查
});
w.appendChild(anyBtn);
// bins(多選)
bins.forEach(label => {
const btn = document.createElement('button');
btn.className = 'chip';
btn.textContent = label;
btn.dataset.bin = label;
btn.addEventListener('click', () => {
const bin = btn.dataset.bin;
const i = state.age_bin.indexOf(bin);
if (i === -1) state.age_bin.push(bin); else state.age_bin.splice(i, 1);
btn.classList.toggle('active');
const any = $('#ageAny');
if (any) any.classList.toggle('active', state.age_bin.length === 0);
// 選了 bins 就清 from/to
state.age_from = '';
state.age_to = '';
updateSTASummary();
// 不 auto-search;按下 Search 才查
});
w.appendChild(btn);
});
// Unknown
const unk = document.createElement('button');
unk.className = 'chip';
unk.textContent = 'Unknown';
unk.dataset.bin = 'UNKNOWN';
unk.addEventListener('click', () => {
const bin = 'UNKNOWN';
const i = state.age_bin.indexOf(bin);
if (i === -1) state.age_bin.push(bin); else state.age_bin.splice(i, 1);
unk.classList.toggle('active');
const any = $('#ageAny');
if (any) any.classList.toggle('active', state.age_bin.length === 0);
state.age_from = '';
state.age_to = '';
updateSTASummary();
// 不 auto-search;按下 Search 才查
});
w.appendChild(unk);
// 初始 active
if (Array.isArray(state.age_bin) && state.age_bin.length) {
state.age_bin.forEach(b => {
const btn = $(`#ageChips .chip[data-bin="${CSS.escape(b)}"]`);
if (btn) btn.classList.add('active');
});
} else {
anyBtn.classList.add('active');
}
}
/* ===== Viewer ===== */
function showPreparing(on) {
$('#prepPage').classList.toggle('show', !!on);
$('#prepPage').setAttribute('aria-hidden', on ? 'false' : 'true');
}
function openViewer(id) {
showPreparing(true);
// 標記 viewer 模式(會觸發 body.viewer-open .hero 隱藏)
document.body.style.overflow = 'hidden';
document.body.classList.add('viewer-open');
const v = $('#viewer');
v.classList.add('compact');
v.setAttribute('aria-hidden', 'false');
// 初始文字/占位
$('#vCard').style.display = 'none';
$('#vSidebar').style.display = 'none';
$('#caseTitle').textContent = 'Case ID: ' + id;
$('#axial').src = vsvg('2D Axial');
$('#sagittal').src = vsvg('2D Sagittal');
$('#coronal').src = vsvg('2D Coronal');
// 讓 viewer 一定在最上面
v.style.position = 'fixed';
v.style.inset = '0';
v.style.zIndex = '10000';
setTimeout(() => {
showPreparing(false);
v.classList.add('show');
$('#prep')?.classList.add('hidden');
// 更新網址
const u = new URL(location.href);
u.searchParams.set('case', id);
history.replaceState({}, '', u);
}, 700);
}
document.activeElement?.blur(); // 移除當前焦點,避免 aria-hidden 警告
document.getElementById('viewer').setAttribute('aria-hidden', 'true');
function closeViewer() {
const v = $('#viewer');
v.classList.remove('show');
v.setAttribute('aria-hidden', 'true');
// 還原捲動與 viewer 標記 → 搜尋列會自動回來
document.body.style.overflow = '';
document.body.classList.remove('viewer-open');
// 清掉 URL 參數
const u = new URL(location.href);
u.searchParams.delete('case');
history.replaceState({}, '', u);
}
$('#btnBack').addEventListener('click', closeViewer);
const CLASS_MAP = [
{ name: 'Vascular System', key: 'vascular', items: [['aorta'], ['celiac_artery'], ['superior_mesenteric_artery'], ['postcava'], ['veins']] },
{ name: 'Digestive System', key: 'digestive', items: [['Pancreas'], ['colon'], ['duodenum'], ['stomach'], ['liver'], ['common_bile_duct'], ['gall_bladder']] },
{ name: 'Endocrine System', key: 'endocrine', items: [['adrenal_gland_left'], ['adrenal_gland_right']] },
{ name: 'Urinary System', key: 'urinary', items: [['Kidneys'], ['bladder']] },
{ name: 'Skeletal System', key: 'skeletal', items: [['femur_left'], ['femur_right']] },
{ name: 'Lymphatic System', key: 'lymphatic', items: [['spleen']] },
{ name: 'Reproductive System', key: 'reproductive', items: [['prostate']] },
{ name: 'Respiratory System', key: 'respiratory', items: [['lung_left'], ['lung_right']] }
];
function renderClassMap() {
const root = document.getElementById('cmRoot'); if (!root) return; root.innerHTML = '';
CLASS_MAP.forEach(grp => {
const box = document.createElement('section'); box.className = 'cm-group';
const row = document.createElement('div'); row.className = 'cm-row';
row.innerHTML = `<span class="chev" aria-hidden="true">▾</span><span class="title">${grp.name}</span><input type="checkbox" class="grpCheck" checked aria-label="Toggle ${grp.name}">`;
const items = document.createElement('div'); items.className = 'cm-items';
grp.items.forEach(([label]) => {
const id = `cm_${grp.key}_${label}`.replace(/\W+/g, '_').toLowerCase();
const r = document.createElement('label'); r.className = 'cm-item';
r.innerHTML = `<input type="checkbox" class="itemCheck" id="${id}" data-group="${grp.key}" data-name="${label}" checked><span class="name">${label.replace(/_/g, ' ')}</span>`;
items.appendChild(r);
});
let open = true; const chev = row.querySelector('.chev');
const setOpen = (on) => { open = on; box.classList.toggle('closed', !on); chev.textContent = on ? '▾' : '▸'; };
row.addEventListener('click', (e) => { const t = e.target; if (t && t.classList && t.classList.contains('grpCheck')) return; setOpen(!open); });
setOpen(true);
const grpCheck = row.querySelector('input.grpCheck');
if (grpCheck) { grpCheck.addEventListener('change', () => { const checked = grpCheck.checked; items.querySelectorAll('input.itemCheck').forEach(c => { c.checked = checked; }); }); }
box.appendChild(row); box.appendChild(items); root.appendChild(box);
});
const btn = document.getElementById('toggleAll');
if (btn) {
btn.onclick = () => {
const boxes = Array.from(document.querySelectorAll('.cm-group .grpCheck'));
const turnOff = boxes.length && boxes.every(b => b.checked);
boxes.forEach(b => b.checked = !turnOff);
document.querySelectorAll('.cm-group .itemCheck').forEach(c => c.checked = !turnOff);
};
}
}
// settings / classmap toggle
document.getElementById('btnGear')?.addEventListener('click', () => {
const card = $('#vCard'), side = $('#vSidebar'); if (!card || !side) return;
side.style.display = 'none';
card.style.display = (getComputedStyle(card).display === 'none') ? 'block' : 'none';
});
document.getElementById('openClassMap')?.addEventListener('click', () => {
const side = $('#vSidebar'), card = $('#vCard'); if (!side || !card) return;
card.style.display = 'none';
if (getComputedStyle(side).display === 'none') { renderClassMap(); side.style.display = 'block'; }
else { side.style.display = 'none'; }
});
['op', 'lvl', 'win'].forEach(id => { const r = $('#' + id), lab = $('#' + id + 'v'); r?.addEventListener('input', () => { if (lab) lab.textContent = r.value; }); });
$('#zoomIn').innerHTML = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M11 4v7H4v2h7v7h2v-7h7v-2h-7V4z"/></svg>';
$('#zoomOut').innerHTML = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 11v2h16v-2z"/></svg>';
$('#download').innerHTML = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 3v10l3.5-3.5 1.4 1.4L12 16.8 7.1 10.9l1.4-1.4L11 13V3zM4 19v2h16v-2H4z"/></svg>';
$('#report').innerHTML = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M7 3h8l4 4v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zm7 1.5V8h3.5L14 4.5zM8 11h8v2H8v-2zm0 4h8v2H8v-2z"/></svg>';
let Z = 1; function applyZoom() { $$('.v-view img').forEach(img => { img.style.transformOrigin = 'center center'; img.style.transform = `scale(${Z})`; }); }
$('#zoomIn')?.addEventListener('click', () => { Z = Math.min(3, Z + 0.2); applyZoom(); });
$('#zoomOut')?.addEventListener('click', () => { Z = Math.max(1, Z - 0.2); applyZoom(); });
const rpModal = $('#reportModal');
$('#download')?.addEventListener('click', () => {
['axial', 'sagittal', 'coronal'].forEach(id => {
const img = document.getElementById(id);
if (!img || !img.src) return;
const a = document.createElement('a');
a.href = img.src;
a.download = `case-${(document.getElementById('caseTitle')?.textContent || 'id').replace(/\D+/g, '')}-${id}.png`;
document.body.appendChild(a); a.click(); a.remove();
});
});
$('#report')?.addEventListener('click', () => rpModal?.classList.add('show'));
$('#rpClose')?.addEventListener('click', () => rpModal?.classList.remove('show'));
rpModal?.addEventListener('click', e => { if (e.target === rpModal) rpModal.classList.remove('show'); });
// deeplink
(() => { const u = new URL(location.href); const id = u.searchParams.get('case'); if (id) openViewer(id); })();
/* ---- Recent render ---- */
function renderRecent() {
const box = $('#idRecent');
if (!box) return;
const r = JSON.parse(localStorage.getItem('recentIds') || '[]');
box.innerHTML = '';
if (!r.length) {
box.innerHTML = '<span class="recMeta">No recent</span>';
return;
}
r.forEach(id => {
const b = document.createElement('button');
b.className = 'chip';
b.textContent = id;
b.addEventListener('click', () => {
$('#q').value = id;
state.q = id;
HAS_SEARCHED = true;
run();
$('#popID')?.classList.remove('open');
});
box.appendChild(b);
});
}
/* ---- STA Summary ---- */
function updateSTASummary() {
const tumor =
state.tumor === '' ? 'Any tumor' :
state.tumor === '1' ? 'Tumor' : 'No tumor';
const toArray = v => Array.isArray(v) ? v : (v ? [String(v)] : []);
const selSex = toArray(state.sex);
const mapSex = s => (s === 'M' ? 'Male' : s === 'F' ? 'Female' : 'Unknown');
const sexLabel = selSex.length ? selSex.map(mapSex).join('/') : 'Any sex';
let ageLabel = 'Any age';
if (Array.isArray(state.age_bin) && state.age_bin.length) {
const bins = state.age_bin.slice();
const onlyUnknown = bins.length === 1 && bins[0] === 'UNKNOWN';
if (onlyUnknown) {
ageLabel = 'UNKNOWN';
} else {
const shown = bins.filter(b => b !== 'UNKNOWN').slice(0, 3).join(', ');
const more = bins.filter(b => b !== 'UNKNOWN').length > 3
? ` +${bins.filter(b => b !== 'UNKNOWN').length - 3}` : '';
ageLabel = shown ? (shown + more) : 'UNKNOWN';
}
}
const sta = document.getElementById('staSummary');
if (sta) sta.textContent = `${tumor} · ${sexLabel} · ${ageLabel}`;
}
/* ---- Search / Sex / Tumor / Sort ---- */
// Search button
(() => {
const btn = document.getElementById('searchBtn');
if (!btn) return;
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
HAS_SEARCHED = true;
run();
document.getElementById('popID')?.classList.remove('open');
document.getElementById('popSTA')?.classList.remove('open');
});
})();
// Sex multi
(() => {
const box = document.getElementById('sexChips');
if (!box) return;
box.addEventListener('click', (e) => {
const b = e.target.closest('.chip');
if (!b) return;
e.stopPropagation();
const val = b.dataset.sex ?? '';
if (!Array.isArray(state.sex)) state.sex = state.sex ? [String(state.sex)] : [];
if (val === '') {
state.sex = [];
$$('#sexChips .chip').forEach(x => x.classList.remove('active'));
$$('#sexChips .chip[data-sex=""]').forEach(x => x.classList.add('active'));
} else {
b.classList.toggle('active');
const i = state.sex.indexOf(val);
if (i === -1) state.sex.push(val); else state.sex.splice(i, 1);
const anyBtn = $$('#sexChips .chip[data-sex=""]')[0];
if (anyBtn) anyBtn.classList.toggle('active', state.sex.length === 0);
}
updateSTASummary();
});
})();
// Tumor single
(() => {
const box = document.getElementById('tumorChips');
if (!box) return;
box.addEventListener('click', (e) => {
const b = e.target.closest('.chip');
if (!b) return;
e.stopPropagation();
state.tumor = b.dataset.tumor ?? '';
$$('#tumorChips .chip').forEach(x =>
x.classList.toggle('active', (x.dataset.tumor ?? '') === state.tumor)
);
updateSTASummary();
});
})();
// Sort select
(() => {
const sortSel = document.getElementById('sortBy');
if (!sortSel) return;
sortSel.addEventListener('change', () => {
state.sort_by = sortSel.value || 'quality';
if (HAS_SEARCHED) renderAfterFetch(lastFetched || []);
});
})();
// 拉一次完整清單到 ALL_ITEMS(給 Browse / 後備用)
async function bootstrapLists(){
try{
const data = await fetch('/api/search?per_page=10000&sort_by=id', {cache:'no-store'})
.then(r => { if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); });
ALL_ITEMS = data.items || [];
}catch(e){
console.warn('bootstrapLists failed:', e);
ALL_ITEMS = [];
}
}
/* ---- 初始化 ---- */
(async function init() {
// 有就呼叫;避免缺函式時中斷
try { renderRecent?.(); } catch(e){ console.warn('renderRecent failed:', e); }
if (typeof bootstrapLists === 'function') {
await bootstrapLists();
} else {
console.warn('bootstrapLists is not defined; skipping preload');
}
// Age 初始 chips
state.age_bin = [];
renderAgeChips();
// Browse 區塊
try { await initBrowse?.(); } catch(e){ console.warn('initBrowse failed:', e); }
// STA chips 初始狀態
$$('#sexChips .chip').forEach(x => x.classList.toggle('active', (x.dataset.sex ?? '') === ''));
$$('#tumorChips .chip').forEach(x => x.classList.toggle('active', (x.dataset.tumor ?? '') === ''));
updateSTASummary?.();
HAS_SEARCHED = false;
lastFetched = [];
updatePanels?.();
})();
// 首頁載入先把結果面板藏起來
document.addEventListener('DOMContentLoaded', () => {
const resultsPanel = document.getElementById('resultsPanel');
const head = document.querySelector('.resultsHead');
const cards = document.getElementById('cards');
if (resultsPanel) resultsPanel.style.display = 'none';
if (head) head.style.display = 'none';
if (cards) cards.style.display = 'none';
try {
updatePanels();
} catch (e) {
console.warn(e);
}
});
// ===== helpers for infinite render =====
// 懶載圖 + 併發限流(避免 429)
// ✅ 只保留「視窗」為 viewport 的定義(刪掉舊的 #resultsPanel 版本)
const IMG_VIEWPORT = () => null;
const MAX_IMG_CONC = 6;
let _imgInFlight = 0;
const _imgQueue = [];
// 圖片 IntersectionObserver:只在接近可視範圍時才排入下載
const ioImg = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (!e.isIntersecting) return;
const img = e.target;
ioImg.unobserve(img);
_enqueueImgLoad(img);
});
}, { root: IMG_VIEWPORT(), rootMargin: '400px' });
function attachLazy(imgEl, url){
imgEl.loading = 'lazy';
imgEl.decoding = 'async';
imgEl.dataset.src = url;
imgEl.src = svg('2D Image'); // 你的 placeholder
ioImg.observe(imgEl);
}
function _enqueueImgLoad(img){
_imgQueue.push({ img, attempt: 0 });
_pumpImgQueue();
}
function _pumpImgQueue(){
while (_imgInFlight < MAX_IMG_CONC && _imgQueue.length){
const job = _imgQueue.shift();
_imgInFlight++;
_loadWithRetry(job.img, job.attempt).finally(() => {
_imgInFlight--;
_pumpImgQueue();
});
}
}
function _loadWithRetry(img, attempt = 0){
const url = img.dataset.src;
if (!url) return Promise.resolve();
return new Promise((resolve) => {
const fail = () => {
if (attempt < 3){
const wait = 500 * Math.pow(2, attempt); // 0.5s,1s,2s
setTimeout(() => _loadWithRetry(img, attempt + 1).then(resolve), wait);
} else {
img.src = svg('2D Image');
resolve();
}
};
img.onerror = fail;
img.onload = () => resolve();
img.src = url; // 真正發請求
});
}
// 分批掛載(防止重複宣告)
window.BATCH = window.BATCH ?? 60; // 每批幾張卡
window._mountIdx = window._mountIdx ?? 0;
window._infIO = window._infIO ?? null;
function _fallbackCard(item){
const el = document.createElement('article');
el.className = 'card';
el.innerHTML = `
<img class="thumb" alt="">
<div class="body">
<div class="titleRow">
<a href="javascript:void(0)" class="caseLink">${String(item.case_id || item.id || '')}</a>
</div>
<div class="keyRow">
<span class="kv"><span class="k">Sex</span><span class="v">${String(item.sex ?? '—')}</span></span>
<span class="kv"><span class="k">Age</span><span class="v">${Number.isFinite(item.age) ? item.age + 'y' : '—'}</span></span>
<span class="kv"><span class="tag ${item.tumor === 1 ? 'bad' : 'ok'}">${item.tumor === 1 ? 'Tumor' : 'No tumor'}</span></span>
</div>
</div>`;
return el;
}
// 允許指定本批掛多少張;不給就用原本的 BATCH
function _appendBatch(cards, items, limit = BATCH){
const end = Math.min(_mountIdx + limit, items.length);
const frag = document.createDocumentFragment();
for (let i = _mountIdx; i < end; i++){
const it = items[i];
const el = (typeof makeCard === 'function') ? makeCard(it)
: (typeof makeCardHTML === 'function') ? (() => {
const a = document.createElement('article');
a.className = 'card';
a.innerHTML = makeCardHTML(it);
return a;
})()
: _fallbackCard(it);
const img = el.querySelector('img.thumb');
if (img) {
const id = extractNumFromId(it.case_id || it.id);
attachLazy(img, profileURL(id));
}
frag.appendChild(el);
}
cards.appendChild(frag);
_mountIdx = end;
}
function _setupInfinite(cards, items){
// 清掉舊 sentinel
const old = cards.querySelector('[data-sentinel]');
if (old) old.remove();
// 確保容器可做絕對定位參考
if (getComputedStyle(cards).position === 'static') {
cards.style.position = 'relative';
}
const sentinel = document.createElement('div');
sentinel.setAttribute('data-sentinel', '1');
// ✅ 用絕對定位,完全不佔 CSS Grid 的格子
Object.assign(sentinel.style, {
position: 'absolute',
left: '0',
bottom: '0',
width: '1px',
height: '1px',
overflow: 'hidden',
pointerEvents: 'none',
// 下面兩行只是保險,確保它不影響排版
transform: 'translateZ(0)',
contain: 'layout style'
});
cards.appendChild(sentinel);
// 重新掛 observer
if (_infIO) { try { _infIO.disconnect(); } catch(e){} }
_infIO = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (!e.isIntersecting) return;
let loops = 0, MAX_LOOPS = 5;
const nearBottom = () => sentinel.getBoundingClientRect().top < (innerHeight + 1200);
while (_mountIdx < items.length && nearBottom() && loops < MAX_LOOPS) {
_appendBatch(cards, items);
loops++;
}
if (_mountIdx < items.length && nearBottom()) {
requestAnimationFrame(() => { _infIO.unobserve(sentinel); _infIO.observe(sentinel); });
}
if (_mountIdx >= items.length) _infIO.disconnect();
});
}, { root: null, rootMargin: '1200px', threshold: 0 });
_infIO.observe(sentinel);
}
function _startInfiniteRender(cards, items){
// 清空並重置游標
cards.innerHTML = '';
_mountIdx = 0;
// 起手先掛兩批(大約 120 張),畫面不空
_appendBatch(cards, items); // 第 1 批
_appendBatch(cards, items); // 第 2 批
// 啟動無限捲動:sentinel 近視窗底時繼續補批
_setupInfinite(cards, items);
// 若內容高度太短,主動補到 sentinel 推出視窗底緣(避免卡在 120)
setTimeout(() => {
const sentinel = cards.querySelector('[data-sentinel]');
if (!sentinel) return;
while (sentinel.getBoundingClientRect().bottom < window.innerHeight + 200 &&
_mountIdx < items.length) {
_appendBatch(cards, items);
}
}, 0);
}
// ===== end helpers =====
function renderAfterFetch(items = []) {
const cardsEl = document.getElementById('cards');
const resultsPanel = document.getElementById('resultsPanel');
const head = document.querySelector('.resultsHead');
const recBar = document.querySelector('#recBar');
if (!cardsEl) return;
const list = Array.isArray(items) ? items : [];
window.lastFetched = list;
const n = list.length;
// 0 筆:結果標題與面板保留,卡片隱藏,Browse 顯示
if (n === 0) {
const total = 0;
if (typeof setResultsCount === 'function') setResultsCount(total);
else {
const counterEl = document.getElementById('counter') || document.querySelector('.counter');
if (counterEl) counterEl.textContent = 'Results: 0 cases';
}
head && (head.style.display = '');
resultsPanel && (resultsPanel.style.display = '');
if (cardsEl) { cardsEl.style.display = 'none'; cardsEl.innerHTML = ''; }
recBar && recBar.style.removeProperty('display');
return;
}
// 有結果:正常顯示
recBar && (recBar.style.display = 'none');
head && (head.style.display = '');
resultsPanel && (resultsPanel.style.display = '');
cardsEl.style.display = '';
const total = Number(window.LAST_TOTAL ?? n) || n;
if (typeof setResultsCount === 'function') setResultsCount(total);
else {
const counterEl = document.getElementById('counter') || document.querySelector('.counter');
if (counterEl) counterEl.textContent = `Results: ${total} ${total === 1 ? 'case' : 'cases'}`;
}
cardsEl.classList.toggle('centerOne', n === 1);
head?.classList.toggle('single', n === 1);
resultsPanel?.classList.toggle('single', n === 1);
const sorted = (typeof sorter === 'function') ? sorter(list.slice()) : list.slice();
_startInfiniteRender(cardsEl, sorted);
const sortSel = document.getElementById('sortBy');
if (sortSel) {
const disabled = n < 2;
sortSel.disabled = disabled;
sortSel.parentElement?.classList?.toggle('disabled', disabled);
}
}
// 防止拖移造成選取
['#recPrev', '#recNext', '#recPlay'].forEach(sel => {
const btn = document.querySelector(sel);
if (btn) btn.addEventListener('mousedown', e => e.preventDefault());
});
</script>
<script>
document.getElementById('homeBtn')?.addEventListener('click', () => {
// ✅ 回到首頁(重新載入整個畫面)
location.reload();
});
(() => {
const qInput = document.getElementById('q');
const clearBtn = document.getElementById('clearBtn');
if (!qInput || !clearBtn) return; // 保護機制,防止報錯
clearBtn.addEventListener('click', () => {
qInput.value = '';
qInput.focus();
// 若希望清除時回首頁 (可選)
window.HAS_SEARCHED = false;
if (typeof loadRandom === 'function') loadRandom();
});
})();
</script>
</body>
</html>