|
80 | 80 | .cat.on{background:var(--aroma);color:#fff;border-color:var(--aroma)} |
81 | 81 | .cat.taste.on{background:var(--sweet);border-color:var(--sweet)} |
82 | 82 | .browse-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px} |
83 | | - .bcell{border:1px solid var(--line);border-radius:8px;padding:8px;cursor:pointer;background:#fff;text-align:center;transition:border-color .15s} |
| 83 | + .bcell{position:relative;border:1px solid var(--line);border-radius:8px;padding:8px;cursor:pointer;background:#fff;text-align:center;transition:border-color .15s} |
| 84 | + .mini-3d{position:absolute;top:6px;right:6px;z-index:2;font:inherit;font-size:10px;font-weight:600;padding:1px 6px;border:1px solid var(--line);border-radius:5px;background:#fff;color:var(--muted);cursor:pointer} |
| 85 | + .mini-3d:hover{border-color:var(--aroma);color:var(--aroma)} |
| 86 | + .bcell .bstruct{position:relative} |
| 87 | + .bcell .bstruct.is3d{height:130px} |
| 88 | + .nb-structwrap{position:relative;flex:0 0 auto} |
| 89 | + .nb-3d{top:2px;right:2px;padding:0 5px} |
| 90 | + .neighbor .nb-struct{position:relative} |
| 91 | + .neighbor .nb-struct.is3d{width:108px;height:88px} |
84 | 92 | .bcell:hover{border-color:var(--aroma)} |
85 | 93 | .bcell .bstruct{height:68px}.bcell .bstruct svg{max-width:100%;height:68px} |
86 | 94 | .bcell .bname{font-size:12px;font-weight:600;margin-top:5px;line-height:1.25;text-transform:capitalize;cursor:pointer} |
@@ -196,9 +204,7 @@ <h1>Flavormancer</h1> |
196 | 204 | </div> |
197 | 205 |
|
198 | 206 | <div id="browse" class="browse"> |
199 | | - <div class="browse-head">Or browse the library — pick a category, then a molecule |
200 | | - <span class="browse-toggle"><button type="button" id="bmode2d" class="st-tab on">2D</button><button type="button" id="bmode3d" class="st-tab">3D</button></span> |
201 | | - </div> |
| 207 | + <div class="browse-head">Or browse the library — pick a category, then a molecule (each card has a 2D/3D toggle)</div> |
202 | 208 | <div id="browseCats" class="browse-cats"></div> |
203 | 209 | <div id="browseGrid" class="browse-grid"></div> |
204 | 210 | </div> |
@@ -388,9 +394,13 @@ <h2>Aroma</h2> |
388 | 394 | if(!neighbors.length){ |
389 | 395 | $('neighbors').innerHTML = '<div class="empty">Build taste_master.parquet to enable substitution search.</div>'; |
390 | 396 | }else{ |
391 | | - $('neighbors').innerHTML = neighbors.map(x=>` |
| 397 | + window._nbrs = neighbors; // for the per-card 3D toggle handler |
| 398 | + $('neighbors').innerHTML = neighbors.map((x,i)=>` |
392 | 399 | <div class="neighbor" data-smi="${x.smiles}" title="Click to analyze this molecule"> |
393 | | - <div class="nb-struct">${x.svg||''}</div> |
| 400 | + <div class="nb-structwrap"> |
| 401 | + <div class="nb-struct" id="nbs${i}">${x.svg||''}</div> |
| 402 | + ${_has3D?`<button type="button" class="mini-3d nb-3d" data-i="${i}" data-m="2d">3D</button>`:''} |
| 403 | + </div> |
394 | 404 | <div class="nb-info"> |
395 | 405 | <div class="nb-name">${x.name||'—'}</div> |
396 | 406 | ${x.iupac?`<div class="nb-iupac">${x.iupac}</div>`:''} |
@@ -520,8 +530,12 @@ <h2>Aroma</h2> |
520 | 530 |
|
521 | 531 | go.addEventListener('click', run); |
522 | 532 | q.addEventListener('keydown', e=>{ if(e.key==='Enter') run(); }); |
523 | | -// click a substitution candidate -> analyze that molecule |
| 533 | +// substitution card: 3D toggle button, rotate-in-place (no analyze), or click card -> analyze |
524 | 534 | $('neighbors').addEventListener('click', e=>{ |
| 535 | + const btn = e.target.closest('.mini-3d'); |
| 536 | + if(btn){ e.stopPropagation(); const i=+btn.dataset.i; const x=(window._nbrs||[])[i]||{}; |
| 537 | + cardToggle3D(btn, $('nbs'+i), x.smiles, x.svg||''); return; } |
| 538 | + if(e.target.closest('.nb-struct.is3d')) return; // rotating the 3D model, not analyzing |
525 | 539 | const nb = e.target.closest('.neighbor'); |
526 | 540 | if(nb && nb.dataset.smi){ q.value = nb.dataset.smi; window.scrollTo({top:0,behavior:'smooth'}); run(); } |
527 | 541 | }); |
@@ -571,45 +585,42 @@ <h2>Aroma</h2> |
571 | 585 | if(el.querySelector('.cat')) showCategory(el.querySelector('.cat')); // open first by default |
572 | 586 | }catch(e){ setTimeout(initBrowse, 3000); } |
573 | 587 | } |
574 | | -let _browseMode='2d', _browseCat=null; |
575 | 588 | const _has3D = typeof $3Dmol !== 'undefined'; |
576 | | -function setBrowseMode(mode){ |
577 | | - _browseMode = mode; |
578 | | - $('bmode2d').classList.toggle('on', mode==='2d'); |
579 | | - $('bmode3d').classList.toggle('on', mode==='3d'); |
580 | | - if(_browseCat) showCategory(_browseCat); |
| 589 | +// mount an interactive 3D conformer INTO a container. The container is sized + position:relative |
| 590 | +// via CSS (.is3d) BEFORE createViewer — that's what keeps 3Dmol from rendering in the page corner. |
| 591 | +async function mount3D(el, smiles){ |
| 592 | + if(!_has3D){ el.innerHTML='<div class="s3-empty">3D unavailable</div>'; return; } |
| 593 | + el.innerHTML='<div class="s3-empty">…</div>'; |
| 594 | + const s3 = await post('/api/structure3d',{smiles}); |
| 595 | + if(!s3 || !s3.molblock){ el.innerHTML='<div class="s3-empty">no 3D</div>'; return; } |
| 596 | + el.innerHTML=''; |
| 597 | + const v = $3Dmol.createViewer(el, {backgroundColor:'white'}); |
| 598 | + v.addModel(s3.molblock,'sdf'); v.setStyle({},{stick:{radius:0.13},sphere:{scale:0.23}}); |
| 599 | + v.zoomTo(); v.render(); v.resize(); |
| 600 | +} |
| 601 | +// per-card 2D/3D toggle: swaps a struct element between its 2D svg and an in-card 3D viewer |
| 602 | +function cardToggle3D(btn, structEl, smiles, svgHtml){ |
| 603 | + if(btn.dataset.m==='3d'){ btn.dataset.m='2d'; btn.textContent='3D'; structEl.classList.remove('is3d'); structEl.innerHTML=svgHtml; } |
| 604 | + else { btn.dataset.m='3d'; btn.textContent='2D'; structEl.classList.add('is3d'); mount3D(structEl, smiles); } |
581 | 605 | } |
582 | | -$('bmode2d').addEventListener('click', ()=>setBrowseMode('2d')); |
583 | | -$('bmode3d').addEventListener('click', ()=>setBrowseMode(_has3D?'3d':'2d')); |
584 | 606 |
|
585 | 607 | async function showCategory(btn){ |
586 | | - _browseCat = btn; |
587 | 608 | document.querySelectorAll('.cat').forEach(c=>c.classList.remove('on')); |
588 | 609 | btn.classList.add('on'); |
589 | | - const grid = $('browseGrid'), threeD = _browseMode==='3d' && _has3D; |
| 610 | + const grid = $('browseGrid'); |
590 | 611 | grid.innerHTML = '<div class="empty">Loading…</div>'; |
591 | | - const d = await getJSON('/api/top?category='+encodeURIComponent(btn.dataset.key)+'&limit='+(threeD?9:24)); |
| 612 | + const d = await getJSON('/api/top?category='+encodeURIComponent(btn.dataset.key)+'&limit=24'); |
592 | 613 | const items = d.items||[]; |
593 | | - const esc = s => s.replace(/"/g,'"'); |
594 | 614 | grid.innerHTML = items.map((it,i)=>` |
595 | | - <div class="bcell" data-smi="${esc(it.smiles)}"> |
596 | | - <div class="bstruct" id="b3d${i}">${threeD?'':(it.svg||'')}</div> |
| 615 | + <div class="bcell"> |
| 616 | + <div class="bstruct" id="bs${i}">${it.svg||''}</div> |
| 617 | + ${_has3D?`<button type="button" class="mini-3d" data-i="${i}" data-m="2d">3D</button>`:''} |
597 | 618 | <div class="bname" title="Analyze ${it.name}">${it.name}</div></div>`).join('') |
598 | 619 | || '<div class="empty">No molecules.</div>'; |
599 | | - // click the NAME to analyze (leaves the 3D structure free to rotate) |
600 | 620 | grid.querySelectorAll('.bname').forEach((el,i)=> el.addEventListener('click', ()=>{ |
601 | 621 | q.value = items[i].smiles; window.scrollTo({top:0,behavior:'smooth'}); run(); })); |
602 | | - if(threeD){ // render up to 9 interactive conformers (WebGL context budget) |
603 | | - items.forEach(async (it,i)=>{ |
604 | | - const s3 = await post('/api/structure3d',{smiles:it.smiles}); |
605 | | - const el = document.getElementById('b3d'+i); |
606 | | - if(s3 && s3.molblock && el){ |
607 | | - const v = $3Dmol.createViewer(el,{backgroundColor:'white'}); |
608 | | - v.addModel(s3.molblock,'sdf'); v.setStyle({},{stick:{radius:0.12},sphere:{scale:0.22}}); |
609 | | - v.zoomTo(); v.render(); v.resize(); |
610 | | - } else if(el){ el.innerHTML='<div class="s3-empty">no 3D</div>'; } |
611 | | - }); |
612 | | - } |
| 622 | + grid.querySelectorAll('.mini-3d').forEach(b=>{ const i=+b.dataset.i; |
| 623 | + b.addEventListener('click', ()=> cardToggle3D(b, $('bs'+i), items[i].smiles, items[i].svg||'')); }); |
613 | 624 | } |
614 | 625 | initBrowse(); |
615 | 626 |
|
|
0 commit comments