Spaces:
Sleeping
Sleeping
feat(ui): progress indicator while the cloud OCR reads a photo
Browse files
livemedcard/static/index.html
CHANGED
|
@@ -354,6 +354,14 @@
|
|
| 354 |
.sphoto{border:1px dashed var(--line);border-radius:10px;padding:12px 14px;margin-top:14px}
|
| 355 |
.sphoto img{width:100%;max-width:420px;border-radius:8px;border:1px solid var(--line);display:block;margin:9px 0}
|
| 356 |
.sfoot{display:flex;gap:9px;justify-content:flex-end;padding:12px 18px;border-top:1px solid var(--line)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
a#samples-photo-dl{display:inline-flex;align-items:center;text-decoration:none;cursor:pointer;
|
| 358 |
font:inherit;font-weight:550;border:1px solid var(--line);border-radius:10px;padding:9px 15px;
|
| 359 |
background:transparent;color:var(--ink);transition:.14s}
|
|
@@ -379,6 +387,7 @@
|
|
| 379 |
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
|
| 380 |
|
| 381 |
@media (prefers-reduced-motion:reduce){
|
|
|
|
| 382 |
*,*::before,*::after{animation-duration:.001s!important;animation-iteration-count:1!important;
|
| 383 |
transition-duration:.001s!important}
|
| 384 |
.spark path.line{stroke-dashoffset:0}
|
|
@@ -444,6 +453,7 @@
|
|
| 444 |
</select>
|
| 445 |
<button class="primary" id="add-submit">Process document</button>
|
| 446 |
</div>
|
|
|
|
| 447 |
</div>
|
| 448 |
|
| 449 |
<div class="story" id="story" role="note">
|
|
@@ -533,6 +543,7 @@
|
|
| 533 |
<button class="ghost" id="samples-photo-btn"></button>
|
| 534 |
<a class="ghost" id="samples-photo-dl" href="/samples/image" download="livemedcard_sample_lab_report.jpg"></a>
|
| 535 |
</div>
|
|
|
|
| 536 |
</div>
|
| 537 |
</div>
|
| 538 |
<div class="sfoot">
|
|
@@ -636,6 +647,7 @@ const I18N = {
|
|
| 636 |
toastExport:'FHIR bundle downloaded', toastNeedInput:'Enter text or choose a photo',
|
| 637 |
ocrReading:'Reading (OCR)…', processing:'Processing…',
|
| 638 |
toastOcrWait:'Reading the photo on the cloud OCR lab — up to ~90 s',
|
|
|
|
| 639 |
toastEscalated:'Processed · escalated to human', toastAuto:'Processed · auto',
|
| 640 |
toastNoFacts:'Document saved, but no measurements were extracted',
|
| 641 |
errorPrefix:'Error: ', selected:name=>`Selected: ${name}`,
|
|
@@ -740,6 +752,7 @@ const I18N = {
|
|
| 740 |
toastExport:'FHIR Bundle завантажено', toastNeedInput:'Введіть текст або виберіть фото',
|
| 741 |
ocrReading:'Зчитування (OCR)…', processing:'Опрацювання…',
|
| 742 |
toastOcrWait:'Фото читає хмарний OCR-лаб — до ~90 с',
|
|
|
|
| 743 |
toastEscalated:'Оброблено · ескалація до людини', toastAuto:'Оброблено · авто',
|
| 744 |
toastNoFacts:'Документ збережено, але жодного показника не витягнуто',
|
| 745 |
errorPrefix:'Помилка: ', selected:name=>`Вибрано: ${name}`,
|
|
@@ -1260,6 +1273,7 @@ $('#samples-load').onclick=async()=>{
|
|
| 1260 |
$('#samples-photo-btn').onclick=async()=>{
|
| 1261 |
const t=I18N[LANG], b=$('#samples-photo-btn'), orig=b.textContent;
|
| 1262 |
b.disabled=true; b.textContent=t.ocrReading; toast(t.toastOcrWait);
|
|
|
|
| 1263 |
try{
|
| 1264 |
const blob=await (await fetch('/samples/image')).blob();
|
| 1265 |
const base64=await new Promise((res,rej)=>{
|
|
@@ -1270,7 +1284,7 @@ $('#samples-photo-btn').onclick=async()=>{
|
|
| 1270 |
const got=(rep.extraction&&rep.extraction.observations||[]).length;
|
| 1271 |
toast(got?(rep.decision.escalate?t.toastEscalated:t.toastAuto):t.toastNoFacts);
|
| 1272 |
}catch(e){toast(t.errorPrefix+e.message);}
|
| 1273 |
-
finally{b.disabled=false;b.textContent=orig;}
|
| 1274 |
};
|
| 1275 |
$('#btn-reset').onclick=async()=>{await api('/reset',{method:'POST'});$('#log').innerHTML='';await refresh();toast(I18N[LANG].toastReset);};
|
| 1276 |
$('#btn-export').onclick=async()=>{
|
|
@@ -1283,6 +1297,14 @@ $('#btn-export').onclick=async()=>{
|
|
| 1283 |
};
|
| 1284 |
$('#btn-add').onclick=()=>$('#add-form').classList.toggle('show');
|
| 1285 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1286 |
/* Згода на відправку фото в хмару. Питаємо ПЕРЕД тим, як файл по��ине пристрій —
|
| 1287 |
згода постфактум згодою не є. Локальний бекенд нічого не питає. */
|
| 1288 |
function askCloudConsent(){
|
|
@@ -1322,11 +1344,13 @@ $('#add-submit').onclick=async()=>{
|
|
| 1322 |
const text=$('#add-text').value.trim(), file=fileInput.files[0];
|
| 1323 |
if(!text&&!file){toast(t.toastNeedInput);return;}
|
| 1324 |
const btn=$('#add-submit'), orig=btn.textContent; btn.disabled=true;
|
|
|
|
| 1325 |
try{
|
| 1326 |
let rep;
|
| 1327 |
if(file){
|
| 1328 |
if(!await askCloudConsent()){ btn.disabled=false; btn.textContent=orig; return; }
|
| 1329 |
btn.textContent=t.ocrReading;
|
|
|
|
| 1330 |
// Хмарний OCR — це ~70–90 с. Без попередження мовчазне очікування читається
|
| 1331 |
// як зависання (і саме так виглядає для журі, що клікне демо самостійно).
|
| 1332 |
toast(t.toastOcrWait);
|
|
@@ -1347,7 +1371,7 @@ $('#add-submit').onclick=async()=>{
|
|
| 1347 |
const got=(rep.extraction&&rep.extraction.observations||[]).length;
|
| 1348 |
toast(got?(rep.decision.escalate?t.toastEscalated:t.toastAuto):t.toastNoFacts);
|
| 1349 |
}catch(e){toast(t.errorPrefix+e.message);}
|
| 1350 |
-
finally{btn.disabled=false;btn.textContent=orig;}
|
| 1351 |
};
|
| 1352 |
|
| 1353 |
$('#ask').onclick=()=>ask();
|
|
|
|
| 354 |
.sphoto{border:1px dashed var(--line);border-radius:10px;padding:12px 14px;margin-top:14px}
|
| 355 |
.sphoto img{width:100%;max-width:420px;border-radius:8px;border:1px solid var(--line);display:block;margin:9px 0}
|
| 356 |
.sfoot{display:flex;gap:9px;justify-content:flex-end;padding:12px 18px;border-top:1px solid var(--line)}
|
| 357 |
+
.ocrp{display:none;margin-top:11px}
|
| 358 |
+
.ocrp.on{display:block}
|
| 359 |
+
.ocrp .track{height:4px;border-radius:99px;background:var(--line-soft);overflow:hidden}
|
| 360 |
+
.ocrp .track i{display:block;height:100%;width:38%;border-radius:99px;background:var(--accent);
|
| 361 |
+
animation:ocrslide 1.5s ease-in-out infinite}
|
| 362 |
+
@keyframes ocrslide{0%{transform:translateX(-100%)}100%{transform:translateX(360%)}}
|
| 363 |
+
.ocrp .lbl{margin-top:7px;font-family:var(--mono);font-size:11.5px;color:var(--muted)}
|
| 364 |
+
.ocrp .lbl b{color:var(--accent);font-weight:600;font-variant-numeric:tabular-nums}
|
| 365 |
a#samples-photo-dl{display:inline-flex;align-items:center;text-decoration:none;cursor:pointer;
|
| 366 |
font:inherit;font-weight:550;border:1px solid var(--line);border-radius:10px;padding:9px 15px;
|
| 367 |
background:transparent;color:var(--ink);transition:.14s}
|
|
|
|
| 387 |
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
|
| 388 |
|
| 389 |
@media (prefers-reduced-motion:reduce){
|
| 390 |
+
.ocrp .track i{animation:none;width:100%}
|
| 391 |
*,*::before,*::after{animation-duration:.001s!important;animation-iteration-count:1!important;
|
| 392 |
transition-duration:.001s!important}
|
| 393 |
.spark path.line{stroke-dashoffset:0}
|
|
|
|
| 453 |
</select>
|
| 454 |
<button class="primary" id="add-submit">Process document</button>
|
| 455 |
</div>
|
| 456 |
+
<div class="ocrp" id="add-ocrp"><div class="track"><i></i></div><div class="lbl"></div></div>
|
| 457 |
</div>
|
| 458 |
|
| 459 |
<div class="story" id="story" role="note">
|
|
|
|
| 543 |
<button class="ghost" id="samples-photo-btn"></button>
|
| 544 |
<a class="ghost" id="samples-photo-dl" href="/samples/image" download="livemedcard_sample_lab_report.jpg"></a>
|
| 545 |
</div>
|
| 546 |
+
<div class="ocrp" id="samples-ocrp"><div class="track"><i></i></div><div class="lbl"></div></div>
|
| 547 |
</div>
|
| 548 |
</div>
|
| 549 |
<div class="sfoot">
|
|
|
|
| 647 |
toastExport:'FHIR bundle downloaded', toastNeedInput:'Enter text or choose a photo',
|
| 648 |
ocrReading:'Reading (OCR)…', processing:'Processing…',
|
| 649 |
toastOcrWait:'Reading the photo on the cloud OCR lab — up to ~90 s',
|
| 650 |
+
ocrProgress:sec=>`Reading on the cloud OCR lab — <b>${sec} s</b> · usually 40–90 s`,
|
| 651 |
toastEscalated:'Processed · escalated to human', toastAuto:'Processed · auto',
|
| 652 |
toastNoFacts:'Document saved, but no measurements were extracted',
|
| 653 |
errorPrefix:'Error: ', selected:name=>`Selected: ${name}`,
|
|
|
|
| 752 |
toastExport:'FHIR Bundle завантажено', toastNeedInput:'Введіть текст або виберіть фото',
|
| 753 |
ocrReading:'Зчитування (OCR)…', processing:'Опрацювання…',
|
| 754 |
toastOcrWait:'Фото читає хмарний OCR-лаб — до ~90 с',
|
| 755 |
+
ocrProgress:sec=>`Читає хмарний OCR-лаб — <b>${sec} с</b> · зазвичай 40–90 с`,
|
| 756 |
toastEscalated:'Оброблено · ескалація до людини', toastAuto:'Оброблено · авто',
|
| 757 |
toastNoFacts:'Документ збережено, але жодного показника не витягнуто',
|
| 758 |
errorPrefix:'Помилка: ', selected:name=>`Вибрано: ${name}`,
|
|
|
|
| 1273 |
$('#samples-photo-btn').onclick=async()=>{
|
| 1274 |
const t=I18N[LANG], b=$('#samples-photo-btn'), orig=b.textContent;
|
| 1275 |
b.disabled=true; b.textContent=t.ocrReading; toast(t.toastOcrWait);
|
| 1276 |
+
const stop=startOcrProgress('#samples-ocrp');
|
| 1277 |
try{
|
| 1278 |
const blob=await (await fetch('/samples/image')).blob();
|
| 1279 |
const base64=await new Promise((res,rej)=>{
|
|
|
|
| 1284 |
const got=(rep.extraction&&rep.extraction.observations||[]).length;
|
| 1285 |
toast(got?(rep.decision.escalate?t.toastEscalated:t.toastAuto):t.toastNoFacts);
|
| 1286 |
}catch(e){toast(t.errorPrefix+e.message);}
|
| 1287 |
+
finally{stop();b.disabled=false;b.textContent=orig;}
|
| 1288 |
};
|
| 1289 |
$('#btn-reset').onclick=async()=>{await api('/reset',{method:'POST'});$('#log').innerHTML='';await refresh();toast(I18N[LANG].toastReset);};
|
| 1290 |
$('#btn-export').onclick=async()=>{
|
|
|
|
| 1297 |
};
|
| 1298 |
$('#btn-add').onclick=()=>$('#add-form').classList.toggle('show');
|
| 1299 |
|
| 1300 |
+
function startOcrProgress(id){
|
| 1301 |
+
const box=$(id), lbl=box.querySelector('.lbl'), t0=Date.now();
|
| 1302 |
+
const tick=()=>{lbl.innerHTML=I18N[LANG].ocrProgress(Math.round((Date.now()-t0)/1000));};
|
| 1303 |
+
tick(); box.classList.add('on');
|
| 1304 |
+
const iv=setInterval(tick,1000);
|
| 1305 |
+
return ()=>{clearInterval(iv); box.classList.remove('on'); lbl.textContent='';};
|
| 1306 |
+
}
|
| 1307 |
+
|
| 1308 |
/* Згода на відправку фото в хмару. Питаємо ПЕРЕД тим, як файл по��ине пристрій —
|
| 1309 |
згода постфактум згодою не є. Локальний бекенд нічого не питає. */
|
| 1310 |
function askCloudConsent(){
|
|
|
|
| 1344 |
const text=$('#add-text').value.trim(), file=fileInput.files[0];
|
| 1345 |
if(!text&&!file){toast(t.toastNeedInput);return;}
|
| 1346 |
const btn=$('#add-submit'), orig=btn.textContent; btn.disabled=true;
|
| 1347 |
+
let stopOcr=null;
|
| 1348 |
try{
|
| 1349 |
let rep;
|
| 1350 |
if(file){
|
| 1351 |
if(!await askCloudConsent()){ btn.disabled=false; btn.textContent=orig; return; }
|
| 1352 |
btn.textContent=t.ocrReading;
|
| 1353 |
+
stopOcr=startOcrProgress('#add-ocrp');
|
| 1354 |
// Хмарний OCR — це ~70–90 с. Без попередження мовчазне очікування читається
|
| 1355 |
// як зависання (і саме так виглядає для журі, що клікне демо самостійно).
|
| 1356 |
toast(t.toastOcrWait);
|
|
|
|
| 1371 |
const got=(rep.extraction&&rep.extraction.observations||[]).length;
|
| 1372 |
toast(got?(rep.decision.escalate?t.toastEscalated:t.toastAuto):t.toastNoFacts);
|
| 1373 |
}catch(e){toast(t.errorPrefix+e.message);}
|
| 1374 |
+
finally{if(stopOcr)stopOcr();btn.disabled=false;btn.textContent=orig;}
|
| 1375 |
};
|
| 1376 |
|
| 1377 |
$('#ask').onclick=()=>ask();
|