Spaces:
Sleeping
Sleeping
Upload 4 files
Browse files- .gitattributes +1 -0
- 1012.html +1390 -0
- metadata.xlsx +3 -0
- nApp.py +731 -0
- requirements.txt +5 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
metadata.xlsx filter=lfs diff=lfs merge=lfs -text
|
1012.html
ADDED
|
@@ -0,0 +1,1390 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="utf-8" />
|
| 6 |
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
| 7 |
+
<title>CT Finder · Search + Viewer (Dark)</title>
|
| 8 |
+
<style>
|
| 9 |
+
:root {
|
| 10 |
+
--bg: #0e1a2b;
|
| 11 |
+
--panel: #12243b;
|
| 12 |
+
--panel-soft: #132a45;
|
| 13 |
+
--ink: #e8eff9;
|
| 14 |
+
--sub: #a9b7cc;
|
| 15 |
+
--line: #243a57;
|
| 16 |
+
--brand: #2a6cff;
|
| 17 |
+
--brand-ink: #fff;
|
| 18 |
+
--accent: #febb02;
|
| 19 |
+
--good: #16a34a;
|
| 20 |
+
--bad: #ef4444;
|
| 21 |
+
--control-h: 44px
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
* {
|
| 25 |
+
box-sizing: border-box
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
body {
|
| 29 |
+
margin: 0;
|
| 30 |
+
font: 14px/1.55 Inter, system-ui, Segoe UI, Arial;
|
| 31 |
+
background: var(--bg);
|
| 32 |
+
color: var(--ink)
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
.hero {
|
| 36 |
+
background: #0a1b32;
|
| 37 |
+
padding: 22px 16px 18px
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.container {
|
| 41 |
+
max-width: 1200px;
|
| 42 |
+
margin: 0 auto;
|
| 43 |
+
position: relative
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
.searchRail {
|
| 47 |
+
display: grid;
|
| 48 |
+
grid-template-columns: 1.4fr 2fr auto;
|
| 49 |
+
gap: 0;
|
| 50 |
+
background: var(--panel);
|
| 51 |
+
border-radius: 14px;
|
| 52 |
+
padding: 6px;
|
| 53 |
+
box-shadow: 0 0 0 3px var(--accent) inset
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.segment {
|
| 57 |
+
display: flex;
|
| 58 |
+
align-items: center;
|
| 59 |
+
gap: 10px;
|
| 60 |
+
padding: 6px 8px;
|
| 61 |
+
border-right: 1px solid var(--line)
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.segment:last-child {
|
| 65 |
+
border-right: none
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.segment label {
|
| 69 |
+
font-weight: 800;
|
| 70 |
+
color: var(--sub);
|
| 71 |
+
min-width: 28px
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.input,
|
| 75 |
+
.fakeInput {
|
| 76 |
+
width: 100%;
|
| 77 |
+
height: var(--control-h);
|
| 78 |
+
display: flex;
|
| 79 |
+
align-items: center;
|
| 80 |
+
padding: 10px 12px;
|
| 81 |
+
border: 1px solid var(--line);
|
| 82 |
+
border-radius: 10px;
|
| 83 |
+
background: var(--panel-soft);
|
| 84 |
+
color: var(--ink)
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.input::placeholder {
|
| 88 |
+
color: #7e90ab
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.btnSearch {
|
| 92 |
+
height: var(--control-h);
|
| 93 |
+
align-self: center;
|
| 94 |
+
border-radius: 10px;
|
| 95 |
+
background: var(--brand);
|
| 96 |
+
color: var(--brand-ink);
|
| 97 |
+
border: none;
|
| 98 |
+
font-weight: 900;
|
| 99 |
+
padding: 0 22px;
|
| 100 |
+
min-width: 132px;
|
| 101 |
+
cursor: pointer;
|
| 102 |
+
margin: 0 2px
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.pop {
|
| 106 |
+
position: relative
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.popBtn {
|
| 110 |
+
width: 100%;
|
| 111 |
+
text-align: left;
|
| 112 |
+
cursor: pointer
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.popPanel {
|
| 116 |
+
position: absolute;
|
| 117 |
+
top: calc(var(--control-h) + 10px);
|
| 118 |
+
left: 0;
|
| 119 |
+
z-index: 50;
|
| 120 |
+
display: none;
|
| 121 |
+
width: 520px;
|
| 122 |
+
max-width: 92vw;
|
| 123 |
+
background: var(--panel);
|
| 124 |
+
border: 1px solid var(--line);
|
| 125 |
+
border-radius: 12px;
|
| 126 |
+
box-shadow: 0 10px 28px rgba(0, 0, 0, .35);
|
| 127 |
+
padding: 14px
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.pop.open .popPanel {
|
| 131 |
+
display: block
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.group {
|
| 135 |
+
margin-bottom: 12px
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.groupTitle {
|
| 139 |
+
font-weight: 800;
|
| 140 |
+
margin-bottom: 6px;
|
| 141 |
+
color: var(--ink)
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.chips {
|
| 145 |
+
display: flex;
|
| 146 |
+
gap: 6px;
|
| 147 |
+
flex-wrap: wrap
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.chip {
|
| 151 |
+
padding: 6px 10px;
|
| 152 |
+
border: 1px solid var(--line);
|
| 153 |
+
border-radius: 999px;
|
| 154 |
+
background: #0f223b;
|
| 155 |
+
color: var(--ink);
|
| 156 |
+
cursor: pointer
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.chip.active {
|
| 160 |
+
outline: 2px solid #6ea8ff
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
.main {
|
| 164 |
+
max-width: 1200px;
|
| 165 |
+
margin: 16px auto;
|
| 166 |
+
display: grid;
|
| 167 |
+
gap: 16px
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
@media(min-width:1100px) {
|
| 171 |
+
.main {
|
| 172 |
+
grid-template-columns: 300px 1fr
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
.panel {
|
| 177 |
+
background: var(--panel);
|
| 178 |
+
border: 1px solid var(--line);
|
| 179 |
+
border-radius: 14px
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.filters {
|
| 183 |
+
padding: 12px;
|
| 184 |
+
display: none
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.filters.show {
|
| 188 |
+
display: block
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.secTitle {
|
| 192 |
+
font-weight: 900;
|
| 193 |
+
margin: 2px 0 8px
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.fset {
|
| 197 |
+
margin-bottom: 14px
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.optRow {
|
| 201 |
+
display: flex;
|
| 202 |
+
align-items: center;
|
| 203 |
+
gap: 8px;
|
| 204 |
+
margin: 6px 0
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.optRow input {
|
| 208 |
+
transform: translateY(1px)
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.count {
|
| 212 |
+
color: var(--sub);
|
| 213 |
+
font-size: 12px
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.showMore {
|
| 217 |
+
color: #8fb3ff;
|
| 218 |
+
background: transparent;
|
| 219 |
+
border: none;
|
| 220 |
+
cursor: pointer;
|
| 221 |
+
padding: 0 2px;
|
| 222 |
+
font-weight: 700
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
.badge {
|
| 226 |
+
display: inline-block;
|
| 227 |
+
border: 1px solid var(--line);
|
| 228 |
+
border-radius: 999px;
|
| 229 |
+
padding: 2px 8px;
|
| 230 |
+
font-size: 12px;
|
| 231 |
+
margin-left: 8px;
|
| 232 |
+
color: var(--sub)
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
label {
|
| 236 |
+
color: var(--ink)
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
/* Browse */
|
| 240 |
+
.recBar {
|
| 241 |
+
padding: 10px 12px;
|
| 242 |
+
position: relative
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
.recTitle {
|
| 246 |
+
font-weight: 900;
|
| 247 |
+
margin: 4px 0 8px
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
.recViewport {
|
| 251 |
+
position: relative
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
.recScroll {
|
| 255 |
+
display: flex;
|
| 256 |
+
gap: 12px;
|
| 257 |
+
overflow: auto;
|
| 258 |
+
padding-bottom: 8px;
|
| 259 |
+
scroll-snap-type: x mandatory;
|
| 260 |
+
scrollbar-width: none
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
.recScroll::-webkit-scrollbar {
|
| 264 |
+
display: none
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
.recCard {
|
| 268 |
+
min-width: 320px;
|
| 269 |
+
scroll-snap-align: start;
|
| 270 |
+
border: 1px solid var(--line);
|
| 271 |
+
border-radius: 14px;
|
| 272 |
+
background: var(--panel);
|
| 273 |
+
overflow: hidden
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.recThumb {
|
| 277 |
+
width: 100%;
|
| 278 |
+
aspect-ratio: 16/9;
|
| 279 |
+
background: #0f223b;
|
| 280 |
+
object-fit: cover
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
.recBody {
|
| 284 |
+
padding: 10px
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
.recMeta {
|
| 288 |
+
color: var(--sub);
|
| 289 |
+
font-size: 12px
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.btn {
|
| 293 |
+
border: 1px solid var(--line);
|
| 294 |
+
background: var(--panel-soft);
|
| 295 |
+
color: var(--ink);
|
| 296 |
+
border-radius: 10px;
|
| 297 |
+
padding: 8px 12px;
|
| 298 |
+
cursor: pointer;
|
| 299 |
+
width: 100%;
|
| 300 |
+
margin-top: 8px
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
.recCtrl {
|
| 304 |
+
position: absolute;
|
| 305 |
+
top: 50%;
|
| 306 |
+
transform: translateY(-50%);
|
| 307 |
+
width: 36px;
|
| 308 |
+
height: 36px;
|
| 309 |
+
border: 1px solid var(--line);
|
| 310 |
+
background: var(--panel-soft);
|
| 311 |
+
color: var(--ink);
|
| 312 |
+
border-radius: 999px;
|
| 313 |
+
display: grid;
|
| 314 |
+
place-items: center;
|
| 315 |
+
cursor: pointer;
|
| 316 |
+
opacity: .9
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
.recPrev {
|
| 320 |
+
left: -6px
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
.recNext {
|
| 324 |
+
right: -6px
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
.recPlay {
|
| 328 |
+
position: absolute;
|
| 329 |
+
right: 44px;
|
| 330 |
+
top: -6px;
|
| 331 |
+
width: 32px;
|
| 332 |
+
height: 32px;
|
| 333 |
+
border: 1px solid var(--line);
|
| 334 |
+
background: var(--panel-soft);
|
| 335 |
+
border-radius: 999px;
|
| 336 |
+
display: grid;
|
| 337 |
+
place-items: center;
|
| 338 |
+
cursor: pointer;
|
| 339 |
+
font-size: 14px
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
/* Results */
|
| 343 |
+
.resultsHead {
|
| 344 |
+
display: flex;
|
| 345 |
+
align-items: center;
|
| 346 |
+
justify-content: space-between;
|
| 347 |
+
padding: 10px 12px
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
.counter {
|
| 351 |
+
font-weight: 900
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
.select {
|
| 355 |
+
border: 1px solid var(--line);
|
| 356 |
+
border-radius: 10px;
|
| 357 |
+
padding: 8px 10px;
|
| 358 |
+
background: var(--panel-soft);
|
| 359 |
+
color: var(--ink)
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
.cards {
|
| 363 |
+
display: grid;
|
| 364 |
+
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
| 365 |
+
gap: 12px;
|
| 366 |
+
padding: 12px
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
.card {
|
| 370 |
+
border: 1px solid var(--line);
|
| 371 |
+
border-radius: 14px;
|
| 372 |
+
background: var(--panel);
|
| 373 |
+
overflow: hidden;
|
| 374 |
+
display: flex;
|
| 375 |
+
flex-direction: column;
|
| 376 |
+
box-shadow: 0 6px 14px rgba(0, 0, 0, .25)
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
.card:hover {
|
| 380 |
+
box-shadow: 0 10px 24px rgba(0, 0, 0, .35);
|
| 381 |
+
transform: translateY(-1px)
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
.thumb {
|
| 385 |
+
aspect-ratio: 4/3;
|
| 386 |
+
width: 100%;
|
| 387 |
+
object-fit: cover;
|
| 388 |
+
background: #0f223b
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
.body {
|
| 392 |
+
padding: 10px
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
.titleRow {
|
| 396 |
+
display: flex;
|
| 397 |
+
align-items: center;
|
| 398 |
+
justify-content: space-between;
|
| 399 |
+
margin: 4px 0 6px
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
.caseLink {
|
| 403 |
+
font-weight: 900;
|
| 404 |
+
font-size: 16px;
|
| 405 |
+
color: #9ec5ff;
|
| 406 |
+
text-decoration: none;
|
| 407 |
+
letter-spacing: .2px
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
.caseLink:hover {
|
| 411 |
+
text-decoration: underline
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
.keyRow {
|
| 415 |
+
display: flex;
|
| 416 |
+
flex-wrap: wrap;
|
| 417 |
+
gap: 10px;
|
| 418 |
+
align-items: center;
|
| 419 |
+
margin: 6px 0 2px
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
.kv {
|
| 423 |
+
display: inline-flex;
|
| 424 |
+
gap: 6px;
|
| 425 |
+
align-items: center;
|
| 426 |
+
font-size: 13px
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
.kv .k {
|
| 430 |
+
color: var(--sub);
|
| 431 |
+
text-transform: uppercase;
|
| 432 |
+
letter-spacing: .4px
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
.kv .v {
|
| 436 |
+
font-weight: 900
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
.tag {
|
| 440 |
+
font-size: 12px;
|
| 441 |
+
padding: 3px 8px;
|
| 442 |
+
border-radius: 999px;
|
| 443 |
+
border: 1px solid transparent
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
.tag.ok {
|
| 447 |
+
color: var(--good);
|
| 448 |
+
background: rgba(22, 163, 74, .12);
|
| 449 |
+
border-color: rgba(34, 197, 94, .25)
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
.tag.bad {
|
| 453 |
+
color: var(--bad);
|
| 454 |
+
background: rgba(239, 68, 68, .12);
|
| 455 |
+
border-color: rgba(239, 68, 68, .25)
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
/* Preparing page */
|
| 459 |
+
.prepPage {
|
| 460 |
+
position: fixed;
|
| 461 |
+
inset: 0;
|
| 462 |
+
display: none;
|
| 463 |
+
align-items: center;
|
| 464 |
+
justify-content: center;
|
| 465 |
+
background: #000;
|
| 466 |
+
z-index: 1200;
|
| 467 |
+
pointer-events: auto
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
.prepPage.show {
|
| 471 |
+
display: flex
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
.prepBox {
|
| 475 |
+
text-align: center;
|
| 476 |
+
color: #cfd7ff
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
.prepTitle {
|
| 480 |
+
margin-top: 6px;
|
| 481 |
+
font-weight: 800
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
.prepHint {
|
| 485 |
+
margin-top: 4px;
|
| 486 |
+
color: #8ea6ff
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
/* Viewer */
|
| 490 |
+
.viewer {
|
| 491 |
+
position: fixed;
|
| 492 |
+
inset: 0;
|
| 493 |
+
background: #000;
|
| 494 |
+
color: #e8edf8;
|
| 495 |
+
z-index: 100;
|
| 496 |
+
display: none
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
.viewer.show {
|
| 500 |
+
display: block
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
.viewer.compact .v-sidebar,
|
| 504 |
+
.viewer.compact .v-card {
|
| 505 |
+
display: none
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
.v-toolbar {
|
| 509 |
+
position: fixed;
|
| 510 |
+
top: 12px;
|
| 511 |
+
left: 12px;
|
| 512 |
+
display: flex;
|
| 513 |
+
gap: 10px;
|
| 514 |
+
z-index: 1000;
|
| 515 |
+
pointer-events: auto
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
.iconBtn {
|
| 519 |
+
width: 40px;
|
| 520 |
+
height: 40px;
|
| 521 |
+
border: 1px solid #2b3146;
|
| 522 |
+
background: #0f1324;
|
| 523 |
+
color: #e8edf8;
|
| 524 |
+
border-radius: 12px;
|
| 525 |
+
display: grid;
|
| 526 |
+
place-items: center;
|
| 527 |
+
cursor: pointer;
|
| 528 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, .35)
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
.v-sidebar {
|
| 532 |
+
position: fixed;
|
| 533 |
+
top: 64px;
|
| 534 |
+
left: 0;
|
| 535 |
+
bottom: 0;
|
| 536 |
+
width: 260px;
|
| 537 |
+
background: #0f1324;
|
| 538 |
+
border-right: 1px solid #2b3146;
|
| 539 |
+
padding: 12px;
|
| 540 |
+
overflow: auto;
|
| 541 |
+
z-index: 200
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
.v-sidebar h3 {
|
| 545 |
+
margin: 2px 0 10px;
|
| 546 |
+
font-size: 16px
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
.toggleAll {
|
| 550 |
+
width: 100%;
|
| 551 |
+
background: #161a2e;
|
| 552 |
+
border: 1px solid #2b3146;
|
| 553 |
+
border-radius: 10px;
|
| 554 |
+
color: #e8edf8;
|
| 555 |
+
padding: 8px 10px;
|
| 556 |
+
margin-bottom: 8px;
|
| 557 |
+
cursor: pointer
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
.v-card {
|
| 561 |
+
position: fixed;
|
| 562 |
+
top: 60px;
|
| 563 |
+
left: 12px;
|
| 564 |
+
width: 300px;
|
| 565 |
+
background: #0f1324;
|
| 566 |
+
border: 1px solid #2b3146;
|
| 567 |
+
border-radius: 12px;
|
| 568 |
+
padding: 10px;
|
| 569 |
+
z-index: 300;
|
| 570 |
+
max-height: calc(100vh - 96px);
|
| 571 |
+
overflow: auto
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
.v-actions {
|
| 575 |
+
display: flex;
|
| 576 |
+
gap: 10px;
|
| 577 |
+
margin-top: 12px;
|
| 578 |
+
justify-content: space-between
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
.v-stage {
|
| 582 |
+
margin-left: 260px;
|
| 583 |
+
height: 100vh;
|
| 584 |
+
display: grid;
|
| 585 |
+
grid-template-columns: 1fr 1fr;
|
| 586 |
+
grid-template-rows: 1fr 1fr;
|
| 587 |
+
gap: 12px;
|
| 588 |
+
padding: 72px 16px 16px 276px
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
.viewer.compact .v-stage {
|
| 592 |
+
margin-left: 0;
|
| 593 |
+
padding: 12px
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
.v-view {
|
| 597 |
+
position: relative;
|
| 598 |
+
border: none;
|
| 599 |
+
border-radius: 12px;
|
| 600 |
+
background: #000;
|
| 601 |
+
overflow: hidden
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
.v-view h4 {
|
| 605 |
+
display: none
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
.v-view img {
|
| 609 |
+
width: 100%;
|
| 610 |
+
height: 100%;
|
| 611 |
+
object-fit: contain;
|
| 612 |
+
background: #000
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
.center {
|
| 616 |
+
position: absolute;
|
| 617 |
+
inset: 0;
|
| 618 |
+
display: grid;
|
| 619 |
+
place-items: center;
|
| 620 |
+
color: #cfd7ff
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
.center .mini {
|
| 624 |
+
width: 160px;
|
| 625 |
+
height: 100px;
|
| 626 |
+
background: #dcd7ce;
|
| 627 |
+
border-radius: 10px;
|
| 628 |
+
margin-bottom: 8px
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
.hidden {
|
| 632 |
+
display: none !important
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
/* modal */
|
| 636 |
+
.modal {
|
| 637 |
+
position: fixed;
|
| 638 |
+
inset: 0;
|
| 639 |
+
background: rgba(0, 0, 0, .55);
|
| 640 |
+
display: none;
|
| 641 |
+
align-items: center;
|
| 642 |
+
justify-content: center;
|
| 643 |
+
z-index: 1300
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
.modal.show {
|
| 647 |
+
display: flex
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
.modalBox {
|
| 651 |
+
width: min(720px, 92vw);
|
| 652 |
+
max-height: 80vh;
|
| 653 |
+
overflow: auto;
|
| 654 |
+
background: #0f1324;
|
| 655 |
+
color: #e8edf8;
|
| 656 |
+
border: 1px solid #2b3146;
|
| 657 |
+
border-radius: 12px;
|
| 658 |
+
padding: 14px;
|
| 659 |
+
box-shadow: 0 12px 32px rgba(0, 0, 0, .45)
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
.modalHead {
|
| 663 |
+
display: flex;
|
| 664 |
+
align-items: center;
|
| 665 |
+
justify-content: space-between;
|
| 666 |
+
margin-bottom: 8px
|
| 667 |
+
}
|
| 668 |
+
|
| 669 |
+
.iconBtn.sm {
|
| 670 |
+
width: 32px;
|
| 671 |
+
height: 32px;
|
| 672 |
+
border-radius: 10px
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
/* Class Map list style */
|
| 676 |
+
.cm {
|
| 677 |
+
display: flex;
|
| 678 |
+
flex-direction: column;
|
| 679 |
+
gap: 8px
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
.cm-head {
|
| 683 |
+
display: flex;
|
| 684 |
+
align-items: center;
|
| 685 |
+
justify-content: space-between;
|
| 686 |
+
font-weight: 800;
|
| 687 |
+
margin-bottom: 4px
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
.cm-group {
|
| 691 |
+
border: 1px solid #2b3146;
|
| 692 |
+
border-radius: 10px;
|
| 693 |
+
background: #0f1324;
|
| 694 |
+
overflow: hidden
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
.cm-row {
|
| 698 |
+
display: flex;
|
| 699 |
+
align-items: center;
|
| 700 |
+
gap: 8px;
|
| 701 |
+
padding: 10px;
|
| 702 |
+
cursor: default
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
.cm-row .chev {
|
| 706 |
+
width: 18px;
|
| 707 |
+
text-align: center;
|
| 708 |
+
opacity: .9
|
| 709 |
+
}
|
| 710 |
+
|
| 711 |
+
.cm-row .title {
|
| 712 |
+
flex: 1;
|
| 713 |
+
font-weight: 700
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
.cm-row input[type=checkbox] {
|
| 717 |
+
accent-color: #6ea8ff
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
.cm-items {
|
| 721 |
+
padding: 8px 10px 10px 36px;
|
| 722 |
+
display: grid;
|
| 723 |
+
grid-template-columns: 1fr;
|
| 724 |
+
gap: 4px;
|
| 725 |
+
max-height: 240px;
|
| 726 |
+
overflow: auto;
|
| 727 |
+
border-top: 1px solid #202844
|
| 728 |
+
}
|
| 729 |
+
|
| 730 |
+
.cm-item {
|
| 731 |
+
display: flex;
|
| 732 |
+
align-items: center;
|
| 733 |
+
gap: 10px;
|
| 734 |
+
padding: 6px 4px;
|
| 735 |
+
border-radius: 8px
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
.cm-item:hover {
|
| 739 |
+
background: #121837
|
| 740 |
+
}
|
| 741 |
+
|
| 742 |
+
.cm-item input[type=checkbox] {
|
| 743 |
+
accent-color: #6ea8ff;
|
| 744 |
+
transform: translateY(1px)
|
| 745 |
+
}
|
| 746 |
+
|
| 747 |
+
.cm-item .name {
|
| 748 |
+
font-size: 13px;
|
| 749 |
+
letter-spacing: .2px
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
.cm-group.closed .cm-items {
|
| 753 |
+
display: none
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
.v-sidebar::-webkit-scrollbar,
|
| 757 |
+
.cm-items::-webkit-scrollbar {
|
| 758 |
+
width: 10px;
|
| 759 |
+
height: 10px
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
.v-sidebar::-webkit-scrollbar-thumb,
|
| 763 |
+
.cm-items::-webkit-scrollbar-thumb {
|
| 764 |
+
background: #1c243a;
|
| 765 |
+
border-radius: 8px;
|
| 766 |
+
border: 2px solid #0f1324
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
.v-sidebar::-webkit-scrollbar-track,
|
| 770 |
+
.cm-items::-webkit-scrollbar-track {
|
| 771 |
+
background: transparent
|
| 772 |
+
}
|
| 773 |
+
|
| 774 |
+
/* tools */
|
| 775 |
+
.toolBtn {
|
| 776 |
+
width: 44px;
|
| 777 |
+
height: 44px;
|
| 778 |
+
border: 1px solid #2b3146;
|
| 779 |
+
background: #0f1324;
|
| 780 |
+
color: #e8edf8;
|
| 781 |
+
border-radius: 12px;
|
| 782 |
+
display: grid;
|
| 783 |
+
place-items: center;
|
| 784 |
+
cursor: pointer
|
| 785 |
+
}
|
| 786 |
+
|
| 787 |
+
.toolBtn svg {
|
| 788 |
+
width: 22px;
|
| 789 |
+
height: 22px
|
| 790 |
+
}
|
| 791 |
+
</style>
|
| 792 |
+
</head>
|
| 793 |
+
|
| 794 |
+
<body>
|
| 795 |
+
|
| 796 |
+
<!-- Search -->
|
| 797 |
+
<section class="hero">
|
| 798 |
+
<div class="container">
|
| 799 |
+
<div class="searchRail">
|
| 800 |
+
|
| 801 |
+
<!-- ID -->
|
| 802 |
+
<div class="segment pop" id="popID">
|
| 803 |
+
<label>ID</label>
|
| 804 |
+
<input id="q" class="input popBtn" placeholder="Search Case ID or keyword…" autocomplete="off">
|
| 805 |
+
<div class="popPanel" style="width:520px">
|
| 806 |
+
<div class="group">
|
| 807 |
+
<div class="groupTitle">Recommended IDs</div>
|
| 808 |
+
<div id="idReco" class="chips"></div>
|
| 809 |
+
</div>
|
| 810 |
+
<div class="group">
|
| 811 |
+
<div class="groupTitle">Recently viewed</div>
|
| 812 |
+
<div id="idRecent" class="chips"><span class="recMeta">No recent</span></div>
|
| 813 |
+
</div>
|
| 814 |
+
</div>
|
| 815 |
+
</div>
|
| 816 |
+
|
| 817 |
+
<!-- STA -->
|
| 818 |
+
<div class="segment pop" id="popSTA">
|
| 819 |
+
<button class="fakeInput popBtn" type="button">
|
| 820 |
+
<span id="staSummary">Any tumor · Any sex · Any age</span>
|
| 821 |
+
</button>
|
| 822 |
+
<div class="popPanel" style="max-width:560px">
|
| 823 |
+
<div class="group">
|
| 824 |
+
<div class="groupTitle">Tumor</div>
|
| 825 |
+
<div id="tumorChips" class="chips">
|
| 826 |
+
<button class="chip" data-tumor="">Any</button>
|
| 827 |
+
<button class="chip" data-tumor="1">Tumor</button>
|
| 828 |
+
<button class="chip" data-tumor="0">No tumor</button>
|
| 829 |
+
</div>
|
| 830 |
+
</div>
|
| 831 |
+
<div class="group">
|
| 832 |
+
<div class="groupTitle">Sex</div>
|
| 833 |
+
<div id="sexChips" class="chips">
|
| 834 |
+
<button class="chip" data-sex="">Any</button>
|
| 835 |
+
<button class="chip" data-sex="M">Male</button>
|
| 836 |
+
<button class="chip" data-sex="F">Female</button>
|
| 837 |
+
</div>
|
| 838 |
+
</div>
|
| 839 |
+
<div class="group">
|
| 840 |
+
<div class="groupTitle">Age quick picks</div>
|
| 841 |
+
<div id="ageChips" class="chips"></div>
|
| 842 |
+
</div>
|
| 843 |
+
<!-- Apply inside the popover -->
|
| 844 |
+
<button id="applySTA" class="btnSearch" style="width:100%" type="button">Apply</button>
|
| 845 |
+
</div>
|
| 846 |
+
</div>
|
| 847 |
+
|
| 848 |
+
<!-- Right side Search button -->
|
| 849 |
+
<button id="searchBtn" class="btnSearch" type="button">Search</button>
|
| 850 |
+
|
| 851 |
+
</div>
|
| 852 |
+
</div>
|
| 853 |
+
</section>
|
| 854 |
+
|
| 855 |
+
<!-- Browse -->
|
| 856 |
+
<section class="panel container recBar" id="recBar">
|
| 857 |
+
<div class="recTitle">Browse</div>
|
| 858 |
+
<div class="recViewport">
|
| 859 |
+
<button class="recCtrl recPrev" id="recPrev" title="Previous">‹</button>
|
| 860 |
+
<button class="recCtrl recNext" id="recNext" title="Next">›</button>
|
| 861 |
+
<button class="recPlay" id="recPlay" title="Pause/Play">⏸</button>
|
| 862 |
+
<div id="recScroll" class="recScroll"></div>
|
| 863 |
+
</div>
|
| 864 |
+
</section>
|
| 865 |
+
|
| 866 |
+
<!-- Main -->
|
| 867 |
+
<section class="main container">
|
| 868 |
+
<aside id="filters" class="filters panel">
|
| 869 |
+
<div class="secTitle">Advanced</div>
|
| 870 |
+
|
| 871 |
+
<div class="fset" id="fs_ct">
|
| 872 |
+
<div class="groupTitle">CT phase <span class="badge">multi-select</span></div>
|
| 873 |
+
<div class="optRow"><label><input type="checkbox" data-k="ct_phase" value="" checked> Any</label></div>
|
| 874 |
+
<div id="ct_phase_opts"></div>
|
| 875 |
+
</div>
|
| 876 |
+
|
| 877 |
+
<div class="fset" id="fs_mfr">
|
| 878 |
+
<div class="groupTitle">Manufacturer <span class="badge">multi-select</span></div>
|
| 879 |
+
<div class="optRow"><label><input type="checkbox" data-k="manufacturer" value="" checked> Any</label>
|
| 880 |
+
</div>
|
| 881 |
+
<div id="manufacturer_opts"></div>
|
| 882 |
+
</div>
|
| 883 |
+
|
| 884 |
+
<div class="fset" id="fs_model">
|
| 885 |
+
<div class="groupTitle">Manufacturer model <span class="badge">multi-select</span></div>
|
| 886 |
+
<div class="optRow"><label><input type="checkbox" data-k="model" value="" checked> Any</label></div>
|
| 887 |
+
<div id="model_opts"></div>
|
| 888 |
+
<button class="showMore" data-target="model_opts">Show more</button>
|
| 889 |
+
</div>
|
| 890 |
+
|
| 891 |
+
<div class="fset" id="fs_type">
|
| 892 |
+
<div class="groupTitle">Study type <span class="badge">multi-select</span></div>
|
| 893 |
+
<div class="optRow"><label><input type="checkbox" data-k="study_type" value="" checked> Any</label>
|
| 894 |
+
</div>
|
| 895 |
+
<div id="type_opts"></div>
|
| 896 |
+
</div>
|
| 897 |
+
|
| 898 |
+
<div class="fset" id="fs_nat">
|
| 899 |
+
<div class="groupTitle">Site nationality <span class="badge">multi-select</span></div>
|
| 900 |
+
<div class="optRow"><label><input type="checkbox" data-k="site_nat" value="" checked> Any</label></div>
|
| 901 |
+
<div id="nat_opts"></div>
|
| 902 |
+
</div>
|
| 903 |
+
|
| 904 |
+
<div class="fset" id="fs_year">
|
| 905 |
+
<div class="groupTitle">Study year <span class="badge">multi-select</span></div>
|
| 906 |
+
<div class="optRow"><label><input type="checkbox" data-k="year" value="" checked> Any</label></div>
|
| 907 |
+
<div id="year_opts"></div>
|
| 908 |
+
</div>
|
| 909 |
+
</aside>
|
| 910 |
+
|
| 911 |
+
<section id="resultsPanel" class="panel" style="display:none">
|
| 912 |
+
<div class="resultsHead" style="display:none">
|
| 913 |
+
<div id="counter" class="counter">Results: 0 cases</div>
|
| 914 |
+
<select id="sortBy" class="select">
|
| 915 |
+
<option value="quality">Sort by: Best quality</option>
|
| 916 |
+
<option value="spacing_asc">Spacing (low → high)</option>
|
| 917 |
+
<option value="shape_desc">Shape score (high → low)</option>
|
| 918 |
+
<option value="age_asc">Age (young → old)</option>
|
| 919 |
+
<option value="age_desc">Age (old → young)</option>
|
| 920 |
+
<option value="id_asc">ID (low → high)</option>
|
| 921 |
+
<option value="id_desc">ID (high → low)</option>
|
| 922 |
+
</select>
|
| 923 |
+
</div>
|
| 924 |
+
<div id="cards" class="cards" aria-live="polite" style="display:none"></div>
|
| 925 |
+
</section>
|
| 926 |
+
</section>
|
| 927 |
+
|
| 928 |
+
<!-- Preparing Page -->
|
| 929 |
+
<div id="prepPage" class="prepPage" aria-hidden="true">
|
| 930 |
+
<div class="prepBox">
|
| 931 |
+
<img id="prepImg" src="" alt=""
|
| 932 |
+
style="width:180px;height:110px;border-radius:12px;background:#dcd7ce;display:block;margin:0 auto 12px;">
|
| 933 |
+
<div class="prepTitle">Preparing data...</div>
|
| 934 |
+
<div class="prepHint">‹ pancreas ›</div>
|
| 935 |
+
</div>
|
| 936 |
+
</div>
|
| 937 |
+
|
| 938 |
+
<!-- Viewer -->
|
| 939 |
+
<section id="viewer" class="viewer" aria-hidden="true">
|
| 940 |
+
<div class="v-toolbar">
|
| 941 |
+
<button class="iconBtn" id="btnBack" title="Back to search">↩</button>
|
| 942 |
+
<button class="iconBtn" id="btnGear" title="Viewer settings">⚙️</button>
|
| 943 |
+
<button class="iconBtn" id="btnClassMap" style="display:none" aria-hidden="true">🗺️</button>
|
| 944 |
+
</div>
|
| 945 |
+
|
| 946 |
+
<aside class="v-sidebar" id="vSidebar" style="display:none">
|
| 947 |
+
<div class="cm">
|
| 948 |
+
<div class="cm-head">
|
| 949 |
+
<h3 style="margin:0">Organs</h3>
|
| 950 |
+
<button class="toggleAll" id="toggleAll">Toggle all</button>
|
| 951 |
+
</div>
|
| 952 |
+
<div id="cmRoot"></div>
|
| 953 |
+
</div>
|
| 954 |
+
</aside>
|
| 955 |
+
|
| 956 |
+
<div class="v-card" id="vCard" style="display:none">
|
| 957 |
+
<h5 id="caseTitle" style="margin:2px 0 8px">Case ID: —</h5>
|
| 958 |
+
<div class="row"><label style="min-width:95px;color:#9aa3b2">Label Opacity</label><input id="op"
|
| 959 |
+
type="range" min="0" max="100" value="60"><span id="opv" class="value">60</span></div>
|
| 960 |
+
<div class="row"><label style="min-width:95px;color:#9aa3b2">Level</label><input id="lvl" type="range"
|
| 961 |
+
min="-200" max="200" value="50"><span id="lvlv" class="value">50</span></div>
|
| 962 |
+
<div class="row"><label style="min-width:95px;color:#9aa3b2">Window</label><input id="win" type="range"
|
| 963 |
+
min="100" max="2000" value="400"><span id="winv" class="value">400</span></div>
|
| 964 |
+
|
| 965 |
+
<button id="openClassMap" class="btn" style="width:100%;margin-top:10px">Class Map</button>
|
| 966 |
+
<div class="v-actions">
|
| 967 |
+
<button id="zoomIn" class="toolBtn" title="Zoom in"></button>
|
| 968 |
+
<button id="zoomOut" class="toolBtn" title="Zoom out"></button>
|
| 969 |
+
<button id="download" class="toolBtn" title="Download"></button>
|
| 970 |
+
<button id="report" class="toolBtn" title="Report"></button>
|
| 971 |
+
</div>
|
| 972 |
+
</div>
|
| 973 |
+
|
| 974 |
+
<main class="v-stage">
|
| 975 |
+
<section class="v-view">
|
| 976 |
+
<h4>Axial</h4><img id="axial" alt="axial">
|
| 977 |
+
</section>
|
| 978 |
+
<section class="v-view">
|
| 979 |
+
<h4>Sagittal</h4><img id="sagittal" alt="sagittal">
|
| 980 |
+
</section>
|
| 981 |
+
<section class="v-view">
|
| 982 |
+
<h4>Coronal</h4><img id="coronal" alt="coronal">
|
| 983 |
+
</section>
|
| 984 |
+
<section class="v-view">
|
| 985 |
+
<h4>3D</h4>
|
| 986 |
+
<div class="center" id="prep">
|
| 987 |
+
<div class="mini"></div>
|
| 988 |
+
<div>Preparing data…</div>
|
| 989 |
+
<div style="color:#8ea6ff;margin-top:4px">‹ pancreas ›</div>
|
| 990 |
+
</div>
|
| 991 |
+
</section>
|
| 992 |
+
</main>
|
| 993 |
+
</section>
|
| 994 |
+
|
| 995 |
+
<!-- Report Modal -->
|
| 996 |
+
<div id="reportModal" class="modal" aria-hidden="true">
|
| 997 |
+
<div class="modalBox">
|
| 998 |
+
<div class="modalHead">
|
| 999 |
+
<h3 style="margin:0">Case Report</h3>
|
| 1000 |
+
<button id="rpClose" class="iconBtn sm">✕</button>
|
| 1001 |
+
</div>
|
| 1002 |
+
<div class="modalBody">
|
| 1003 |
+
<p style="color:#9aa3b2">Report content goes here…</p>
|
| 1004 |
+
</div>
|
| 1005 |
+
</div>
|
| 1006 |
+
</div>
|
| 1007 |
+
|
| 1008 |
+
<script>
|
| 1009 |
+
/* ===== helpers ===== */
|
| 1010 |
+
const $ = s => document.querySelector(s), $$ = s => Array.from(document.querySelectorAll(s));
|
| 1011 |
+
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>`);
|
| 1012 |
+
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>`);
|
| 1013 |
+
|
| 1014 |
+
const state = { q: '', sex: '', tumor: '', age_from: '', age_to: '', sort_by: 'quality', ct_phase: [], manufacturer: [], model: [], study_type: [], site_nat: [], year: [], per_page: 1000000, page: 1 };
|
| 1015 |
+
let ALL_ITEMS = [], lastFetched = []; let HAS_SEARCHED = false;
|
| 1016 |
+
|
| 1017 |
+
function show(nodeOrSel, on) { const n = (typeof nodeOrSel === 'string') ? document.querySelector(nodeOrSel) : nodeOrSel; if (!n) return; if (on) n.style.removeProperty('display'); else n.style.display = 'none'; }
|
| 1018 |
+
function updatePanels() {
|
| 1019 |
+
const searched = HAS_SEARCHED; const count = searched ? (lastFetched?.length || 0) : 0; const showResults = searched && count > 0;
|
| 1020 |
+
const counter = $('#counter'); if (counter) counter.textContent = `Results: ${count} cases`;
|
| 1021 |
+
show('#recBar', !showResults); show('#resultsPanel', showResults); show('.resultsHead', showResults); show('#cards', showResults);
|
| 1022 |
+
const filt = $('#filters'); if (filt) { filt.classList.toggle('show', showResults); show(filt, showResults); }
|
| 1023 |
+
}
|
| 1024 |
+
|
| 1025 |
+
function wirePop(root) { const btn = root.querySelector('.popBtn'); btn.addEventListener('click', e => { e.stopPropagation(); root.classList.toggle('open'); }); document.addEventListener('click', e => { if (!root.contains(e.target)) root.classList.remove('open'); }); }
|
| 1026 |
+
wirePop($('#popID')); wirePop($('#popSTA'));
|
| 1027 |
+
|
| 1028 |
+
async function fetchJSON(u) { const r = await fetch(u, { cache: 'no-store' }); if (!r.ok) throw new Error(await r.text()); return r.json(); }
|
| 1029 |
+
|
| 1030 |
+
/* quality helpers */
|
| 1031 |
+
function idNum(x) { const m = String(x.case_id || x['PanTS ID'] || x.id).match(/\d+/) || [0]; return Number(m[0]); }
|
| 1032 |
+
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; }
|
| 1033 |
+
function compareQuality(a, b) { const ca = isComplete(a), cb = isComplete(b); if (ca !== cb) return cb - ca; const s1 = (a.spacing_sum ?? 1e9) - (b.spacing_sum ?? 1e9); if (s1 !== 0) return s1; const s2 = (b.shape_sum ?? -1) - (a.shape_sum ?? -1); if (s2 !== 0) return s2; return idNum(a) - idNum(b); }
|
| 1034 |
+
|
| 1035 |
+
/* Browse */
|
| 1036 |
+
let recTimer = null, recPlaying = true;
|
| 1037 |
+
async function initBrowse() {
|
| 1038 |
+
try {
|
| 1039 |
+
const bar = $('#recScroll'); bar.innerHTML = '';
|
| 1040 |
+
let source = ALL_ITEMS && ALL_ITEMS.length ? ALL_ITEMS : (await fetchJSON('/api/random?n=200&k=800&scope=filtered')).items || [];
|
| 1041 |
+
const best = source.slice().sort(compareQuality).filter(isComplete).slice(0, 20);
|
| 1042 |
+
best.forEach(it => {
|
| 1043 |
+
const id = String(it.case_id || it['PanTS ID'] || it.id || ''); const sex = it.sex || '—';
|
| 1044 |
+
const age = (Number.isFinite(it.age) ? `${it.age}y` : '—'); const tumor = (it.tumor === 1 ? 'Tumor' : (it.tumor === 0 ? 'No tumor' : '—'));
|
| 1045 |
+
const card = document.createElement('article'); card.className = 'recCard';
|
| 1046 |
+
card.innerHTML = `<img class="recThumb" src="${svg('Preview 2D')}"/><div class="recBody"><div style="font-weight:900">${id}</div><div class="recMeta">Sex ${sex} · Age ${age} · ${tumor}</div><button class="btn">Open Viewer</button></div>`;
|
| 1047 |
+
card.querySelector('.btn').addEventListener('click', () => openViewer(id));
|
| 1048 |
+
bar.appendChild(card);
|
| 1049 |
+
});
|
| 1050 |
+
const scroller = bar, step = 340;
|
| 1051 |
+
function atEnd() { return scroller.scrollLeft + scroller.clientWidth >= scroller.scrollWidth - 2; }
|
| 1052 |
+
function tick() { if (atEnd()) scroller.scrollTo({ left: 0, behavior: 'smooth' }); else scroller.scrollBy({ left: step, behavior: 'smooth' }); }
|
| 1053 |
+
$('#recPrev').onclick = () => scroller.scrollBy({ left: -step, behavior: 'smooth' });
|
| 1054 |
+
$('#recNext').onclick = () => scroller.scrollBy({ left: +step, behavior: 'smooth' });
|
| 1055 |
+
$('#recPlay').onclick = () => { recPlaying = !recPlaying; $('#recPlay').textContent = recPlaying ? '⏸' : '▶'; if (recPlaying) startAuto(); else stopAuto(); };
|
| 1056 |
+
function startAuto() { stopAuto(); recTimer = setInterval(tick, 2600); }
|
| 1057 |
+
function stopAuto() { if (recTimer) { clearInterval(recTimer); recTimer = null; } }
|
| 1058 |
+
startAuto();
|
| 1059 |
+
} catch (e) { console.warn(e); }
|
| 1060 |
+
}
|
| 1061 |
+
|
| 1062 |
+
/* Lists / facets */
|
| 1063 |
+
const SPLIT_RE = /[;,|/、,·・]+/;
|
| 1064 |
+
const NAT_MAP = { 'USA': 'US', 'U.S.': 'US', 'U S A': 'US', 'GB': 'UK', 'U.K.': 'UK', 'N/A': 'NA', 'NULL': 'NA' };
|
| 1065 |
+
const normToken = s => (s ?? '').toString().trim().toUpperCase();
|
| 1066 |
+
const mapNat = code => NAT_MAP[normToken(code)] ?? normToken(code);
|
| 1067 |
+
const splitTokens = (raw, mapper = x => x) => String(raw ?? '').split(SPLIT_RE).map(t => mapper(normToken(t))).filter(Boolean);
|
| 1068 |
+
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 ''; }
|
| 1069 |
+
|
| 1070 |
+
function buildFacetList(container, key, rows, hasLabel = false) {
|
| 1071 |
+
const box = $('#' + container); box.innerHTML = '';
|
| 1072 |
+
rows.forEach(r => {
|
| 1073 |
+
const text = hasLabel ? (r.label || r.value) : r.value;
|
| 1074 |
+
const d = document.createElement('div'); d.className = 'optRow'; d.dataset.k = key; d.dataset.v = String(r.value);
|
| 1075 |
+
d.innerHTML = `<label><input type="checkbox" data-k="${key}" value="${r.value}"> ${text}</label> <span class="count">${r.count ?? 0}</span>`;
|
| 1076 |
+
box.appendChild(d);
|
| 1077 |
+
});
|
| 1078 |
+
}
|
| 1079 |
+
|
| 1080 |
+
function updateFacetCounts(countPayload = {}) {
|
| 1081 |
+
const containers = [['ct_phase_opts', 'ct_phase'], ['manufacturer_opts', 'manufacturer'], ['model_opts', 'model'], ['type_opts', 'study_type'], ['nat_opts', 'site_nat'], ['year_opts', 'year']];
|
| 1082 |
+
containers.forEach(([containerId, key]) => {
|
| 1083 |
+
const rows = $$('#' + containerId + ' .optRow'); const mp = Object.create(null);
|
| 1084 |
+
(countPayload[key] || []).forEach(r => { mp[String(r.value)] = r.count || 0; });
|
| 1085 |
+
rows.forEach(row => {
|
| 1086 |
+
const input = row.querySelector('input');
|
| 1087 |
+
const val = (row.dataset.v ?? (input ? input.value : '') ?? '').toString();
|
| 1088 |
+
const c = Object.prototype.hasOwnProperty.call(mp, val) ? mp[val] : 0;
|
| 1089 |
+
const badge = row.querySelector('.count'); if (badge) badge.textContent = c;
|
| 1090 |
+
row.classList.toggle('zero', c === 0);
|
| 1091 |
+
});
|
| 1092 |
+
});
|
| 1093 |
+
}
|
| 1094 |
+
|
| 1095 |
+
function qsFromState() {
|
| 1096 |
+
const p = new URLSearchParams();
|
| 1097 |
+
if (state.q) p.set('caseid', state.q);
|
| 1098 |
+
if (state.sex) p.set('sex', state.sex);
|
| 1099 |
+
if (state.tumor !== '') p.set('tumor', state.tumor);
|
| 1100 |
+
if (state.age_from) p.set('age_from', state.age_from);
|
| 1101 |
+
if (state.age_to) p.set('age_to', state.age_to);
|
| 1102 |
+
['ct_phase', 'manufacturer', 'model', 'study_type', 'site_nat', 'year'].forEach(k => {
|
| 1103 |
+
(state[k] || []).forEach(v => p.append(k + '[]', v));
|
| 1104 |
+
});
|
| 1105 |
+
return p.toString();
|
| 1106 |
+
}
|
| 1107 |
+
|
| 1108 |
+
async function refreshFacets() {
|
| 1109 |
+
const qs = qsFromState();
|
| 1110 |
+
const f = await fetchJSON('/api/facets?fields=ct_phase,manufacturer,model,study_type,site_nat,year&top_k=999&guarantee=0&' + qs);
|
| 1111 |
+
updateFacetCounts(f?.facets || {});
|
| 1112 |
+
}
|
| 1113 |
+
|
| 1114 |
+
async function bootstrapLists() {
|
| 1115 |
+
const data = await fetchJSON('/api/search?per_page=1000000&sort_by=id');
|
| 1116 |
+
ALL_ITEMS = data.items || [];
|
| 1117 |
+
|
| 1118 |
+
// Age chips
|
| 1119 |
+
const cnt = {};
|
| 1120 |
+
for (const it of ALL_ITEMS) { const a = Number(it.age); if (Number.isFinite(a) && a > 0) { const lo = Math.floor(a / 10) * 10; const k = `${lo}-${lo + 9}`; cnt[k] = (cnt[k] || 0) + 1; } }
|
| 1121 |
+
const bins = Object.entries(cnt).sort((a, b) => b[1] - a[1]).map(([k]) => k).slice(0, 6);
|
| 1122 |
+
renderAgeChips(bins.length ? bins : ['50-59', '60-69', '40-49', '70-79', '30-39', '80-89']);
|
| 1123 |
+
|
| 1124 |
+
// fetch facets
|
| 1125 |
+
let f0 = { facets: {} };
|
| 1126 |
+
try { f0 = await fetchJSON('/api/facets?fields=ct_phase,manufacturer,model,study_type,site_nat,year&top_k=999&guarantee=1'); }
|
| 1127 |
+
catch (e) { console.warn('facets fetch failed:', e); }
|
| 1128 |
+
const fx = f0?.facets || {};
|
| 1129 |
+
|
| 1130 |
+
// fallbacks
|
| 1131 |
+
if (!Array.isArray(fx.model) || fx.model.length === 0) {
|
| 1132 |
+
const modelsMap = {};
|
| 1133 |
+
for (const it of ALL_ITEMS) {
|
| 1134 |
+
const raw = pickField(it, ['manufacturer model', 'Manufacturer model', 'model', 'Model']);
|
| 1135 |
+
const norm = String(raw || '').trim(); if (!norm) continue; const key = norm.toLowerCase();
|
| 1136 |
+
if (!modelsMap[key]) modelsMap[key] = { value: norm, label: norm, count: 0 }; modelsMap[key].count++;
|
| 1137 |
+
}
|
| 1138 |
+
fx.model = Object.values(modelsMap).sort((a, b) => b.count - a.count);
|
| 1139 |
+
}
|
| 1140 |
+
if (!Array.isArray(fx.study_type) || fx.study_type.length === 0) {
|
| 1141 |
+
const typeCounts = {};
|
| 1142 |
+
for (const it of ALL_ITEMS) { const tokens = splitTokens(pickField(it, ['study type', 'Study type', 'study_type', 'type', 'Type'])); for (const t of tokens) if (t) typeCounts[t] = (typeCounts[t] || 0) + 1; }
|
| 1143 |
+
fx.study_type = Object.entries(typeCounts).sort((a, b) => b[1] - a[1]).map(([value, count]) => ({ value, count }));
|
| 1144 |
+
}
|
| 1145 |
+
if (!Array.isArray(fx.site_nat) || fx.site_nat.length === 0) {
|
| 1146 |
+
const natCounts = {};
|
| 1147 |
+
for (const it of ALL_ITEMS) {
|
| 1148 |
+
const tokens = splitTokens(pickField(it, ['site nationality', 'Site nationality', 'site_nat', 'nationality', 'country', 'Country']), mapNat);
|
| 1149 |
+
for (const t of tokens) if (t) natCounts[t] = (natCounts[t] || 0) + 1;
|
| 1150 |
+
}
|
| 1151 |
+
fx.site_nat = Object.entries(natCounts).sort((a, b) => b[1] - a[1]).map(([value, count]) => ({ value, count, label: value }));
|
| 1152 |
+
}
|
| 1153 |
+
|
| 1154 |
+
// build lists
|
| 1155 |
+
try {
|
| 1156 |
+
if (document.getElementById('ct_phase_opts')) buildFacetList('ct_phase_opts', 'ct_phase', fx.ct_phase || []);
|
| 1157 |
+
if (document.getElementById('manufacturer_opts')) buildFacetList('manufacturer_opts', 'manufacturer', fx.manufacturer || []);
|
| 1158 |
+
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);
|
| 1159 |
+
if (document.getElementById('type_opts')) buildFacetList('type_opts', 'study_type', fx.study_type || []);
|
| 1160 |
+
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);
|
| 1161 |
+
if (document.getElementById('year_opts')) buildFacetList('year_opts', 'year', (fx.year || []).map(r => ({ value: String(r.value), count: r.count })));
|
| 1162 |
+
await refreshFacets();
|
| 1163 |
+
} catch (e) { console.warn('build facet list failed:', e); }
|
| 1164 |
+
|
| 1165 |
+
// ID suggestions
|
| 1166 |
+
const ids = [...ALL_ITEMS].sort(compareQuality).filter(isComplete).map(x => String(x.case_id || x['PanTS ID'] || x.id)).filter(Boolean);
|
| 1167 |
+
const uniq = []; for (const id of ids) { if (!uniq.includes(id)) { uniq.unshift(id); if (uniq.length >= 5) break; } } renderIdReco(uniq);
|
| 1168 |
+
|
| 1169 |
+
// filter change
|
| 1170 |
+
const filtersEl = document.getElementById('filters');
|
| 1171 |
+
if (filtersEl) {
|
| 1172 |
+
filtersEl.addEventListener('change', async e => {
|
| 1173 |
+
const cb = e.target && e.target.closest && e.target.closest('input[type=checkbox]'); if (!cb) return;
|
| 1174 |
+
const key = cb.dataset.k; const isAny = (cb.value === '');
|
| 1175 |
+
if (isAny) { $$('#filters input[type=checkbox][data-k="' + key + '"]').forEach(x => x.checked = (x === cb)); }
|
| 1176 |
+
else { const any = $$('#filters input[type=checkbox][data-k="' + key + '"][value=""]')[0]; if (any) any.checked = false; }
|
| 1177 |
+
collectAdvanced();
|
| 1178 |
+
await refreshFacets();
|
| 1179 |
+
if (HAS_SEARCHED) run();
|
| 1180 |
+
});
|
| 1181 |
+
}
|
| 1182 |
+
|
| 1183 |
+
// show more/less
|
| 1184 |
+
$$('.showMore').forEach(btn => {
|
| 1185 |
+
const id = btn.dataset.target; const limit = Number(btn.dataset.limit || 12);
|
| 1186 |
+
const rows = $$('#' + id + ' .optRow');
|
| 1187 |
+
rows.forEach((r, i) => r.style.display = i < limit ? 'flex' : 'none');
|
| 1188 |
+
if (rows.length <= limit) btn.style.display = 'none';
|
| 1189 |
+
btn.textContent = 'Show more';
|
| 1190 |
+
btn.addEventListener('click', () => {
|
| 1191 |
+
const isMore = btn.textContent.trim().toLowerCase().startsWith('show more');
|
| 1192 |
+
rows.forEach((r, i) => { if (i >= limit) r.style.display = isMore ? 'flex' : 'none'; });
|
| 1193 |
+
btn.textContent = isMore ? 'Show less' : 'Show more';
|
| 1194 |
+
});
|
| 1195 |
+
});
|
| 1196 |
+
}
|
| 1197 |
+
|
| 1198 |
+
/* Results render */
|
| 1199 |
+
const cardsEl = $('#cards'); let rendered = 0; const BATCH = 60; let current = [];
|
| 1200 |
+
function sorter(items) {
|
| 1201 |
+
const s = state.sort_by || 'quality';
|
| 1202 |
+
if (s === 'spacing_asc') return items.slice().sort((a, b) => (a.spacing_sum ?? 1e9) - (b.spacing_sum ?? 1e9));
|
| 1203 |
+
if (s === 'shape_desc') return items.slice().sort((a, b) => (b.shape_sum ?? -1) - (a.shape_sum ?? -1));
|
| 1204 |
+
if (s === 'age_asc') return items.slice().sort((a, b) => (a.age ?? 1e9) - (b.age ?? 1e9));
|
| 1205 |
+
if (s === 'age_desc') return items.slice().sort((a, b) => (b.age ?? -1) - (a.age ?? -1));
|
| 1206 |
+
if (s === 'id_asc') return items.slice().sort((a, b) => idNum(a) - idNum(b));
|
| 1207 |
+
if (s === 'id_desc') return items.slice().sort((a, b) => idNum(b) - idNum(a));
|
| 1208 |
+
return items.slice().sort(compareQuality);
|
| 1209 |
+
}
|
| 1210 |
+
function renderAfterFetch(items) { current = sorter(items); rendered = 0; cardsEl.replaceChildren(); renderMore(); }
|
| 1211 |
+
function makeCard(it) {
|
| 1212 |
+
const id = String(it.case_id || it['PanTS ID'] || it.id || ''); const sex = it.sex || '—'; const age = Number.isFinite(it.age) ? `${it.age}y` : '—';
|
| 1213 |
+
const tumor = (it.tumor === 1 ? 'Tumor' : (it.tumor === 0 ? 'No tumor' : '—'));
|
| 1214 |
+
const wrap = document.createElement('article'); wrap.className = 'card';
|
| 1215 |
+
wrap.innerHTML = `<img class="thumb" src="${svg('2D Image')}"/><div class="body">
|
| 1216 |
+
<div class="titleRow"><a href="javascript:void(0)" class="caseLink" data-id="${id}">${id.replace(/^Case\\s*/, '')}</a></div>
|
| 1217 |
+
<div class="keyRow">
|
| 1218 |
+
<span class="kv"><span class="k">Sex</span><span class="v">${sex}</span></span>
|
| 1219 |
+
<span class="kv"><span class="k">Age</span><span class="v">${age}</span></span>
|
| 1220 |
+
<span class="kv"><span class="tag ${tumor === 'No tumor' ? 'ok' : 'bad'}">${tumor}</span></span>
|
| 1221 |
+
</div>
|
| 1222 |
+
<button class="btn">Open Viewer</button></div>`;
|
| 1223 |
+
const open = () => { saveRecent(id); openViewer(id); };
|
| 1224 |
+
wrap.querySelector('.btn').addEventListener('click', open);
|
| 1225 |
+
wrap.querySelector('.caseLink').addEventListener('click', open);
|
| 1226 |
+
return wrap;
|
| 1227 |
+
}
|
| 1228 |
+
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; }
|
| 1229 |
+
window.addEventListener('scroll', () => { const near = (window.innerHeight + window.scrollY) > (document.body.offsetHeight - 800); if (near) renderMore(); });
|
| 1230 |
+
|
| 1231 |
+
/* Search run */
|
| 1232 |
+
async function run() {
|
| 1233 |
+
await refreshFacets();
|
| 1234 |
+
const p = new URLSearchParams(qsFromState()); p.set('per_page', '1000000');
|
| 1235 |
+
const data = await fetchJSON('/api/search?' + p.toString()); lastFetched = (data.items || []).filter(Boolean);
|
| 1236 |
+
|
| 1237 |
+
const norm = s => String(s ?? '').trim().toLowerCase();
|
| 1238 |
+
lastFetched = lastFetched.filter(it => {
|
| 1239 |
+
const hasAll = (selected, value) => selected.length === 0 || selected.some(v => norm(v) === norm(value));
|
| 1240 |
+
const overlap = (selected, tokens) => { if (selected.length === 0) return true; const sel = selected.map(norm); return tokens.some(t => sel.includes(norm(t))); };
|
| 1241 |
+
const model = pickField(it, ['manufacturer model', 'Manufacturer model', 'model', 'Model']);
|
| 1242 |
+
if (!hasAll(state.model, model)) return false;
|
| 1243 |
+
const types = splitTokens(pickField(it, ['study type', 'Study type', 'study_type', 'type', 'Type']));
|
| 1244 |
+
const nats = splitTokens(pickField(it, ['site nationality', 'Site nationality', 'site_nat', 'nationality', 'country', 'Country']), mapNat);
|
| 1245 |
+
const year = pickField(it, ['study year', 'Study year', 'year', 'study_year', 'Year']);
|
| 1246 |
+
return overlap(state.study_type, types) && overlap(state.site_nat, nats) && hasAll(state.year, year);
|
| 1247 |
+
});
|
| 1248 |
+
|
| 1249 |
+
updatePanels(); renderAfterFetch(lastFetched);
|
| 1250 |
+
}
|
| 1251 |
+
|
| 1252 |
+
/* STA / Recent */
|
| 1253 |
+
function renderAgeChips(list) {
|
| 1254 |
+
const w = $('#ageChips'); w.innerHTML = '';
|
| 1255 |
+
const anyBtn = document.createElement('button'); anyBtn.className = 'chip active'; anyBtn.id = 'ageAny'; anyBtn.textContent = 'Any age';
|
| 1256 |
+
anyBtn.addEventListener('click', () => { state.age_from = ''; state.age_to = ''; $$('#ageChips .chip').forEach(x => x.classList.remove('active')); anyBtn.classList.add('active'); updateSTASummary(); });
|
| 1257 |
+
w.appendChild(anyBtn);
|
| 1258 |
+
list.forEach(bin => {
|
| 1259 |
+
const [a, b] = bin.split('-').map(Number);
|
| 1260 |
+
const btn = document.createElement('button'); btn.className = 'chip'; btn.textContent = bin;
|
| 1261 |
+
btn.addEventListener('click', () => { state.age_from = a; state.age_to = b; $$('#ageChips .chip').forEach(x => x.classList.remove('active')); btn.classList.add('active'); updateSTASummary(); });
|
| 1262 |
+
w.appendChild(btn);
|
| 1263 |
+
});
|
| 1264 |
+
}
|
| 1265 |
+
|
| 1266 |
+
function renderIdReco(ids) {
|
| 1267 |
+
const w = $('#idReco'); w.innerHTML = '';
|
| 1268 |
+
ids.forEach(id => {
|
| 1269 |
+
const b = document.createElement('button'); b.className = 'chip'; b.textContent = id;
|
| 1270 |
+
b.addEventListener('click', () => { $('#q').value = id; state.q = id; HAS_SEARCHED = true; run(); $('#popID').classList.remove('open'); });
|
| 1271 |
+
w.appendChild(b);
|
| 1272 |
+
});
|
| 1273 |
+
}
|
| 1274 |
+
function updateSTASummary() {
|
| 1275 |
+
const tumor = state.tumor === '' ? 'Any tumor' : (state.tumor === '1' ? 'Tumor' : 'No tumor');
|
| 1276 |
+
const sex = state.sex ? (state.sex === 'M' ? 'Male' : 'Female') : 'Any sex';
|
| 1277 |
+
const age = (state.age_from && state.age_to) ? `${state.age_from}-${state.age_to}` : 'Any age';
|
| 1278 |
+
$('#staSummary').textContent = `${tumor} · ${sex} �� ${age}`;
|
| 1279 |
+
}
|
| 1280 |
+
$('#sexChips').addEventListener('click', e => { const b = e.target.closest('.chip'); if (!b) return; state.sex = b.dataset.sex ?? ''; $$('#sexChips .chip').forEach(x => x.classList.toggle('active', (x.dataset.sex ?? '') === state.sex)); updateSTASummary(); });
|
| 1281 |
+
$('#tumorChips').addEventListener('click', e => { const b = e.target.closest('.chip'); if (!b) return; state.tumor = b.dataset.tumor ?? ''; $$('#tumorChips .chip').forEach(x => x.classList.toggle('active', (x.dataset.tumor ?? '') === state.tumor)); updateSTASummary(); });
|
| 1282 |
+
$('#applySTA').addEventListener('click', () => { $('#popSTA').classList.remove('open'); HAS_SEARCHED = true; run(); });
|
| 1283 |
+
$('#searchBtn').addEventListener('click', () => { HAS_SEARCHED = true; run(); });
|
| 1284 |
+
$('#q').addEventListener('input', e => state.q = e.target.value.trim());
|
| 1285 |
+
$('#q').addEventListener('keydown', e => { if (e.key === 'Enter') { HAS_SEARCHED = true; run(); } });
|
| 1286 |
+
|
| 1287 |
+
const sortSel = $('#sortBy'); sortSel?.addEventListener('change', () => { state.sort_by = sortSel.value; renderAfterFetch(lastFetched); });
|
| 1288 |
+
|
| 1289 |
+
function renderRecent() {
|
| 1290 |
+
const box = $('#idRecent'); const r = JSON.parse(localStorage.getItem('recentIds') || '[]');
|
| 1291 |
+
box.innerHTML = ''; if (!r.length) { box.innerHTML = '<span class="recMeta">No recent</span>'; return; }
|
| 1292 |
+
r.forEach(id => {
|
| 1293 |
+
const b = document.createElement('button'); b.className = 'chip'; b.textContent = id;
|
| 1294 |
+
b.addEventListener('click', () => { $('#q').value = id; state.q = id; HAS_SEARCHED = true; run(); $('#popID').classList.remove('open'); });
|
| 1295 |
+
box.appendChild(b);
|
| 1296 |
+
});
|
| 1297 |
+
}
|
| 1298 |
+
function saveRecent(id) { const r = JSON.parse(localStorage.getItem('recentIds') || '[]'); if (!r.includes(id)) { r.unshift(id); if (r.length > 6) r.pop(); localStorage.setItem('recentIds', JSON.stringify(r)); renderRecent(); } }
|
| 1299 |
+
function collectAdvanced() { ['ct_phase', 'manufacturer', 'model', 'study_type', 'site_nat', 'year'].forEach(k => { state[k] = [].filter.call($$(`#filters input[type=checkbox][data-k="${k}"]`), c => c.checked && c.value !== '').map(c => c.value); }); }
|
| 1300 |
+
|
| 1301 |
+
/* Viewer */
|
| 1302 |
+
function showPreparing(on) { $('#prepPage').classList.toggle('show', !!on); }
|
| 1303 |
+
function openViewer(id) {
|
| 1304 |
+
showPreparing(true); document.body.style.overflow = 'hidden'; const v = $('#viewer');
|
| 1305 |
+
v.classList.add('compact'); $('#vCard').style.display = 'none'; $('#vSidebar').style.display = 'none';
|
| 1306 |
+
$('#caseTitle').textContent = 'Case ID: ' + id;
|
| 1307 |
+
$('#axial').src = vsvg('2D Axial'); $('#sagittal').src = vsvg('2D Sagittal'); $('#coronal').src = vsvg('2D Coronal');
|
| 1308 |
+
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);
|
| 1309 |
+
}
|
| 1310 |
+
function closeViewer() { $('#viewer').classList.remove('show'); document.body.style.overflow = 'auto'; const u = new URL(location.href); u.searchParams.delete('case'); history.replaceState({}, '', u); }
|
| 1311 |
+
$('#btnBack').addEventListener('click', closeViewer);
|
| 1312 |
+
|
| 1313 |
+
const CLASS_MAP = [
|
| 1314 |
+
{ name: 'Vascular System', key: 'vascular', items: [['aorta'], ['celiac_artery'], ['superior_mesenteric_artery'], ['postcava'], ['veins']] },
|
| 1315 |
+
{ name: 'Digestive System', key: 'digestive', items: [['Pancreas'], ['colon'], ['duodenum'], ['stomach'], ['liver'], ['common_bile_duct'], ['gall_bladder']] },
|
| 1316 |
+
{ name: 'Endocrine System', key: 'endocrine', items: [['adrenal_gland_left'], ['adrenal_gland_right']] },
|
| 1317 |
+
{ name: 'Urinary System', key: 'urinary', items: [['Kidneys'], ['bladder']] },
|
| 1318 |
+
{ name: 'Skeletal System', key: 'skeletal', items: [['femur_left'], ['femur_right']] },
|
| 1319 |
+
{ name: 'Lymphatic System', key: 'lymphatic', items: [['spleen']] },
|
| 1320 |
+
{ name: 'Reproductive System', key: 'reproductive', items: [['prostate']] },
|
| 1321 |
+
{ name: 'Respiratory System', key: 'respiratory', items: [['lung_left'], ['lung_right']] }
|
| 1322 |
+
];
|
| 1323 |
+
function renderClassMap() {
|
| 1324 |
+
const root = document.getElementById('cmRoot'); if (!root) return; root.innerHTML = '';
|
| 1325 |
+
CLASS_MAP.forEach(grp => {
|
| 1326 |
+
const box = document.createElement('section'); box.className = 'cm-group';
|
| 1327 |
+
const row = document.createElement('div'); row.className = 'cm-row';
|
| 1328 |
+
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}">`;
|
| 1329 |
+
const items = document.createElement('div'); items.className = 'cm-items';
|
| 1330 |
+
grp.items.forEach(([label]) => {
|
| 1331 |
+
const id = `cm_${grp.key}_${label}`.replace(/\W+/g, '_').toLowerCase();
|
| 1332 |
+
const r = document.createElement('label'); r.className = 'cm-item';
|
| 1333 |
+
r.innerHTML = `<input type="checkbox" class="itemCheck" id="${id}" data-group="${grp.key}" data-name="${label}" checked><span class="name">${label.replace(/_/g, ' ')}</span>`;
|
| 1334 |
+
items.appendChild(r);
|
| 1335 |
+
});
|
| 1336 |
+
// fold/unfold
|
| 1337 |
+
let open = true; const chev = row.querySelector('.chev');
|
| 1338 |
+
const setOpen = (on) => { open = on; box.classList.toggle('closed', !on); chev.textContent = on ? '▾' : '▸'; };
|
| 1339 |
+
row.addEventListener('click', (e) => { const t = e.target; if (t && t.classList && t.classList.contains('grpCheck')) return; setOpen(!open); });
|
| 1340 |
+
setOpen(true);
|
| 1341 |
+
// group check
|
| 1342 |
+
const grpCheck = row.querySelector('input.grpCheck');
|
| 1343 |
+
if (grpCheck) { grpCheck.addEventListener('change', () => { const checked = grpCheck.checked; items.querySelectorAll('input.itemCheck').forEach(c => { c.checked = checked; }); }); }
|
| 1344 |
+
box.appendChild(row); box.appendChild(items); root.appendChild(box);
|
| 1345 |
+
});
|
| 1346 |
+
const btn = document.getElementById('toggleAll');
|
| 1347 |
+
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); }; }
|
| 1348 |
+
}
|
| 1349 |
+
|
| 1350 |
+
// settings / classmap toggle
|
| 1351 |
+
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'; });
|
| 1352 |
+
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'; } });
|
| 1353 |
+
['op', 'lvl', 'win'].forEach(id => { const r = $('#' + id), lab = $('#' + id + 'v'); r?.addEventListener('input', () => { if (lab) lab.textContent = r.value; }); });
|
| 1354 |
+
$('#zoomIn').innerHTML = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M11 4v7H4v2h7v7h2v-7h7v-2h-7V4z"/></svg>';
|
| 1355 |
+
$('#zoomOut').innerHTML = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 11v2h16v-2z"/></svg>';
|
| 1356 |
+
$('#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>';
|
| 1357 |
+
$('#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>';
|
| 1358 |
+
let Z = 1; function applyZoom() { $$('.v-view img').forEach(img => { img.style.transformOrigin = 'center center'; img.style.transform = `scale(${Z})`; }); }
|
| 1359 |
+
$('#zoomIn')?.addEventListener('click', () => { Z = Math.min(3, Z + 0.2); applyZoom(); });
|
| 1360 |
+
$('#zoomOut')?.addEventListener('click', () => { Z = Math.max(1, Z - 0.2); applyZoom(); });
|
| 1361 |
+
const rpModal = $('#reportModal');
|
| 1362 |
+
$('#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(); }); });
|
| 1363 |
+
$('#report')?.addEventListener('click', () => rpModal?.classList.add('show'));
|
| 1364 |
+
$('#rpClose')?.addEventListener('click', () => rpModal?.classList.remove('show'));
|
| 1365 |
+
rpModal?.addEventListener('click', e => { if (e.target === rpModal) rpModal.classList.remove('show'); });
|
| 1366 |
+
|
| 1367 |
+
// deeplink
|
| 1368 |
+
(() => { const u = new URL(location.href); const id = u.searchParams.get('case'); if (id) openViewer(id); })();
|
| 1369 |
+
|
| 1370 |
+
// init
|
| 1371 |
+
(async function init() {
|
| 1372 |
+
renderRecent();
|
| 1373 |
+
await bootstrapLists();
|
| 1374 |
+
await initBrowse();
|
| 1375 |
+
$$('#sexChips .chip').forEach(x => x.classList.toggle('active', (x.dataset.sex ?? '') === ''));
|
| 1376 |
+
$$('#tumorChips .chip').forEach(x => x.classList.toggle('active', (x.dataset.tumor ?? '') === ''));
|
| 1377 |
+
updateSTASummary();
|
| 1378 |
+
HAS_SEARCHED = false; lastFetched = []; updatePanels();
|
| 1379 |
+
})();
|
| 1380 |
+
|
| 1381 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 1382 |
+
const resultsPanel = document.getElementById('resultsPanel'); if (resultsPanel) resultsPanel.style.display = 'none';
|
| 1383 |
+
const head = document.querySelector('.resultsHead'); if (head) head.style.display = 'none';
|
| 1384 |
+
const cards = document.getElementById('cards'); if (cards) cards.style.display = 'none';
|
| 1385 |
+
try { updatePanels(); } catch { }
|
| 1386 |
+
});
|
| 1387 |
+
</script>
|
| 1388 |
+
</body>
|
| 1389 |
+
|
| 1390 |
+
</html>
|
metadata.xlsx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ce243b44994809954c225cd6b0e34c3f3860a037df354bf51a14f389caa6b3c5
|
| 3 |
+
size 657993
|
nApp.py
ADDED
|
@@ -0,0 +1,731 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
CT Finder - Booking-style backend (complete)
|
| 4 |
+
--------------------------------------------
|
| 5 |
+
Endpoints
|
| 6 |
+
- GET /api/search
|
| 7 |
+
Query params:
|
| 8 |
+
q, caseid, sex, tumor, age_from, age_to,
|
| 9 |
+
ct_phase, manufacturer, study_type, site_nationality (or site_nat),
|
| 10 |
+
model (or model[] / manufacturer_model),
|
| 11 |
+
sort_by = top|shape_desc|spacing_asc|age_asc|age_desc|id|shape|spacing
|
| 12 |
+
sort_dir = asc|desc
|
| 13 |
+
per_page (default 24), page (default 1)
|
| 14 |
+
|
| 15 |
+
- GET /api/facets
|
| 16 |
+
fields=ct_phase,manufacturer,year,sex,tumor (subset)
|
| 17 |
+
top_k=6, guarantee=0|1
|
| 18 |
+
|
| 19 |
+
- GET /api/random
|
| 20 |
+
n=3, k=100, offset=?, recent=csv, scope=filtered|all
|
| 21 |
+
|
| 22 |
+
- GET /api/health
|
| 23 |
+
- GET / (若 --index 指向 HTML 就送檔,否則返回字串)
|
| 24 |
+
|
| 25 |
+
Run:
|
| 26 |
+
python nApp.py --meta /path/to/metadata.xlsx --index /path/to/index.html
|
| 27 |
+
"""
|
| 28 |
+
import os, re, math, argparse
|
| 29 |
+
from typing import Any, Dict, Optional, Set, List, Tuple
|
| 30 |
+
from datetime import datetime
|
| 31 |
+
|
| 32 |
+
import numpy as np
|
| 33 |
+
import pandas as pd
|
| 34 |
+
from flask import Flask, jsonify, request, make_response, send_file
|
| 35 |
+
from flask_cors import CORS
|
| 36 |
+
|
| 37 |
+
# ---------------------------
|
| 38 |
+
# CLI
|
| 39 |
+
# ---------------------------
|
| 40 |
+
parser = argparse.ArgumentParser()
|
| 41 |
+
parser.add_argument("--meta", required=True, help="Path to metadata.xlsx")
|
| 42 |
+
parser.add_argument("--index", default="", help="Path to index.html (optional)")
|
| 43 |
+
parser.add_argument("--host", default="0.0.0.0")
|
| 44 |
+
parser.add_argument("--port", default=8888, type=int)
|
| 45 |
+
|
| 46 |
+
args, _ = parser.parse_known_args()
|
| 47 |
+
META_FILE = args.meta
|
| 48 |
+
INDEX_FILE = args.index
|
| 49 |
+
|
| 50 |
+
app = Flask(__name__)
|
| 51 |
+
CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=False)
|
| 52 |
+
|
| 53 |
+
# ---------------------------
|
| 54 |
+
# Helpers
|
| 55 |
+
# ---------------------------
|
| 56 |
+
def _arg(name: str, default=None):
|
| 57 |
+
return request.args.get(name, default)
|
| 58 |
+
|
| 59 |
+
def _to_int(x) -> Optional[int]:
|
| 60 |
+
try:
|
| 61 |
+
return int(x)
|
| 62 |
+
except Exception:
|
| 63 |
+
return None
|
| 64 |
+
|
| 65 |
+
def _to_float(x) -> Optional[float]:
|
| 66 |
+
try:
|
| 67 |
+
return float(x)
|
| 68 |
+
except Exception:
|
| 69 |
+
return None
|
| 70 |
+
|
| 71 |
+
def _to01_query(x) -> Optional[int]:
|
| 72 |
+
if x is None: return None
|
| 73 |
+
s = str(x).strip().lower()
|
| 74 |
+
if s in ("1","true","yes","y"): return 1
|
| 75 |
+
if s in ("0","false","no","n"): return 0
|
| 76 |
+
return None
|
| 77 |
+
|
| 78 |
+
def _collect_list_params(names: List[str]) -> List[str]:
|
| 79 |
+
out: List[str] = []
|
| 80 |
+
for n in names:
|
| 81 |
+
if n in request.args:
|
| 82 |
+
out += request.args.getlist(n)
|
| 83 |
+
tmp: List[str] = []
|
| 84 |
+
for s in out:
|
| 85 |
+
if "," in s:
|
| 86 |
+
tmp += [t.strip() for t in s.split(",") if t.strip()]
|
| 87 |
+
else:
|
| 88 |
+
tmp.append(s.strip())
|
| 89 |
+
return [t for t in tmp if t]
|
| 90 |
+
|
| 91 |
+
def _nan2none(v):
|
| 92 |
+
try:
|
| 93 |
+
if v is None: return None
|
| 94 |
+
if pd.isna(v): return None
|
| 95 |
+
except Exception:
|
| 96 |
+
pass
|
| 97 |
+
return v
|
| 98 |
+
|
| 99 |
+
def _clean_json_list(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 100 |
+
def _clean(v):
|
| 101 |
+
if isinstance(v, (np.integer,)): return int(v)
|
| 102 |
+
if isinstance(v, (np.floating,)): return float(v)
|
| 103 |
+
if isinstance(v, (np.bool_,)): return bool(v)
|
| 104 |
+
return v
|
| 105 |
+
return [{k: _clean(v) for k, v in d.items()} for d in items]
|
| 106 |
+
|
| 107 |
+
# ---------------------------
|
| 108 |
+
# Load & normalize
|
| 109 |
+
# ---------------------------
|
| 110 |
+
def _norm_cols(df_raw: pd.DataFrame) -> pd.DataFrame:
|
| 111 |
+
"""標準化欄位,產出搜尋/排序需要的衍生欄位。"""
|
| 112 |
+
df = df_raw.copy()
|
| 113 |
+
|
| 114 |
+
# ---- Case ID ----
|
| 115 |
+
case_cols = ["PanTS ID", "PanTS_ID", "case_id", "id", "case", "CaseID"]
|
| 116 |
+
def _first_nonempty(row, cols):
|
| 117 |
+
for c in cols:
|
| 118 |
+
if c in row.index and pd.notna(row[c]) and str(row[c]).strip():
|
| 119 |
+
return str(row[c]).strip(), c
|
| 120 |
+
return "", None
|
| 121 |
+
|
| 122 |
+
cases, mapping = [], []
|
| 123 |
+
for _, r in df.iterrows():
|
| 124 |
+
s, c = _first_nonempty(r, case_cols)
|
| 125 |
+
cases.append(s); mapping.append({"case": c} if c else {})
|
| 126 |
+
df["__case_str"] = cases
|
| 127 |
+
df["_orig_cols"] = mapping
|
| 128 |
+
|
| 129 |
+
# ---- Tumor -> __tumor01 ----
|
| 130 |
+
def _canon(s: str) -> str: return re.sub(r"[^a-z]+", "", str(s).lower())
|
| 131 |
+
tumor_names = [c for c in df.columns if "tumor" in _canon(c)] or []
|
| 132 |
+
tcol = tumor_names[0] if tumor_names else None
|
| 133 |
+
|
| 134 |
+
def _to01_v(v):
|
| 135 |
+
if pd.isna(v): return np.nan
|
| 136 |
+
s = str(v).strip().lower()
|
| 137 |
+
if s in ("1","yes","y","true","t"): return 1
|
| 138 |
+
if s in ("0","no","n","false","f"): return 0
|
| 139 |
+
try:
|
| 140 |
+
iv = int(float(s))
|
| 141 |
+
return 1 if iv == 1 else (0 if iv == 0 else np.nan)
|
| 142 |
+
except Exception:
|
| 143 |
+
return np.nan
|
| 144 |
+
|
| 145 |
+
df["__tumor01"] = (df[tcol].map(_to01_v) if tcol else pd.Series([np.nan]*len(df), index=df.index))
|
| 146 |
+
if tcol:
|
| 147 |
+
df["_orig_cols"] = [{**(df["_orig_cols"].iat[i] or {}), "tumor": tcol} for i in range(len(df))]
|
| 148 |
+
|
| 149 |
+
# ---- Sex -> __sex ----
|
| 150 |
+
df["__sex"] = df.get("sex", pd.Series([""]*len(df))).astype(str).str.strip().str.upper()
|
| 151 |
+
df["__sex"] = df["__sex"].where(df["__sex"].isin(["F","M"]), "")
|
| 152 |
+
|
| 153 |
+
# ---- Generic column finder ----
|
| 154 |
+
def _find_col(prefer, keyword_sets=None):
|
| 155 |
+
for c in prefer:
|
| 156 |
+
if c in df.columns: return c
|
| 157 |
+
if keyword_sets:
|
| 158 |
+
canon_map = {c: re.sub(r"[^a-z0-9]+", "", str(c).lower()) for c in df.columns}
|
| 159 |
+
for c, cs in canon_map.items():
|
| 160 |
+
for ks in keyword_sets:
|
| 161 |
+
if all(k in cs for k in ks): return c
|
| 162 |
+
return None
|
| 163 |
+
|
| 164 |
+
# ---- CT phase -> __ct / __ct_lc ----
|
| 165 |
+
ct_col = _find_col(
|
| 166 |
+
prefer=["ct phase","CT phase","ct_phase","CT_phase","ct"],
|
| 167 |
+
keyword_sets=[["ct","phase"],["phase"]],
|
| 168 |
+
)
|
| 169 |
+
if ct_col:
|
| 170 |
+
df["__ct"] = df[ct_col].astype(str).str.strip()
|
| 171 |
+
df["__ct_lc"] = df["__ct"].str.lower()
|
| 172 |
+
df["_orig_cols"] = [{**(df["_orig_cols"].iat[i] or {}), "ct_phase": ct_col} for i in range(len(df))]
|
| 173 |
+
else:
|
| 174 |
+
df["__ct"], df["__ct_lc"] = "", ""
|
| 175 |
+
|
| 176 |
+
# ---- Manufacturer -> __mfr / __mfr_lc ----
|
| 177 |
+
mfr_col = _find_col(
|
| 178 |
+
prefer=["manufacturer","Manufacturer","mfr","MFR","vendor","Vendor","manufacturer name","Manufacturer Name"],
|
| 179 |
+
keyword_sets=[["manufactur"],["vendor"],["brand"],["maker"]],
|
| 180 |
+
)
|
| 181 |
+
if mfr_col:
|
| 182 |
+
df["__mfr"] = df[mfr_col].astype(str).str.strip()
|
| 183 |
+
df["__mfr_lc"] = df["__mfr"].str.lower()
|
| 184 |
+
df["_orig_cols"] = [{**(df["_orig_cols"].iat[i] or {}), "manufacturer": mfr_col} for i in range(len(df))]
|
| 185 |
+
else:
|
| 186 |
+
df["__mfr"], df["__mfr_lc"] = "", ""
|
| 187 |
+
|
| 188 |
+
# ---- Manufacturer model -> model / __model_lc ----
|
| 189 |
+
model_col = _find_col(
|
| 190 |
+
prefer=["manufacturer model","Manufacturer model","model","Model"],
|
| 191 |
+
keyword_sets=[["model"]],
|
| 192 |
+
)
|
| 193 |
+
if model_col:
|
| 194 |
+
df["model"] = df[model_col].astype(str).str.strip()
|
| 195 |
+
df["__model_lc"] = df["model"].str.lower()
|
| 196 |
+
df["_orig_cols"] = [{**(df["_orig_cols"].iat[i] or {}), "model": model_col} for i in range(len(df))]
|
| 197 |
+
else:
|
| 198 |
+
# 以免前端讀不到欄位
|
| 199 |
+
df["model"] = ""
|
| 200 |
+
df["__model_lc"] = ""
|
| 201 |
+
|
| 202 |
+
# ---- Year -> __year_int ----
|
| 203 |
+
year_col = _find_col(prefer=["study year","Study year","study_year","year","Year"], keyword_sets=[["year"]])
|
| 204 |
+
df["__year_int"] = pd.to_numeric(df[year_col], errors="coerce") if year_col else pd.Series([np.nan]*len(df), index=df.index)
|
| 205 |
+
if year_col:
|
| 206 |
+
df["_orig_cols"] = [{**(df["_orig_cols"].iat[i] or {}), "year": year_col} for i in range(len(df))]
|
| 207 |
+
|
| 208 |
+
# ---- Age -> __age ----
|
| 209 |
+
age_col = _find_col(prefer=["age","Age"], keyword_sets=[["age"]])
|
| 210 |
+
df["__age"] = pd.to_numeric(df[age_col], errors="coerce") if age_col else pd.Series([np.nan]*len(df), index=df.index)
|
| 211 |
+
if age_col:
|
| 212 |
+
df["_orig_cols"] = [{**(df["_orig_cols"].iat[i] or {}), "age": age_col} for i in range(len(df))]
|
| 213 |
+
|
| 214 |
+
# ---- Study type -> study_type / __st_lc ----
|
| 215 |
+
st_col = _find_col(
|
| 216 |
+
prefer=["study type","Study type","study_type","Study_type"],
|
| 217 |
+
keyword_sets=[["study","type"]]
|
| 218 |
+
)
|
| 219 |
+
if st_col:
|
| 220 |
+
df["study_type"] = df[st_col].astype(str)
|
| 221 |
+
df["__st_lc"] = df["study_type"].astype(str).str.strip().str.lower()
|
| 222 |
+
df["_orig_cols"] = [{**(df["_orig_cols"].iat[i] or {}), "study_type": st_col}
|
| 223 |
+
for i in range(len(df))]
|
| 224 |
+
else:
|
| 225 |
+
df["study_type"] = ""
|
| 226 |
+
df["__st_lc"] = ""
|
| 227 |
+
|
| 228 |
+
# ---- Site nationality -> site_nationality / __sn_lc ----
|
| 229 |
+
sn_col = _find_col(
|
| 230 |
+
prefer=[
|
| 231 |
+
"site nationality","Site nationality","site_nationality","Site_nationality",
|
| 232 |
+
"nationality","Nationality","site country","Site country","country","Country"
|
| 233 |
+
],
|
| 234 |
+
keyword_sets=[["site","national"], ["nationality"], ["site","country"], ["country"]]
|
| 235 |
+
)
|
| 236 |
+
if sn_col:
|
| 237 |
+
df["site_nationality"] = df[sn_col].astype(str)
|
| 238 |
+
df["__sn_lc"] = df["site_nationality"].astype(str).str.strip().str.lower()
|
| 239 |
+
df["_orig_cols"] = [{**(df["_orig_cols"].iat[i] or {}), "site_nationality": sn_col}
|
| 240 |
+
for i in range(len(df))]
|
| 241 |
+
else:
|
| 242 |
+
df["site_nationality"] = ""
|
| 243 |
+
df["__sn_lc"] = ""
|
| 244 |
+
|
| 245 |
+
return df
|
| 246 |
+
|
| 247 |
+
def _safe_float(x) -> Optional[float]:
|
| 248 |
+
try:
|
| 249 |
+
if x is None: return None
|
| 250 |
+
if isinstance(x, float) and np.isnan(x): return None
|
| 251 |
+
if isinstance(x, str):
|
| 252 |
+
s = x.strip().replace(",", " ")
|
| 253 |
+
if not s: return None
|
| 254 |
+
return float(s)
|
| 255 |
+
return float(x)
|
| 256 |
+
except Exception:
|
| 257 |
+
return None
|
| 258 |
+
|
| 259 |
+
def _take_first_str(row, cols: List[str]) -> str:
|
| 260 |
+
for c in cols:
|
| 261 |
+
if c in row and pd.notna(row[c]) and str(row[c]).strip():
|
| 262 |
+
return str(row[c]).strip()
|
| 263 |
+
return ""
|
| 264 |
+
|
| 265 |
+
def _case_key(row) -> int:
|
| 266 |
+
s = _take_first_str(row, ["PanTS ID","PanTS_ID","case_id","id","__case_str"])
|
| 267 |
+
if not s: return 0
|
| 268 |
+
m = re.search(r"(\d+)", str(s))
|
| 269 |
+
return int(m.group(1)) if m else 0
|
| 270 |
+
|
| 271 |
+
def _parse_3tuple_from_row(row, name_candidates: List[str]) -> List[Optional[float]]:
|
| 272 |
+
# 3 個獨立欄
|
| 273 |
+
for base in name_candidates:
|
| 274 |
+
cx, cy, cz = f"{base}_x", f"{base}_y", f"{base}_z"
|
| 275 |
+
if cx in row and cy in row and cz in row:
|
| 276 |
+
xs = [_safe_float(row[c]) for c in (cx, cy, cz)]
|
| 277 |
+
if all(v is not None for v in xs):
|
| 278 |
+
return xs
|
| 279 |
+
# 單欄字串
|
| 280 |
+
seps = [",", "x", " ", "×", "X", ";", "|"]
|
| 281 |
+
str_cols = []
|
| 282 |
+
for base in name_candidates:
|
| 283 |
+
str_cols += [base, f"{base}_str", base.replace(" ", "_")]
|
| 284 |
+
for c in str_cols:
|
| 285 |
+
if c in row and pd.notna(row[c]):
|
| 286 |
+
s = str(row[c]).strip()
|
| 287 |
+
if not s: continue
|
| 288 |
+
s2 = re.sub(r"[\[\]\(\)\{\}]", " ", s)
|
| 289 |
+
for sep in seps:
|
| 290 |
+
s2 = s2.replace(sep, " ")
|
| 291 |
+
parts = [p for p in s2.split() if p]
|
| 292 |
+
vals = [_safe_float(p) for p in parts[:3]]
|
| 293 |
+
if len(vals) == 3 and all(v is not None for v in vals):
|
| 294 |
+
return vals
|
| 295 |
+
return [None, None, None]
|
| 296 |
+
|
| 297 |
+
def _spacing_sum(row) -> Optional[float]:
|
| 298 |
+
vals = _parse_3tuple_from_row(row, ["spacing","voxel_spacing","voxel_size","pixel_spacing"])
|
| 299 |
+
if any(v is None for v in vals): return None
|
| 300 |
+
return float(vals[0] + vals[1] + vals[2])
|
| 301 |
+
|
| 302 |
+
def _shape_sum(row) -> Optional[float]:
|
| 303 |
+
vals = _parse_3tuple_from_row(row, ["shape","dim","size","image_shape","resolution"])
|
| 304 |
+
if any(v is None for v in vals): return None
|
| 305 |
+
return float(vals[0] + vals[1] + vals[2])
|
| 306 |
+
|
| 307 |
+
def _ensure_sort_cols(df: pd.DataFrame) -> pd.DataFrame:
|
| 308 |
+
if "__case_sortkey" not in df.columns:
|
| 309 |
+
df["__case_sortkey"] = df.apply(_case_key, axis=1)
|
| 310 |
+
if "__spacing_sum" not in df.columns:
|
| 311 |
+
df["__spacing_sum"] = df.apply(_spacing_sum, axis=1)
|
| 312 |
+
if "__shape_sum" not in df.columns:
|
| 313 |
+
df["__shape_sum"] = df.apply(_shape_sum, axis=1)
|
| 314 |
+
|
| 315 |
+
# 完整度:Browse 與 top 排序會用到
|
| 316 |
+
need_cols = ["__spacing_sum", "__shape_sum", "__sex", "__age"]
|
| 317 |
+
complete = pd.Series(True, index=df.index)
|
| 318 |
+
for c in need_cols:
|
| 319 |
+
if c not in df.columns:
|
| 320 |
+
complete &= False
|
| 321 |
+
elif c == "__sex":
|
| 322 |
+
complete &= (df[c].astype(str).str.strip() != "")
|
| 323 |
+
else:
|
| 324 |
+
complete &= df[c].notna()
|
| 325 |
+
df["__complete"] = complete
|
| 326 |
+
return df
|
| 327 |
+
|
| 328 |
+
# load meta
|
| 329 |
+
if not os.path.exists(META_FILE):
|
| 330 |
+
raise FileNotFoundError(f"metadata not found: {META_FILE}")
|
| 331 |
+
DF_RAW = pd.read_excel(META_FILE)
|
| 332 |
+
DF = _norm_cols(DF_RAW)
|
| 333 |
+
|
| 334 |
+
# ---------------------------
|
| 335 |
+
# Filters
|
| 336 |
+
# ---------------------------
|
| 337 |
+
def apply_filters(base: pd.DataFrame, exclude: Optional[Set[str]] = None) -> pd.DataFrame:
|
| 338 |
+
exclude = exclude or set()
|
| 339 |
+
df = base
|
| 340 |
+
|
| 341 |
+
# Case ID / keyword
|
| 342 |
+
q = (_arg("q") or _arg("caseid") or "").strip()
|
| 343 |
+
if q and "caseid" not in exclude:
|
| 344 |
+
if "__case_str" in df:
|
| 345 |
+
df = df[df["__case_str"].str.contains(re.escape(q), na=False)]
|
| 346 |
+
|
| 347 |
+
# Tumor
|
| 348 |
+
tv = _to01_query(_arg("tumor"))
|
| 349 |
+
tnull = _to01_query(_arg("tumor_is_null"))
|
| 350 |
+
if (_arg("tumor","").strip().lower() == "unknown"):
|
| 351 |
+
tnull, tv = 1, None
|
| 352 |
+
if "__tumor01" in df and "tumor" not in exclude:
|
| 353 |
+
if tnull in (0,1) and "tumor_is_null" not in exclude:
|
| 354 |
+
df = df[df["__tumor01"].isna()] if tnull==1 else df[df["__tumor01"].notna()]
|
| 355 |
+
elif tv in (0,1):
|
| 356 |
+
df = df[df["__tumor01"] == tv]
|
| 357 |
+
|
| 358 |
+
# Sex
|
| 359 |
+
sv = (_arg("sex","") or "").strip().upper()
|
| 360 |
+
snull = _to01_query(_arg("sex_is_null"))
|
| 361 |
+
if sv == "UNKNOWN": snull, sv = 1, ""
|
| 362 |
+
if "__sex" in df and "sex" not in exclude:
|
| 363 |
+
ser = df["__sex"].fillna("").str.strip()
|
| 364 |
+
if snull in (0,1) and "sex_is_null" not in exclude:
|
| 365 |
+
df = df[ser == ""] if snull==1 else df[ser != ""]
|
| 366 |
+
elif sv in ("F","M"):
|
| 367 |
+
df = df[ser == sv]
|
| 368 |
+
|
| 369 |
+
# Age
|
| 370 |
+
af = _to_float(_arg("age_from"))
|
| 371 |
+
at = _to_float(_arg("age_to"))
|
| 372 |
+
if "__age" in df:
|
| 373 |
+
if "age_from" not in exclude and af is not None:
|
| 374 |
+
df = df[df["__age"].fillna(-1) >= af]
|
| 375 |
+
if "age_to" not in exclude and at is not None:
|
| 376 |
+
df = df[df["__age"].fillna(1e9) <= at]
|
| 377 |
+
|
| 378 |
+
# CT phase
|
| 379 |
+
ct = (_arg("ct_phase","") or "").strip().lower()
|
| 380 |
+
ct_list = _collect_list_params(["ct_phase","ct_phase[]"])
|
| 381 |
+
if ct == "unknown" or any(s.lower()=="unknown" for s in ct_list):
|
| 382 |
+
if "__ct" in df:
|
| 383 |
+
s_ct = df["__ct"].astype(str).str.strip().str.lower()
|
| 384 |
+
tokens_null_ct = {'', 'unknown','nan','n/a','na','none','(blank)','(null)'}
|
| 385 |
+
df = df[df["__ct"].isna() | s_ct.isin(tokens_null_ct)]
|
| 386 |
+
elif (ct or ct_list) and "__ct_lc" in df:
|
| 387 |
+
parts = []
|
| 388 |
+
if ct: parts += [p.strip() for p in re.split(r"[;,/]+", ct) if p.strip()]
|
| 389 |
+
parts += [p.strip().lower() for p in ct_list if p.strip()]
|
| 390 |
+
patt = "|".join(re.escape(p) for p in parts)
|
| 391 |
+
df = df[df["__ct_lc"].str.contains(patt, na=False)]
|
| 392 |
+
|
| 393 |
+
# Manufacturer
|
| 394 |
+
m_list = _collect_list_params(["manufacturer","manufacturer[]","mfr"])
|
| 395 |
+
m_raw = (_arg("manufacturer","") or "").strip()
|
| 396 |
+
if m_raw and not m_list:
|
| 397 |
+
m_list = [p.strip() for p in m_raw.split(",") if p.strip()]
|
| 398 |
+
if m_list and "__mfr_lc" in df:
|
| 399 |
+
m_lc = [s.lower() for s in m_list]
|
| 400 |
+
df = df[df["__mfr_lc"].isin(m_lc)]
|
| 401 |
+
|
| 402 |
+
# Model(多值、大小寫不敏感,OR 連接)
|
| 403 |
+
model_list = _collect_list_params(["model","model[]","manufacturer_model"])
|
| 404 |
+
model_raw = (_arg("model","") or "").strip()
|
| 405 |
+
if model_raw and not model_list:
|
| 406 |
+
model_list = [p.strip() for p in re.split(r"[;,/|]+", model_raw) if p.strip()]
|
| 407 |
+
if model_list and "__model_lc" in df and "model" not in exclude:
|
| 408 |
+
parts = [p.lower() for p in model_list]
|
| 409 |
+
patt = "|".join(re.escape(p) for p in parts)
|
| 410 |
+
df = df[df["__model_lc"].str.contains(patt, na=False)]
|
| 411 |
+
|
| 412 |
+
# Study type(多值、大小寫不敏感)
|
| 413 |
+
st_list = _collect_list_params(["study_type","study_type[]"])
|
| 414 |
+
st_raw = (_arg("study_type","") or "").strip()
|
| 415 |
+
if st_raw and not st_list:
|
| 416 |
+
st_list = [p.strip() for p in re.split(r"[;,/|]+", st_raw) if p.strip()]
|
| 417 |
+
if st_list and "__st_lc" in df and "study_type" not in exclude:
|
| 418 |
+
parts = [p.lower() for p in st_list]
|
| 419 |
+
patt = "|".join(re.escape(p) for p in parts)
|
| 420 |
+
df = df[df["__st_lc"].str.contains(patt, na=False)]
|
| 421 |
+
|
| 422 |
+
# Site nationality(多值、大小寫不敏感); 支援 site_nat 同義
|
| 423 |
+
nat_list = _collect_list_params(["site_nat","site_nat[]","site_nationality","site_nationality[]"])
|
| 424 |
+
nat_raw = (_arg("site_nationality","") or _arg("site_nat","") or "").strip()
|
| 425 |
+
if nat_raw and not nat_list:
|
| 426 |
+
nat_list = [p.strip() for p in re.split(r"[;,/|]+", nat_raw) if p.strip()]
|
| 427 |
+
if nat_list and "__sn_lc" in df and "site_nationality" not in exclude:
|
| 428 |
+
parts = [p.lower() for p in nat_list]
|
| 429 |
+
patt = "|".join(re.escape(p) for p in parts)
|
| 430 |
+
df = df[df["__sn_lc"].str.contains(patt, na=False)]
|
| 431 |
+
|
| 432 |
+
return df
|
| 433 |
+
|
| 434 |
+
# ---------------------------
|
| 435 |
+
# /api/search
|
| 436 |
+
# ---------------------------
|
| 437 |
+
@app.get("/api/search")
|
| 438 |
+
def api_search():
|
| 439 |
+
try:
|
| 440 |
+
df = apply_filters(DF).copy()
|
| 441 |
+
df = _ensure_sort_cols(df)
|
| 442 |
+
|
| 443 |
+
sort_by = (_arg("sort_by", "top") or "top").strip().lower()
|
| 444 |
+
sort_dir = (_arg("sort_dir", "asc") or "asc").strip().lower()
|
| 445 |
+
|
| 446 |
+
if sort_by == "top":
|
| 447 |
+
by = ["__complete", "__spacing_sum", "__shape_sum", "__case_sortkey"]
|
| 448 |
+
asc = [False, True, False, True]
|
| 449 |
+
elif sort_by in ("shape_desc", "shape"):
|
| 450 |
+
by = ["__shape_sum", "__case_sortkey"]
|
| 451 |
+
asc = [False, True]
|
| 452 |
+
elif sort_by in ("spacing_asc", "spacing"):
|
| 453 |
+
by = ["__spacing_sum", "__case_sortkey"]
|
| 454 |
+
asc = [True, True]
|
| 455 |
+
elif sort_by == "age_asc":
|
| 456 |
+
by = ["__age", "__case_sortkey"]; asc = [True, True]
|
| 457 |
+
elif sort_by == "age_desc":
|
| 458 |
+
by = ["__age", "__case_sortkey"]; asc = [False, True]
|
| 459 |
+
else:
|
| 460 |
+
key_map = {"id":"__case_sortkey","spacing":"__spacing_sum","shape":"__shape_sum"}
|
| 461 |
+
k = key_map.get(sort_by, "__case_sortkey")
|
| 462 |
+
by = [k, "__case_sortkey"]
|
| 463 |
+
asc = [(sort_dir!="desc"), True]
|
| 464 |
+
|
| 465 |
+
df = df.sort_values(by=by, ascending=asc, na_position="last", kind="mergesort")
|
| 466 |
+
|
| 467 |
+
total = int(len(df))
|
| 468 |
+
page = max(_to_int(_arg("page", "1")) or 1, 1)
|
| 469 |
+
per_page = _to_int(_arg("per_page", "24")) or 24
|
| 470 |
+
per_page = max(1, min(per_page, 1_000_000))
|
| 471 |
+
|
| 472 |
+
pages = max(1, int(math.ceil(total / per_page)))
|
| 473 |
+
page = max(1, min(page, pages))
|
| 474 |
+
start, end = (page-1)*per_page, (page-1)*per_page + per_page
|
| 475 |
+
|
| 476 |
+
items = [_row_to_item(r) for _, r in df.iloc[start:end].iterrows()]
|
| 477 |
+
return jsonify({"total": total, "page": int(page), "pages": int(pages), "items": _clean_json_list(items)})
|
| 478 |
+
except Exception as e:
|
| 479 |
+
return jsonify({"error": str(e)}), 400
|
| 480 |
+
|
| 481 |
+
# ---------------------------
|
| 482 |
+
# /api/facets
|
| 483 |
+
# ---------------------------
|
| 484 |
+
def _facet_counts_with_unknown(df: pd.DataFrame, col_key: str, top_k: int = 6) -> Dict[str, Any]:
|
| 485 |
+
key_to_col = {
|
| 486 |
+
"ct_phase": ("__ct", str),
|
| 487 |
+
"manufacturer": ("__mfr", str),
|
| 488 |
+
"year": ("__year_int", int),
|
| 489 |
+
"sex": ("__sex", str),
|
| 490 |
+
"tumor": ("__tumor01", int),
|
| 491 |
+
"model": ("model", str),
|
| 492 |
+
"study_type": ("study_type", str),
|
| 493 |
+
"site_nat": ("site_nationality", str),
|
| 494 |
+
"site_nationality": ("site_nationality", str),
|
| 495 |
+
}
|
| 496 |
+
if col_key not in key_to_col:
|
| 497 |
+
return {"rows": [], "unknown": 0}
|
| 498 |
+
col_name, _typ = key_to_col[col_key]
|
| 499 |
+
if col_name not in df.columns:
|
| 500 |
+
return {"rows": [], "unknown": 0}
|
| 501 |
+
|
| 502 |
+
ser = df[col_name]
|
| 503 |
+
if col_key == "year":
|
| 504 |
+
s = ser.dropna().astype(int)
|
| 505 |
+
vc = s.value_counts()
|
| 506 |
+
rows = [{"value": int(v), "count": int(c)} for v, c in vc.items()]
|
| 507 |
+
rows.sort(key=lambda x: (-x["count"], x["value"]))
|
| 508 |
+
unknown = int(ser.isna().sum())
|
| 509 |
+
return {"rows": rows[:top_k] if top_k>0 else rows, "unknown": unknown}
|
| 510 |
+
|
| 511 |
+
s_stripped = ser.astype(str).str.strip().str.lower()
|
| 512 |
+
unknown_mask = ser.isna() | (s_stripped=="") | (s_stripped.isin({"unknown","nan","none"}))
|
| 513 |
+
unknown = int(unknown_mask.sum())
|
| 514 |
+
|
| 515 |
+
def ok(v):
|
| 516 |
+
if pd.isna(v): return False
|
| 517 |
+
s = str(v).strip()
|
| 518 |
+
if s == "": return False
|
| 519 |
+
if s.lower() in ("unknown","nan","none"): return False
|
| 520 |
+
return True
|
| 521 |
+
|
| 522 |
+
vals = ser[ser.map(ok)]
|
| 523 |
+
vc = vals.value_counts(dropna=False)
|
| 524 |
+
rows = [{"value": (int(v) if col_key=="tumor" else v), "count": int(c)} for v, c in vc.items()]
|
| 525 |
+
rows.sort(key=lambda x: (-x["count"], str(x["value"])))
|
| 526 |
+
return {"rows": rows[:top_k] if top_k>0 else rows, "unknown": unknown}
|
| 527 |
+
|
| 528 |
+
@app.get("/api/facets")
|
| 529 |
+
def api_facets():
|
| 530 |
+
try:
|
| 531 |
+
fields_raw = (_arg("fields","ct_phase,manufacturer") or "").strip()
|
| 532 |
+
fields = [f.strip().lower() for f in fields_raw.split(",") if f.strip()]
|
| 533 |
+
|
| 534 |
+
# 允許的新欄位
|
| 535 |
+
valid = {
|
| 536 |
+
"ct_phase","manufacturer","year","sex","tumor",
|
| 537 |
+
"model","study_type","site_nat","site_nationality"
|
| 538 |
+
}
|
| 539 |
+
fields = [f for f in fields if f in valid] or ["ct_phase","manufacturer"]
|
| 540 |
+
top_k = _to_int(_arg("top_k","6")) or 6
|
| 541 |
+
guarantee = (_arg("guarantee","0") or "0").strip().lower() in ("1","true","yes","y")
|
| 542 |
+
|
| 543 |
+
df_now = apply_filters(DF)
|
| 544 |
+
base_for_ranges = df_now if len(df_now) else DF
|
| 545 |
+
|
| 546 |
+
facets = {}
|
| 547 |
+
unknown_counts = {}
|
| 548 |
+
|
| 549 |
+
# 為每個 facet 準備要排除的條件(避免自我影響)
|
| 550 |
+
exclude_map = {
|
| 551 |
+
"ct_phase": {"ct_phase"},
|
| 552 |
+
"manufacturer": {"manufacturer","mfr_is_null","manufacturer_is_null"},
|
| 553 |
+
"year": {"year_from","year_to"},
|
| 554 |
+
"sex": {"sex"},
|
| 555 |
+
"tumor": {"tumor"},
|
| 556 |
+
|
| 557 |
+
# 新增 ↓↓↓
|
| 558 |
+
"model": {"model"},
|
| 559 |
+
"study_type": {"study_type"},
|
| 560 |
+
"site_nat": {"site_nat","site_nationality"},
|
| 561 |
+
"site_nationality": {"site_nat","site_nationality"},
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
for f in fields:
|
| 565 |
+
ex = exclude_map.get(f,set())
|
| 566 |
+
src = (DF if (guarantee and len(df_now)==0) else df_now)
|
| 567 |
+
df_facet = apply_filters(src, exclude=ex)
|
| 568 |
+
res = _facet_counts_with_unknown(df_facet, f, top_k=top_k)
|
| 569 |
+
facets[f] = res["rows"]; unknown_counts[f] = res["unknown"]
|
| 570 |
+
|
| 571 |
+
# 年齡/年份範圍(原樣保留)
|
| 572 |
+
def _minmax(series: pd.Series):
|
| 573 |
+
s = series.dropna()
|
| 574 |
+
if not len(s): return (None, None)
|
| 575 |
+
return (float(s.min()), float(s.max()))
|
| 576 |
+
|
| 577 |
+
age_min = age_max = None
|
| 578 |
+
year_min = year_max = None
|
| 579 |
+
if "__age" in base_for_ranges:
|
| 580 |
+
age_min, age_max = _minmax(base_for_ranges["__age"])
|
| 581 |
+
if "__year_int" in base_for_ranges:
|
| 582 |
+
yr = base_for_ranges["__year_int"].dropna().astype(int)
|
| 583 |
+
if len(yr):
|
| 584 |
+
year_min, year_max = int(yr.min()), int(yr.max())
|
| 585 |
+
|
| 586 |
+
return jsonify({
|
| 587 |
+
"facets": facets,
|
| 588 |
+
"unknown_counts": unknown_counts,
|
| 589 |
+
"age_range": {"min": age_min, "max": age_max},
|
| 590 |
+
"year_range": {"min": year_min, "max": year_max},
|
| 591 |
+
"total": int(len(df_now)),
|
| 592 |
+
})
|
| 593 |
+
except Exception as e:
|
| 594 |
+
return jsonify({"error": str(e)}), 400
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
# ---------------------------
|
| 598 |
+
# /api/random (Browse)
|
| 599 |
+
# ---------------------------
|
| 600 |
+
@app.get("/api/random")
|
| 601 |
+
def api_random_topk_rotate_norand():
|
| 602 |
+
"""
|
| 603 |
+
推薦:完整資料優先 → 取 Top-K(預設100) → 環狀位移 → 可排除最近看過
|
| 604 |
+
排序:__spacing_sum ↑, __shape_sum ↓, __case_sortkey ↑
|
| 605 |
+
"""
|
| 606 |
+
try:
|
| 607 |
+
scope = (request.args.get("scope", "filtered") or "filtered").strip().lower()
|
| 608 |
+
base_df = apply_filters(DF)
|
| 609 |
+
if len(base_df) == 0 and scope == "all":
|
| 610 |
+
base_df = DF.copy()
|
| 611 |
+
|
| 612 |
+
base_df = _ensure_sort_cols(base_df)
|
| 613 |
+
|
| 614 |
+
# 只取完整資料;若沒有完整的就退回全部
|
| 615 |
+
df_full = base_df[base_df["__complete"]] if "__complete" in base_df.columns else base_df
|
| 616 |
+
if len(df_full) == 0:
|
| 617 |
+
df_full = base_df
|
| 618 |
+
df = df_full.sort_values(
|
| 619 |
+
by=["__spacing_sum","__shape_sum","__case_sortkey"],
|
| 620 |
+
ascending=[True, False, True],
|
| 621 |
+
na_position="last",
|
| 622 |
+
kind="mergesort",
|
| 623 |
+
)
|
| 624 |
+
|
| 625 |
+
if len(df) == 0:
|
| 626 |
+
return jsonify({"items": [], "total": 0, "meta": {"k": 0, "used_recent": 0}}), 200
|
| 627 |
+
|
| 628 |
+
# n, k
|
| 629 |
+
try: n = int(request.args.get("n") or 3)
|
| 630 |
+
except Exception: n = 3
|
| 631 |
+
n = max(1, min(n, len(df)))
|
| 632 |
+
|
| 633 |
+
try: K = int(request.args.get("k") or 100)
|
| 634 |
+
except Exception: K = 100
|
| 635 |
+
K = max(n, min(K, len(df)))
|
| 636 |
+
|
| 637 |
+
# recent 排除
|
| 638 |
+
recent_raw = (request.args.get("recent") or "").strip()
|
| 639 |
+
used_recent = 0
|
| 640 |
+
if recent_raw:
|
| 641 |
+
recent_ids = {s.strip() for s in recent_raw.split(",") if s.strip()}
|
| 642 |
+
key = df["__case_str"].astype(str) if "__case_str" in df.columns else None
|
| 643 |
+
if key is not None:
|
| 644 |
+
mask = ~key.isin(recent_ids)
|
| 645 |
+
used_recent = int((~mask).sum())
|
| 646 |
+
df2 = df[mask]
|
| 647 |
+
if len(df2): df = df2
|
| 648 |
+
|
| 649 |
+
topk = df.iloc[:K]
|
| 650 |
+
if len(topk) == 0:
|
| 651 |
+
return jsonify({"items": [], "total": 0, "meta": {"k": 0, "used_recent": used_recent}}), 200
|
| 652 |
+
|
| 653 |
+
off_arg = request.args.get("offset")
|
| 654 |
+
if off_arg is not None:
|
| 655 |
+
try: offset = int(off_arg) % len(topk)
|
| 656 |
+
except Exception: offset = 0
|
| 657 |
+
else:
|
| 658 |
+
now = datetime.utcnow()
|
| 659 |
+
offset = ((now.minute * 60) + now.second) % len(topk)
|
| 660 |
+
|
| 661 |
+
idx = list(range(len(topk))) + list(range(len(topk)))
|
| 662 |
+
pick = idx[offset:offset + min(n, len(topk))]
|
| 663 |
+
sub = topk.iloc[pick]
|
| 664 |
+
|
| 665 |
+
items = [_row_to_item(r) for _, r in sub.iterrows()]
|
| 666 |
+
resp = jsonify({
|
| 667 |
+
"items": _clean_json_list(items),
|
| 668 |
+
"total": int(len(df)),
|
| 669 |
+
"meta": {"k": int(len(topk)), "used_recent": used_recent, "offset": int(offset)}
|
| 670 |
+
})
|
| 671 |
+
r = make_response(resp)
|
| 672 |
+
r.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
| 673 |
+
r.headers["Pragma"] = "no-cache"
|
| 674 |
+
r.headers["Expires"] = "0"
|
| 675 |
+
return r
|
| 676 |
+
|
| 677 |
+
except Exception as e:
|
| 678 |
+
return jsonify({"error": str(e)}), 400
|
| 679 |
+
|
| 680 |
+
# ---------------------------
|
| 681 |
+
# Row → JSON
|
| 682 |
+
# ---------------------------
|
| 683 |
+
def _row_to_item(row: pd.Series) -> Dict[str, Any]:
|
| 684 |
+
cols = row.get("_orig_cols")
|
| 685 |
+
cols = cols if isinstance(cols, dict) else {}
|
| 686 |
+
|
| 687 |
+
def pick(k, fallback=None):
|
| 688 |
+
col = cols.get(k)
|
| 689 |
+
if col and col in row.index:
|
| 690 |
+
return row[col]
|
| 691 |
+
return fallback
|
| 692 |
+
|
| 693 |
+
return {
|
| 694 |
+
"PanTS ID": _nan2none(pick("case") or row.get("__case_str")),
|
| 695 |
+
"case_id": _nan2none(pick("case") or row.get("__case_str")),
|
| 696 |
+
"tumor": (int(row.get("__tumor01")) if pd.notna(row.get("__tumor01")) else None),
|
| 697 |
+
"sex": _nan2none(row.get("__sex")),
|
| 698 |
+
"age": _nan2none(row.get("__age")),
|
| 699 |
+
"ct phase": _nan2none(pick("ct_phase") or row.get("__ct")),
|
| 700 |
+
"manufacturer": _nan2none(pick("manufacturer") or row.get("__mfr")),
|
| 701 |
+
"manufacturer model": _nan2none(pick("model") or row.get("model")),
|
| 702 |
+
"study year": _nan2none(row.get("__year_int")),
|
| 703 |
+
"study type": _nan2none(pick("study_type") or row.get("study_type")),
|
| 704 |
+
"site nationality": _nan2none(pick("site_nationality") or row.get("site_nationality")),
|
| 705 |
+
# 排序輔助輸出
|
| 706 |
+
"spacing_sum": _nan2none(row.get("__spacing_sum")),
|
| 707 |
+
"shape_sum": _nan2none(row.get("__shape_sum")),
|
| 708 |
+
"complete": bool(row.get("__complete")) if "__complete" in row else None,
|
| 709 |
+
}
|
| 710 |
+
|
| 711 |
+
# ---------------------------
|
| 712 |
+
# Health & index
|
| 713 |
+
# ---------------------------
|
| 714 |
+
@app.get("/api/health")
|
| 715 |
+
def api_health():
|
| 716 |
+
return jsonify({"ok": True})
|
| 717 |
+
|
| 718 |
+
@app.get("/")
|
| 719 |
+
def index():
|
| 720 |
+
if not INDEX_FILE or not os.path.exists(INDEX_FILE):
|
| 721 |
+
return "Backend OK (HTML not found or not provided)", 200
|
| 722 |
+
return send_file(INDEX_FILE)
|
| 723 |
+
|
| 724 |
+
# ---------------------------
|
| 725 |
+
# main
|
| 726 |
+
# ---------------------------
|
| 727 |
+
if __name__ == "__main__":
|
| 728 |
+
# 這裡直接用前面 argparse 解析到的參數
|
| 729 |
+
app.run(host=args.host, port=args.port, debug=True)
|
| 730 |
+
|
| 731 |
+
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask
|
| 2 |
+
flask-cors
|
| 3 |
+
pandas
|
| 4 |
+
numpy
|
| 5 |
+
openpyxl
|