Spaces:
Sleeping
Sleeping
| <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 ; | |
| } | |
| /* ✅ 窄螢幕時回歸滿寬 */ | |
| @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> | |