|
99 | 99 | .map-legend{display:flex;flex-wrap:wrap;gap:10px;margin:10px 0 6px;font-size:12px} |
100 | 100 | .map-legend span{display:inline-flex;align-items:center;gap:5px} |
101 | 101 | .map-legend i{width:10px;height:10px;border-radius:50%;display:inline-block} |
| 102 | + .map-controls{display:flex;flex-wrap:wrap;align-items:center;gap:12px;margin:10px 0 8px} |
| 103 | + .map-toggle3d{display:inline-flex} |
| 104 | + .map-toggle3d .st-tab{padding:3px 12px} |
| 105 | + .map-reset{font:inherit;font-size:12px;padding:3px 10px;border:1px solid var(--line);border-radius:6px;background:#fff;color:var(--muted);cursor:pointer;margin-left:auto} |
| 106 | + .map-reset:hover{border-color:var(--aroma)} |
| 107 | + .map-key{font-size:12px;color:var(--muted);line-height:1.55;margin-top:8px;max-width:900px} |
| 108 | + .map-key b{color:var(--ink)} |
102 | 109 | .map-canvas-wrap{position:relative;width:100%;overflow-x:auto} |
103 | 110 | #flavorMap{max-width:100%;border:1px solid var(--line);border-radius:8px;background:#fff;cursor:crosshair} |
104 | 111 | .map-tip{position:absolute;pointer-events:none;background:#1c2620;color:#fff;font-size:11px;padding:3px 7px;border-radius:5px;white-space:nowrap;transform:translate(-50%,-140%)} |
@@ -211,11 +218,20 @@ <h1>Flavormancer</h1> |
211 | 218 |
|
212 | 219 | <div class="map-section"> |
213 | 220 | <div class="map-head" id="mapToggle"><span id="mapCaret">▸</span> Flavor-space map |
214 | | - <span class="mapsub">— every labeled molecule placed by structural similarity; taste classes separate. Hover a point, click to read.</span></div> |
| 221 | + <span class="mapsub">— molecules placed by structural similarity; taste classes cluster</span></div> |
215 | 222 | <div id="mapWrap" style="display:none"> |
216 | | - <div id="mapLegend" class="map-legend"></div> |
217 | | - <div class="map-canvas-wrap"><canvas id="flavorMap" width="920" height="520"></canvas> |
| 223 | + <div class="map-controls"> |
| 224 | + <span class="map-toggle3d"><button type="button" id="map2d" class="st-tab on">2D</button><button type="button" id="map3d" class="st-tab">3D</button></span> |
| 225 | + <span id="mapLegend" class="map-legend"></span> |
| 226 | + <button type="button" id="mapReset" class="map-reset">Reset view</button> |
| 227 | + </div> |
| 228 | + <div class="map-canvas-wrap"><canvas id="flavorMap" width="960" height="560"></canvas> |
218 | 229 | <div id="mapTip" class="map-tip" style="display:none"></div></div> |
| 230 | + <div class="map-key">Each dot is a molecule; <b>nearby dots are structurally similar</b> |
| 231 | + (UMAP over Morgan fingerprints, Jaccard/Tanimoto). Color = <b>dominant taste</b>; tight |
| 232 | + groups are structural families. Axes have no units — only <i>relative</i> position matters. |
| 233 | + <b>2D:</b> scroll to zoom, drag to pan. <b>3D:</b> drag to rotate. Hover for the molecule, |
| 234 | + click to open its read.</div> |
219 | 235 | </div> |
220 | 236 | </div> |
221 | 237 |
|
@@ -624,43 +640,78 @@ <h2>Aroma</h2> |
624 | 640 | } |
625 | 641 | initBrowse(); |
626 | 642 |
|
627 | | -// --- flavor-space map (t-SNE of fingerprints, colored by taste) --- |
| 643 | +// --- flavor-space map: 2D (zoom/pan) + rotatable 3D point cloud, colored by taste --- |
628 | 644 | const MAP_COLORS = {sweet:'#E0972E',bitter:'#6B4E9E',umami:'#C0563B',sour:'#9FB024',salty:'#4E84B0',other:'#C3C9C0'}; |
629 | | -let _mapPts=null, _mapDrawn=false; |
| 645 | +let _mapPts=null, _mapReady=false, _mapMode='2d', _mapProj=null; |
| 646 | +let _v2={s:1,ox:0,oy:0}, _v3={rx:-0.45,ry:0.6}, _drag=null, _dragged=false; |
| 647 | + |
630 | 648 | $('mapToggle').addEventListener('click', async ()=>{ |
631 | | - const w=$('mapWrap'); const open = w.style.display==='none'; |
632 | | - w.style.display = open?'block':'none'; $('mapCaret').textContent = open?'▾':'▸'; |
633 | | - if(open && !_mapDrawn){ await drawMap(); } |
| 649 | + const w=$('mapWrap'); const open=w.style.display==='none'; |
| 650 | + w.style.display=open?'block':'none'; $('mapCaret').textContent=open?'▾':'▸'; |
| 651 | + if(open && !_mapReady) await loadMap(); |
634 | 652 | }); |
635 | | -async function drawMap(){ |
636 | | - const cv=$('flavorMap'); const ctx=cv.getContext('2d'); |
637 | | - ctx.clearRect(0,0,cv.width,cv.height); |
| 653 | +$('map2d').addEventListener('click', ()=>setMapMode('2d')); |
| 654 | +$('map3d').addEventListener('click', ()=>setMapMode('3d')); |
| 655 | +$('mapReset').addEventListener('click', ()=>{ _v2={s:1,ox:0,oy:0}; _v3={rx:-0.45,ry:0.6}; drawMap(); }); |
| 656 | +function setMapMode(m){ _mapMode=m; $('map2d').classList.toggle('on',m==='2d'); $('map3d').classList.toggle('on',m==='3d'); drawMap(); } |
| 657 | + |
| 658 | +async function loadMap(){ |
| 659 | + const cv=$('flavorMap'), ctx=cv.getContext('2d'); |
638 | 660 | ctx.fillStyle='#8a938c'; ctx.font='13px sans-serif'; ctx.fillText('Loading…',12,22); |
639 | | - const d = await getJSON('/api/map'); _mapPts = d.points||[]; |
640 | | - if(!_mapPts.length){ ctx.clearRect(0,0,cv.width,cv.height); ctx.fillStyle='#8a938c'; ctx.fillText('Map not built yet (build_flavor_map.py).',12,22); return; } |
641 | | - const pad=14, W=cv.width, H=cv.height; |
642 | | - const px = p => pad + p.x*(W-2*pad), py = p => H-pad - p.y*(H-2*pad); |
643 | | - ctx.clearRect(0,0,W,H); |
644 | | - for(const p of _mapPts){ ctx.beginPath(); ctx.arc(px(p),py(p),2.6,0,7); ctx.fillStyle=MAP_COLORS[p.label]||MAP_COLORS.other; ctx.globalAlpha=.82; ctx.fill(); } |
645 | | - ctx.globalAlpha=1; |
646 | | - // legend |
| 661 | + const d=await getJSON('/api/map'); _mapPts=d.points||[]; |
| 662 | + if(!_mapPts.length){ ctx.clearRect(0,0,cv.width,cv.height); ctx.fillText('Map not built yet (build_flavor_map.py).',12,22); return; } |
647 | 663 | const counts={}; _mapPts.forEach(p=>counts[p.label]=(counts[p.label]||0)+1); |
648 | | - $('mapLegend').innerHTML = Object.keys(MAP_COLORS).filter(l=>counts[l]).map(l=> |
| 664 | + $('mapLegend').innerHTML=Object.keys(MAP_COLORS).filter(l=>counts[l]).map(l=> |
649 | 665 | `<span><i style="background:${MAP_COLORS[l]}"></i>${l} (${counts[l]})</span>`).join(''); |
650 | | - _mapDrawn=true; |
651 | | - // interactivity: nearest point in canvas space |
652 | | - const nearest = ev=>{ |
653 | | - const r=cv.getBoundingClientRect(), sx=cv.width/r.width, sy=cv.height/r.height; |
654 | | - const mx=(ev.clientX-r.left)*sx, my=(ev.clientY-r.top)*sy; |
655 | | - let best=null,bd=100; |
656 | | - for(const p of _mapPts){ const dx=px(p)-mx, dy=py(p)-my, dd=dx*dx+dy*dy; if(dd<bd){bd=dd;best=p;} } |
657 | | - return best; |
658 | | - }; |
| 666 | + _mapReady=true; attachMapEvents(cv); drawMap(); |
| 667 | +} |
| 668 | +function drawMap(){ _mapMode==='3d'?draw3D():draw2D(); } |
| 669 | + |
| 670 | +function draw2D(){ |
| 671 | + const cv=$('flavorMap'), ctx=cv.getContext('2d'), W=cv.width, H=cv.height, pad=18; |
| 672 | + ctx.clearRect(0,0,W,H); |
| 673 | + const px=p=>(pad+p.x*(W-2*pad))*_v2.s+_v2.ox, py=p=>(H-pad-p.y*(H-2*pad))*_v2.s+_v2.oy; |
| 674 | + for(const p of _mapPts){ ctx.beginPath(); ctx.arc(px(p),py(p),2.6,0,7); ctx.fillStyle=MAP_COLORS[p.label]||MAP_COLORS.other; ctx.globalAlpha=.8; ctx.fill(); } |
| 675 | + ctx.globalAlpha=1; _mapProj={px,py}; |
| 676 | +} |
| 677 | +function draw3D(){ |
| 678 | + const cv=$('flavorMap'), ctx=cv.getContext('2d'), W=cv.width, H=cv.height; |
| 679 | + ctx.clearRect(0,0,W,H); |
| 680 | + const cx=W/2, cy=H/2, R=Math.min(W,H)*0.66, cY=Math.cos(_v3.ry),sY=Math.sin(_v3.ry),cX=Math.cos(_v3.rx),sX=Math.sin(_v3.rx); |
| 681 | + const proj=p=>{ const X=(p.x3||0)-.5,Y=(p.y3||0)-.5,Z=(p.z3||0)-.5; |
| 682 | + const x1=X*cY-Z*sY, z1=X*sY+Z*cY, y1=Y*cX-z1*sX, z2=Y*sX+z1*cX; |
| 683 | + return {sx:cx+x1*R, sy:cy-y1*R, d:z2}; }; |
| 684 | + const all=_mapPts.map(p=>({p,pr:proj(p)})).sort((a,b)=>a.pr.d-b.pr.d); // far first |
| 685 | + for(const {p,pr} of all){ const t=pr.d+0.5; ctx.beginPath(); ctx.arc(pr.sx,pr.sy,1.7+2.1*t,0,7); ctx.fillStyle=MAP_COLORS[p.label]||MAP_COLORS.other; ctx.globalAlpha=Math.max(.22,Math.min(1,.35+.6*t)); ctx.fill(); } |
| 686 | + ctx.globalAlpha=1; _mapProj={px:p=>proj(p).sx, py:p=>proj(p).sy}; |
| 687 | +} |
| 688 | +function nearestMapPt(ev){ |
| 689 | + if(!_mapProj) return null; |
| 690 | + const cv=$('flavorMap'), r=cv.getBoundingClientRect(), sx=cv.width/r.width, sy=cv.height/r.height; |
| 691 | + const mx=(ev.clientX-r.left)*sx, my=(ev.clientY-r.top)*sy; |
| 692 | + let best=null,bd=130; |
| 693 | + for(const p of _mapPts){ const dx=_mapProj.px(p)-mx, dy=_mapProj.py(p)-my, dd=dx*dx+dy*dy; if(dd<bd){bd=dd;best=p;} } |
| 694 | + return best; |
| 695 | +} |
| 696 | +function attachMapEvents(cv){ |
659 | 697 | const tip=$('mapTip'); |
660 | | - cv.onmousemove = ev=>{ const p=nearest(ev); if(p){ const r=cv.getBoundingClientRect(); tip.style.display='block'; tip.style.left=(ev.clientX-r.left)+'px'; tip.style.top=(ev.clientY-r.top)+'px'; tip.textContent=(p.name||p.smiles)+' · '+p.label; } else tip.style.display='none'; }; |
661 | | - cv.onmouseleave = ()=> tip.style.display='none'; |
662 | | - cv.onclick = ev=>{ const p=nearest(ev); if(p){ q.value=p.smiles; window.scrollTo({top:0,behavior:'smooth'}); run(); } }; |
| 698 | + cv.addEventListener('mousedown', ev=>{ _drag={x:ev.clientX,y:ev.clientY,v2:{..._v2},v3:{..._v3}}; _dragged=false; }); |
| 699 | + window.addEventListener('mouseup', ()=>{ _drag=null; }); |
| 700 | + cv.addEventListener('mousemove', ev=>{ |
| 701 | + if(_drag){ _dragged=true; const dx=ev.clientX-_drag.x, dy=ev.clientY-_drag.y; |
| 702 | + if(_mapMode==='2d'){ _v2.ox=_drag.v2.ox+dx; _v2.oy=_drag.v2.oy+dy; } |
| 703 | + else { _v3.ry=_drag.v3.ry+dx*0.008; _v3.rx=_drag.v3.rx+dy*0.008; } |
| 704 | + drawMap(); tip.style.display='none'; return; } |
| 705 | + const p=nearestMapPt(ev); _mapLastHover=p; |
| 706 | + if(p){ const r=cv.getBoundingClientRect(); tip.style.display='block'; tip.style.left=(ev.clientX-r.left)+'px'; tip.style.top=(ev.clientY-r.top)+'px'; |
| 707 | + tip.innerHTML=`<b>${p.name||p.smiles}</b><br><span style="color:${MAP_COLORS[p.label]}">●</span> ${p.label} · <span style="opacity:.8">${p.smiles}</span>`; } |
| 708 | + else tip.style.display='none'; |
| 709 | + }); |
| 710 | + cv.addEventListener('mouseleave', ()=>{ tip.style.display='none'; }); |
| 711 | + cv.addEventListener('wheel', ev=>{ if(_mapMode!=='2d')return; ev.preventDefault(); _v2.s*=(ev.deltaY<0?1.12:0.89); drawMap(); }, {passive:false}); |
| 712 | + cv.addEventListener('click', ()=>{ if(_dragged){ _dragged=false; return; } const p=_mapLastHover; if(p){ q.value=p.smiles; window.scrollTo({top:0,behavior:'smooth'}); run(); } }); |
663 | 713 | } |
| 714 | +let _mapLastHover=null; |
664 | 715 |
|
665 | 716 | // --- mixture mode --- |
666 | 717 | function mixAddRow(val){ |
|
0 commit comments