jen900704 commited on
Commit
4828c8f
·
verified ·
1 Parent(s): 3e65308

Upload 4 files

Browse files
Files changed (5) hide show
  1. .gitattributes +1 -0
  2. 1012.html +1390 -0
  3. metadata.xlsx +3 -0
  4. nApp.py +731 -0
  5. 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