Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Epicure Explorer</title> | |
| <style> | |
| :root { | |
| --bg: #0e0e0e; | |
| --surface: #161616; | |
| --surface2: #1e1e1e; | |
| --surface3: #272727; | |
| --border: #2e2e2e; | |
| --text: #e8e3dc; | |
| --muted: #7a7369; | |
| --accent: #c9956a; | |
| --accent2: #8fb88a; | |
| --accent3: #7aaec9; | |
| --danger: #c97a6a; | |
| --radius: 10px; | |
| --font: 'Inter', system-ui, sans-serif; | |
| --mono: 'JetBrains Mono', 'Fira Code', monospace; | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: var(--font); | |
| font-size: 14px; | |
| line-height: 1.6; | |
| min-height: 100vh; | |
| } | |
| /* ββ Header ββ */ | |
| header { | |
| border-bottom: 1px solid var(--border); | |
| padding: 20px 32px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| position: sticky; | |
| top: 0; | |
| background: rgba(14,14,14,0.92); | |
| backdrop-filter: blur(12px); | |
| z-index: 100; | |
| } | |
| .logo { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .logo-icon { font-size: 22px; } | |
| .logo-text { font-size: 17px; font-weight: 600; letter-spacing: -0.3px; } | |
| .logo-sub { color: var(--muted); font-size: 12px; margin-top: -2px; } | |
| .model-badge { | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| padding: 4px 10px; | |
| border-radius: 20px; | |
| font-size: 11px; | |
| color: var(--muted); | |
| font-family: var(--mono); | |
| } | |
| /* ββ Nav tabs ββ */ | |
| nav { | |
| display: flex; | |
| gap: 4px; | |
| padding: 16px 32px 0; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .tab { | |
| padding: 8px 18px; | |
| border-radius: 8px 8px 0 0; | |
| cursor: pointer; | |
| font-size: 13px; | |
| font-weight: 500; | |
| color: var(--muted); | |
| border: 1px solid transparent; | |
| border-bottom: none; | |
| transition: all 0.15s; | |
| user-select: none; | |
| background: transparent; | |
| } | |
| .tab:hover { color: var(--text); background: var(--surface2); } | |
| .tab.active { | |
| color: var(--text); | |
| background: var(--surface); | |
| border-color: var(--border); | |
| border-bottom-color: var(--surface); | |
| margin-bottom: -1px; | |
| } | |
| .tab .tab-icon { margin-right: 6px; } | |
| /* ββ Main layout ββ */ | |
| main { | |
| max-width: 900px; | |
| margin: 0 auto; | |
| padding: 32px 32px 80px; | |
| } | |
| .panel { display: none; } | |
| .panel.active { display: block; } | |
| .panel-title { | |
| font-size: 20px; | |
| font-weight: 600; | |
| letter-spacing: -0.3px; | |
| margin-bottom: 6px; | |
| } | |
| .panel-desc { | |
| color: var(--muted); | |
| margin-bottom: 28px; | |
| font-size: 13px; | |
| line-height: 1.5; | |
| } | |
| /* ββ Input group ββ */ | |
| .input-group { | |
| display: flex; | |
| gap: 8px; | |
| align-items: flex-start; | |
| flex-wrap: wrap; | |
| margin-bottom: 24px; | |
| } | |
| .input-wrap { position: relative; flex: 1; min-width: 200px; } | |
| input[type="text"], select { | |
| width: 100%; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 10px 14px; | |
| color: var(--text); | |
| font-family: var(--font); | |
| font-size: 14px; | |
| outline: none; | |
| transition: border-color 0.15s; | |
| } | |
| input[type="text"]:focus, select:focus { | |
| border-color: var(--accent); | |
| } | |
| select { | |
| cursor: pointer; | |
| appearance: none; | |
| background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%237a7369' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); | |
| background-repeat: no-repeat; | |
| background-position: right 12px center; | |
| padding-right: 32px; | |
| } | |
| select option { background: var(--surface3); } | |
| /* Autocomplete dropdown */ | |
| .autocomplete-list { | |
| position: absolute; | |
| top: calc(100% + 4px); | |
| left: 0; right: 0; | |
| background: var(--surface3); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| max-height: 220px; | |
| overflow-y: auto; | |
| z-index: 50; | |
| display: none; | |
| scrollbar-width: thin; | |
| scrollbar-color: var(--border) transparent; | |
| } | |
| .autocomplete-list.open { display: block; } | |
| .autocomplete-item { | |
| padding: 8px 14px; | |
| cursor: pointer; | |
| font-family: var(--mono); | |
| font-size: 13px; | |
| color: var(--text); | |
| transition: background 0.1s; | |
| } | |
| .autocomplete-item:hover, .autocomplete-item.selected { | |
| background: var(--surface2); | |
| color: var(--accent); | |
| } | |
| .autocomplete-item mark { | |
| background: transparent; | |
| color: var(--accent); | |
| font-weight: 600; | |
| } | |
| /* ββ Slider ββ */ | |
| .slider-wrap { | |
| flex: 1; min-width: 200px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 4px; | |
| } | |
| .slider-label { | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 12px; | |
| color: var(--muted); | |
| padding: 0 2px; | |
| } | |
| .slider-val { color: var(--accent); font-family: var(--mono); } | |
| input[type="range"] { | |
| width: 100%; | |
| accent-color: var(--accent); | |
| cursor: pointer; | |
| height: 4px; | |
| } | |
| /* ββ Button ββ */ | |
| .btn { | |
| background: var(--accent); | |
| color: #0e0e0e; | |
| border: none; | |
| border-radius: var(--radius); | |
| padding: 10px 20px; | |
| font-size: 14px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: opacity 0.15s, transform 0.1s; | |
| white-space: nowrap; | |
| font-family: var(--font); | |
| } | |
| .btn:hover { opacity: 0.88; } | |
| .btn:active { transform: scale(0.97); } | |
| .btn:disabled { opacity: 0.4; cursor: not-allowed; } | |
| .btn-secondary { | |
| background: var(--surface2); | |
| color: var(--text); | |
| border: 1px solid var(--border); | |
| } | |
| .btn-secondary:hover { background: var(--surface3); } | |
| /* ββ Results ββ */ | |
| .results-area { | |
| margin-top: 8px; | |
| } | |
| .results-title { | |
| font-size: 12px; | |
| color: var(--muted); | |
| text-transform: uppercase; | |
| letter-spacing: 0.8px; | |
| margin-bottom: 12px; | |
| font-weight: 600; | |
| } | |
| /* Score bar cards */ | |
| .result-list { display: flex; flex-direction: column; gap: 6px; } | |
| .result-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 10px 14px; | |
| transition: border-color 0.15s, background 0.15s; | |
| cursor: default; | |
| } | |
| .result-item:hover { | |
| border-color: var(--accent); | |
| background: var(--surface3); | |
| } | |
| .result-rank { | |
| font-family: var(--mono); | |
| font-size: 11px; | |
| color: var(--muted); | |
| width: 20px; | |
| text-align: right; | |
| flex-shrink: 0; | |
| } | |
| .result-name { | |
| font-family: var(--mono); | |
| font-size: 13px; | |
| font-weight: 500; | |
| flex: 1; | |
| color: var(--text); | |
| } | |
| .result-bar-wrap { | |
| width: 100px; | |
| height: 4px; | |
| background: var(--surface3); | |
| border-radius: 2px; | |
| overflow: hidden; | |
| flex-shrink: 0; | |
| } | |
| .result-bar { | |
| height: 100%; | |
| border-radius: 2px; | |
| background: var(--accent); | |
| transition: width 0.4s ease; | |
| } | |
| .result-score { | |
| font-family: var(--mono); | |
| font-size: 11px; | |
| color: var(--muted); | |
| width: 40px; | |
| text-align: right; | |
| flex-shrink: 0; | |
| } | |
| .result-use-btn { | |
| font-size: 11px; | |
| padding: 3px 9px; | |
| border-radius: 6px; | |
| background: transparent; | |
| border: 1px solid var(--border); | |
| color: var(--muted); | |
| cursor: pointer; | |
| font-family: var(--font); | |
| transition: all 0.1s; | |
| flex-shrink: 0; | |
| } | |
| .result-use-btn:hover { | |
| border-color: var(--accent); | |
| color: var(--accent); | |
| background: rgba(201,149,106,0.08); | |
| } | |
| /* Mode cards */ | |
| .mode-list { display: flex; flex-direction: column; gap: 8px; } | |
| .mode-item { | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 14px 16px; | |
| } | |
| .mode-id { | |
| font-family: var(--mono); | |
| font-size: 11px; | |
| color: var(--accent3); | |
| margin-bottom: 4px; | |
| } | |
| .mode-desc { | |
| font-size: 14px; | |
| color: var(--text); | |
| margin-bottom: 8px; | |
| } | |
| .mode-score-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .mode-bar-wrap { | |
| flex: 1; | |
| height: 3px; | |
| background: var(--surface3); | |
| border-radius: 2px; | |
| overflow: hidden; | |
| } | |
| .mode-bar { | |
| height: 100%; | |
| border-radius: 2px; | |
| background: var(--accent3); | |
| } | |
| .mode-score { | |
| font-family: var(--mono); | |
| font-size: 11px; | |
| color: var(--muted); | |
| } | |
| /* ββ Empty / loading / error states ββ */ | |
| .state-box { | |
| border: 1px dashed var(--border); | |
| border-radius: var(--radius); | |
| padding: 40px; | |
| text-align: center; | |
| color: var(--muted); | |
| font-size: 13px; | |
| } | |
| .state-box .icon { font-size: 28px; margin-bottom: 10px; } | |
| .loading-dots span { | |
| display: inline-block; | |
| animation: blink 1.2s infinite; | |
| font-size: 20px; | |
| line-height: 1; | |
| } | |
| .loading-dots span:nth-child(2) { animation-delay: 0.2s; } | |
| .loading-dots span:nth-child(3) { animation-delay: 0.4s; } | |
| @keyframes blink { | |
| 0%, 80%, 100% { opacity: 0.2; } | |
| 40% { opacity: 1; } | |
| } | |
| /* ββ Compass viz for SLERP ββ */ | |
| .compass-wrap { | |
| display: flex; | |
| gap: 24px; | |
| align-items: flex-start; | |
| flex-wrap: wrap; | |
| margin-bottom: 16px; | |
| } | |
| .compass-info { | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 14px 18px; | |
| font-size: 13px; | |
| line-height: 1.8; | |
| flex: 1; | |
| min-width: 200px; | |
| } | |
| .compass-info .label { color: var(--muted); } | |
| .compass-info .val { color: var(--accent); font-family: var(--mono); font-weight: 600; } | |
| .compass-info .arrow { color: var(--muted); margin: 0 6px; } | |
| /* ββ Info chips ββ */ | |
| .chip-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 20px; } | |
| .chip { | |
| padding: 4px 10px; | |
| border-radius: 20px; | |
| font-size: 11px; | |
| border: 1px solid var(--border); | |
| color: var(--muted); | |
| background: var(--surface2); | |
| cursor: pointer; | |
| transition: all 0.1s; | |
| font-family: var(--mono); | |
| } | |
| .chip:hover { | |
| border-color: var(--accent); | |
| color: var(--accent); | |
| background: rgba(201,149,106,0.08); | |
| } | |
| /* ββ Section divider ββ */ | |
| .divider { | |
| border: none; | |
| border-top: 1px solid var(--border); | |
| margin: 28px 0; | |
| } | |
| /* ββ Responsive ββ */ | |
| @media (max-width: 600px) { | |
| header { padding: 14px 16px; } | |
| nav { padding: 12px 16px 0; } | |
| main { padding: 20px 16px 60px; } | |
| .input-group { flex-direction: column; } | |
| .btn { width: 100%; } | |
| } | |
| /* ββ Graph panel ββ */ | |
| #graph-svg-wrap { | |
| width: 100%; | |
| height: 500px; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| overflow: hidden; | |
| position: relative; | |
| margin-bottom: 12px; | |
| } | |
| #graph-svg-wrap svg { width: 100%; height: 100%; display: block; } | |
| #graph-tooltip { | |
| position: fixed; | |
| background: var(--surface3); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| padding: 5px 10px; | |
| font-family: var(--mono); | |
| font-size: 12px; | |
| color: var(--text); | |
| pointer-events: none; | |
| opacity: 0; | |
| transition: opacity 0.12s; | |
| z-index: 200; | |
| } | |
| /* ββ Recipe Lab ββ */ | |
| .basket-area { | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 14px; | |
| min-height: 54px; | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 6px; | |
| align-items: center; | |
| margin-bottom: 16px; | |
| } | |
| .basket-empty { color: var(--muted); font-size: 13px; } | |
| .basket-chip { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 5px; | |
| background: var(--surface3); | |
| border: 1px solid var(--border); | |
| border-radius: 20px; | |
| padding: 4px 10px 4px 12px; | |
| font-family: var(--mono); | |
| font-size: 12px; | |
| color: var(--text); | |
| } | |
| .basket-chip-remove { | |
| background: none; | |
| border: none; | |
| color: var(--muted); | |
| cursor: pointer; | |
| padding: 0 2px; | |
| font-size: 13px; | |
| line-height: 1; | |
| transition: color 0.1s; | |
| } | |
| .basket-chip-remove:hover { color: var(--danger); } | |
| .coherence-box { | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 16px 20px; | |
| display: flex; | |
| align-items: center; | |
| gap: 20px; | |
| margin-bottom: 16px; | |
| flex-wrap: wrap; | |
| } | |
| .coherence-score-num { | |
| font-family: var(--mono); | |
| font-size: 36px; | |
| font-weight: 700; | |
| line-height: 1; | |
| transition: color 0.3s; | |
| } | |
| .coherence-label { | |
| font-size: 12px; | |
| color: var(--muted); | |
| text-transform: uppercase; | |
| letter-spacing: 0.8px; | |
| margin-top: 4px; | |
| font-weight: 600; | |
| } | |
| .coherence-details { flex: 1; min-width: 200px; } | |
| details.pair-table summary { | |
| font-size: 12px; | |
| color: var(--muted); | |
| cursor: pointer; | |
| user-select: none; | |
| margin-bottom: 8px; | |
| } | |
| details.pair-table summary:hover { color: var(--text); } | |
| .pair-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr auto; | |
| gap: 4px 12px; | |
| font-size: 12px; | |
| font-family: var(--mono); | |
| max-height: 200px; | |
| overflow-y: auto; | |
| } | |
| .pair-grid .ph { color: var(--muted); font-size: 11px; font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 4px; } | |
| .pair-grid .pv { color: var(--text); padding: 2px 0; } | |
| .pair-grid .ps { padding: 2px 0; } | |
| .recipe-actions { | |
| display: flex; | |
| gap: 8px; | |
| flex-wrap: wrap; | |
| margin-bottom: 20px; | |
| } | |
| .cuisine-bars { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; } | |
| .cuisine-bar-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .cuisine-bar-label { | |
| font-family: var(--mono); | |
| font-size: 12px; | |
| color: var(--text); | |
| width: 130px; | |
| flex-shrink: 0; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .cuisine-bar-track { | |
| flex: 1; | |
| height: 8px; | |
| background: var(--surface3); | |
| border-radius: 4px; | |
| overflow: hidden; | |
| } | |
| .cuisine-bar-fill { | |
| height: 100%; | |
| background: var(--accent2); | |
| border-radius: 4px; | |
| transition: width 0.5s ease; | |
| } | |
| .cuisine-bar-pct { | |
| font-family: var(--mono); | |
| font-size: 11px; | |
| color: var(--muted); | |
| width: 38px; | |
| text-align: right; | |
| flex-shrink: 0; | |
| } | |
| .recipe-result-card { | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 16px 18px; | |
| margin-top: 8px; | |
| } | |
| .recipe-result-card .card-title { | |
| font-family: var(--mono); | |
| font-size: 14px; | |
| font-weight: 600; | |
| color: var(--accent); | |
| margin-bottom: 6px; | |
| } | |
| .recipe-result-card .card-reason { | |
| font-size: 13px; | |
| color: var(--text); | |
| line-height: 1.6; | |
| } | |
| /* ββ Graph ββ */ | |
| .graph-wrap { | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| overflow: hidden; | |
| margin-bottom: 20px; | |
| } | |
| .graph-svg { | |
| width: 100%; | |
| height: 500px; | |
| display: block; | |
| } | |
| .graph-tooltip { | |
| position: absolute; | |
| background: var(--surface3); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| padding: 6px 10px; | |
| font-size: 12px; | |
| color: var(--text); | |
| pointer-events: none; | |
| opacity: 0; | |
| transition: opacity 0.15s; | |
| z-index: 200; | |
| font-family: var(--mono); | |
| } | |
| .graph-legend { | |
| display: flex; | |
| gap: 16px; | |
| padding: 10px 14px; | |
| font-size: 11px; | |
| color: var(--muted); | |
| border-top: 1px solid var(--border); | |
| } | |
| .graph-legend span { display: flex; align-items: center; gap: 5px; } | |
| .legend-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } | |
| /* ββ Recipe Lab ββ */ | |
| .basket-row { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| margin-bottom: 20px; | |
| min-height: 36px; | |
| } | |
| .basket-chip { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 5px 12px; | |
| border-radius: 20px; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| font-family: var(--mono); | |
| font-size: 13px; | |
| color: var(--text); | |
| } | |
| .basket-chip .remove { | |
| cursor: pointer; | |
| color: var(--muted); | |
| font-size: 14px; | |
| line-height: 1; | |
| padding: 0 2px; | |
| } | |
| .basket-chip .remove:hover { color: var(--danger); } | |
| .coherence-box { | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 16px 20px; | |
| margin-bottom: 20px; | |
| display: flex; | |
| align-items: center; | |
| gap: 20px; | |
| flex-wrap: wrap; | |
| } | |
| .coherence-number { | |
| font-size: 32px; | |
| font-weight: 700; | |
| font-family: var(--mono); | |
| line-height: 1; | |
| } | |
| .coherence-number.green { color: var(--accent2); } | |
| .coherence-number.amber { color: var(--accent); } | |
| .coherence-number.red { color: var(--danger); } | |
| .coherence-label { | |
| font-size: 12px; | |
| color: var(--muted); | |
| text-transform: uppercase; | |
| letter-spacing: 0.8px; | |
| } | |
| .coherence-detail { | |
| font-size: 12px; | |
| color: var(--muted); | |
| margin-top: 4px; | |
| } | |
| .recipe-actions { | |
| display: flex; | |
| gap: 8px; | |
| flex-wrap: wrap; | |
| margin-bottom: 20px; | |
| } | |
| .cuisine-bar-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| margin-bottom: 6px; | |
| } | |
| .cuisine-bar-label { | |
| width: 140px; | |
| font-size: 12px; | |
| color: var(--muted); | |
| text-align: right; | |
| flex-shrink: 0; | |
| } | |
| .cuisine-bar-track { | |
| flex: 1; | |
| height: 6px; | |
| background: var(--surface3); | |
| border-radius: 3px; | |
| overflow: hidden; | |
| } | |
| .cuisine-bar-fill { | |
| height: 100%; | |
| border-radius: 3px; | |
| background: var(--accent2); | |
| transition: width 0.5s ease; | |
| } | |
| .cuisine-bar-score { | |
| width: 50px; | |
| font-family: var(--mono); | |
| font-size: 11px; | |
| color: var(--muted); | |
| text-align: right; | |
| flex-shrink: 0; | |
| } | |
| .pairwise-table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| font-size: 12px; | |
| margin-top: 8px; | |
| } | |
| .pairwise-table th, .pairwise-table td { | |
| padding: 6px 10px; | |
| text-align: left; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .pairwise-table th { | |
| color: var(--muted); | |
| font-weight: 500; | |
| font-size: 11px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| } | |
| .pairwise-table td { font-family: var(--mono); } | |
| </style> | |
| <script src="https://d3js.org/d3.v7.min.js"></script> | |
| <script src="https://d3js.org/d3.v7.min.js"></script> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="logo"> | |
| <span class="logo-icon">πΏ</span> | |
| <div> | |
| <div class="logo-text">Epicure Explorer</div> | |
| <div class="logo-sub">Ingredient embedding playground</div> | |
| </div> | |
| </div> | |
| <div class="model-badge" id="modelBadge">loadingβ¦</div> | |
| </header> | |
| <nav> | |
| <button class="tab active" onclick="switchTab('sub')" id="tab-sub"> | |
| <span class="tab-icon">π</span>Substitution & Experiments | |
| </button> | |
| <button class="tab" onclick="switchTab('compass')" id="tab-compass"> | |
| <span class="tab-icon">π§</span>Cuisine Compass | |
| </button> | |
| <button class="tab" onclick="switchTab('modes')" id="tab-modes"> | |
| <span class="tab-icon">π¨</span>Flavour Profile | |
| </button> | |
| <button class="tab" onclick="switchTab('graph')" id="tab-graph"> | |
| <span class="tab-icon">πΈοΈ</span>Ingredient Graph | |
| </button> | |
| <button class="tab" onclick="switchTab('recipe')" id="tab-recipe"> | |
| <span class="tab-icon">π§ͺ</span>Recipe Lab | |
| </button> | |
| </nav> | |
| <main> | |
| <!-- βββββββββββββββββββ PANEL 1: SUBSTITUTION βββββββββββββββββββ --> | |
| <div class="panel active" id="panel-sub"> | |
| <div class="panel-title">Substitution & Experimental Combos</div> | |
| <div class="panel-desc"> | |
| Find what's closest to an ingredient β either as a direct substitute (recipe-context mode) | |
| or as an unexpected chemistry-compatible pairing (chemistry mode). | |
| Use the model toggle to switch between spaces. | |
| </div> | |
| <div class="input-group"> | |
| <div class="input-wrap" style="flex:2"> | |
| <input type="text" id="sub-input" placeholder="Type an ingredientβ¦" autocomplete="off" spellcheck="false"> | |
| <div class="autocomplete-list" id="sub-ac"></div> | |
| </div> | |
| <select id="sub-model" style="max-width:160px"> | |
| <option value="core" selected>Core (blend)</option> | |
| <option value="cooc">Cooc (recipe)</option> | |
| <option value="chem">Chem (chemistry)</option> | |
| </select> | |
| <select id="sub-k" style="max-width:90px"> | |
| <option value="8">8 results</option> | |
| <option value="12" selected>12 results</option> | |
| <option value="20">20 results</option> | |
| </select> | |
| <button class="btn" id="sub-btn" onclick="runNeighbors()">Find β</button> | |
| </div> | |
| <div class="chip-row"> | |
| <span style="font-size:11px;color:var(--muted);align-self:center">Try:</span> | |
| <span class="chip" onclick="setAndRun('sub', 'chicken')">chicken</span> | |
| <span class="chip" onclick="setAndRun('sub', 'chocolate')">chocolate</span> | |
| <span class="chip" onclick="setAndRun('sub', 'olive_oil')">olive_oil</span> | |
| <span class="chip" onclick="setAndRun('sub', 'tomato')">tomato</span> | |
| <span class="chip" onclick="setAndRun('sub', 'miso')">miso</span> | |
| <span class="chip" onclick="setAndRun('sub', 'soy_sauce')">soy_sauce</span> | |
| <span class="chip" onclick="setAndRun('sub', 'vanilla')">vanilla</span> | |
| </div> | |
| <div class="results-area" id="sub-results"> | |
| <div class="state-box"> | |
| <div class="icon">π«</div> | |
| Enter an ingredient above to find substitutes and experimental combinations | |
| </div> | |
| </div> | |
| </div> | |
| <!-- βββββββββββββββββββ PANEL 2: CUISINE COMPASS βββββββββββββββββββ --> | |
| <div class="panel" id="panel-compass"> | |
| <div class="panel-title">Cuisine Compass</div> | |
| <div class="panel-desc"> | |
| SLERP direction arithmetic β start from an ingredient and rotate it toward a cuisine pole. | |
| A small angle gives a gentle suggestion; 60β80Β° gives dramatic cuisine-shift ideas. | |
| </div> | |
| <div class="input-group"> | |
| <div class="input-wrap" style="flex:2"> | |
| <input type="text" id="compass-input" placeholder="Starting ingredientβ¦" autocomplete="off" spellcheck="false"> | |
| <div class="autocomplete-list" id="compass-ac"></div> | |
| </div> | |
| <select id="compass-cuisine" style="max-width:180px"> | |
| <option value="South_Asian">South Asian</option> | |
| <option value="East_Asian">East Asian</option> | |
| <option value="Southeast_Asian">Southeast Asian</option> | |
| <option value="French">French</option> | |
| <option value="Italian">Italian</option> | |
| <option value="Mediterranean">Mediterranean</option> | |
| <option value="Mexican">Mexican</option> | |
| <option value="American">American</option> | |
| <option value="Middle_Eastern">Middle Eastern</option> | |
| <option value="West_African">West African</option> | |
| <option value="Japanese">Japanese</option> | |
| <option value="Chinese">Chinese</option> | |
| </select> | |
| </div> | |
| <div class="input-group"> | |
| <div class="slider-wrap"> | |
| <div class="slider-label"> | |
| <span>Rotation angle</span> | |
| <span class="slider-val" id="theta-val">30Β°</span> | |
| </div> | |
| <input type="range" id="compass-theta" min="5" max="85" value="30" | |
| oninput="document.getElementById('theta-val').textContent=this.value+'Β°'"> | |
| <div class="slider-label"> | |
| <span style="font-size:11px">β gentle suggestion</span> | |
| <span style="font-size:11px">full cuisine shift β</span> | |
| </div> | |
| </div> | |
| <button class="btn" onclick="runSlerp()" style="align-self:flex-start;margin-top:2px">Explore β</button> | |
| </div> | |
| <div class="chip-row"> | |
| <span style="font-size:11px;color:var(--muted);align-self:center">Try:</span> | |
| <span class="chip" onclick="setAndRun('compass', 'rice', 'South_Asian')">rice β South Asian</span> | |
| <span class="chip" onclick="setAndRun('compass', 'butter', 'French')">butter β French</span> | |
| <span class="chip" onclick="setAndRun('compass', 'chicken', 'Mexican')">chicken β Mexican</span> | |
| <span class="chip" onclick="setAndRun('compass', 'pasta', 'Japanese')">pasta β Japanese</span> | |
| <span class="chip" onclick="setAndRun('compass', 'potato', 'Middle_Eastern')">potato β Middle Eastern</span> | |
| </div> | |
| <div class="results-area" id="compass-results"> | |
| <div class="state-box"> | |
| <div class="icon">π§</div> | |
| Pick an ingredient and a cuisine to explore what it could become | |
| </div> | |
| </div> | |
| </div> | |
| <!-- βββββββββββββββββββ PANEL 3: FLAVOUR MODES βββββββββββββββββββ --> | |
| <div class="panel" id="panel-modes"> | |
| <div class="panel-title">Flavour Profile</div> | |
| <div class="panel-desc"> | |
| Discover the aroma and flavour clusters (modes) an ingredient belongs to. | |
| Factor modes are derived from the embedding geometry; supervised modes are labelled by cuisine/flavour category. | |
| </div> | |
| <div class="input-group"> | |
| <div class="input-wrap" style="flex:2"> | |
| <input type="text" id="modes-input" placeholder="Type an ingredientβ¦" autocomplete="off" spellcheck="false"> | |
| <div class="autocomplete-list" id="modes-ac"></div> | |
| </div> | |
| <select id="modes-kind" style="max-width:160px"> | |
| <option value="factor" selected>Factor modes</option> | |
| <option value="supervised">Supervised modes</option> | |
| </select> | |
| <button class="btn" onclick="runModes()">Profile β</button> | |
| </div> | |
| <div class="chip-row"> | |
| <span style="font-size:11px;color:var(--muted);align-self:center">Try:</span> | |
| <span class="chip" onclick="setAndRun('modes', 'chocolate')">chocolate</span> | |
| <span class="chip" onclick="setAndRun('modes', 'truffle')">truffle</span> | |
| <span class="chip" onclick="setAndRun('modes', 'lemon')">lemon</span> | |
| <span class="chip" onclick="setAndRun('modes', 'fish_sauce')">fish_sauce</span> | |
| <span class="chip" onclick="setAndRun('modes', 'cinnamon')">cinnamon</span> | |
| <span class="chip" onclick="setAndRun('modes', 'coffee')">coffee</span> | |
| </div> | |
| <div class="results-area" id="modes-results"> | |
| <div class="state-box"> | |
| <div class="icon">π¨</div> | |
| Enter an ingredient to see its flavour and aroma profile | |
| </div> | |
| </div> | |
| </div> | |
| <!-- βββββββββββββββββββ PANEL 4: INGREDIENT GRAPH βββββββββββββββββββ --> | |
| <div class="panel" id="panel-graph"> | |
| <div class="panel-title">Ingredient Graph</div> | |
| <div class="panel-desc"> | |
| Force-directed neighbourhood map. The centre ingredient pulls its closest companions; | |
| cross-edges show which neighbours are also similar to each other β revealing natural clusters. | |
| Click any node to re-root the graph there. | |
| </div> | |
| <div class="input-group"> | |
| <div class="input-wrap" style="flex:2"> | |
| <input type="text" id="graph-input" placeholder="Type an ingredientβ¦" autocomplete="off" spellcheck="false"> | |
| <div class="autocomplete-list" id="graph-ac"></div> | |
| </div> | |
| <select id="graph-model" style="max-width:160px"> | |
| <option value="core" selected>Core (blend)</option> | |
| <option value="cooc">Cooc (recipe)</option> | |
| <option value="chem">Chem (chemistry)</option> | |
| </select> | |
| <button class="btn" onclick="runGraph()">Map β</button> | |
| </div> | |
| <div class="chip-row"> | |
| <span style="font-size:11px;color:var(--muted);align-self:center">Try:</span> | |
| <span class="chip" onclick="setAndRun('graph', 'truffle')">truffle</span> | |
| <span class="chip" onclick="setAndRun('graph', 'chocolate')">chocolate</span> | |
| <span class="chip" onclick="setAndRun('graph', 'kimchi')">kimchi</span> | |
| <span class="chip" onclick="setAndRun('graph', 'miso')">miso</span> | |
| <span class="chip" onclick="setAndRun('graph', 'vanilla')">vanilla</span> | |
| <span class="chip" onclick="setAndRun('graph', 'saffron')">saffron</span> | |
| </div> | |
| <div class="results-area" id="graph-results"> | |
| <div class="state-box"> | |
| <div class="icon">πΈοΈ</div> | |
| Enter an ingredient to explore its flavour neighbourhood | |
| </div> | |
| </div> | |
| </div> | |
| <!-- βββββββββββββββββββ PANEL 5: RECIPE LAB βββββββββββββββββββ --> | |
| <div class="panel" id="panel-recipe"> | |
| <div class="panel-title">Recipe Lab</div> | |
| <div class="panel-desc"> | |
| Build a basket of ingredients and analyse coherence, get suggestions, | |
| discover cuisine affinity, or find a surprising chemistry-compatible addition. | |
| </div> | |
| <div class="input-group"> | |
| <div class="input-wrap" style="flex:2"> | |
| <input type="text" id="recipe-input" placeholder="Add an ingredientβ¦" autocomplete="off" spellcheck="false"> | |
| <div class="autocomplete-list" id="recipe-ac"></div> | |
| </div> | |
| <button class="btn" onclick="addToRecipeBasket()">Add</button> | |
| </div> | |
| <div class="basket-row" id="recipe-basket"></div> | |
| <div class="coherence-box" id="recipe-coherence" style="display:none"> | |
| <div> | |
| <div class="coherence-number" id="coherence-num">β</div> | |
| <div class="coherence-label">Coherence</div> | |
| </div> | |
| <div style="flex:1;min-width:200px"> | |
| <div id="coherence-label" style="font-size:14px;font-weight:600;margin-bottom:4px">β</div> | |
| <div class="coherence-detail" id="coherence-detail">Add ingredients to see how well they work together</div> | |
| </div> | |
| </div> | |
| <div class="recipe-actions" id="recipe-actions" style="display:none"> | |
| <button class="btn btn-secondary" onclick="recipeSuggest()">π‘ Suggest next</button> | |
| <button class="btn btn-secondary" onclick="recipeAffinity()">π Cuisine affinity</button> | |
| <button class="btn btn-secondary" onclick="recipeSurprise()">π² Surprise me</button> | |
| </div> | |
| <div class="results-area" id="recipe-results"></div> | |
| </div> | |
| </main> | |
| <script> | |
| // ββ Config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const API = ''; // relative β same origin | |
| // ββ State βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let vocab = []; | |
| let activeTab = 'sub'; | |
| // ββ Init ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function init() { | |
| try { | |
| const health = await fetch(`${API}/api/health`).then(r => r.json()); | |
| document.getElementById('modelBadge').textContent = health.model?.split('/').pop() ?? 'loaded'; | |
| const res = await fetch(`${API}/api/ingredients`).then(r => r.json()); | |
| vocab = res.ingredients ?? []; | |
| } catch(e) { | |
| document.getElementById('modelBadge').textContent = 'loadingβ¦'; | |
| // retry after 5s (model might still be starting up) | |
| setTimeout(init, 5000); | |
| } | |
| } | |
| init(); | |
| // ββ Tab switching βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function switchTab(name) { | |
| activeTab = name; | |
| document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); | |
| document.querySelectorAll('.panel').forEach(p => p.classList.remove('active')); | |
| document.getElementById('tab-' + name).classList.add('active'); | |
| document.getElementById('panel-' + name).classList.add('active'); | |
| } | |
| // ββ Autocomplete ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function setupAutocomplete(inputId, acId, onSelect) { | |
| const input = document.getElementById(inputId); | |
| const ac = document.getElementById(acId); | |
| let idx = -1; | |
| function renderAc(q) { | |
| if (!q || q.length < 1) { ac.classList.remove('open'); return; } | |
| const matches = vocab.filter(v => v.toLowerCase().includes(q.toLowerCase())).slice(0, 40); | |
| if (!matches.length) { ac.classList.remove('open'); return; } | |
| ac.innerHTML = matches.map((m, i) => { | |
| const hi = m.replace(new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), s => `<mark>${s}</mark>`); | |
| return `<div class="autocomplete-item" data-val="${m}" onclick="selectAc('${inputId}','${acId}','${m}')">${hi}</div>`; | |
| }).join(''); | |
| ac.classList.add('open'); | |
| idx = -1; | |
| } | |
| input.addEventListener('input', () => renderAc(input.value)); | |
| input.addEventListener('keydown', e => { | |
| const items = ac.querySelectorAll('.autocomplete-item'); | |
| if (e.key === 'ArrowDown') { idx = Math.min(idx+1, items.length-1); items.forEach((it,i) => it.classList.toggle('selected', i===idx)); e.preventDefault(); } | |
| else if (e.key === 'ArrowUp') { idx = Math.max(idx-1, 0); items.forEach((it,i) => it.classList.toggle('selected', i===idx)); e.preventDefault(); } | |
| else if (e.key === 'Enter') { | |
| if (idx >= 0 && items[idx]) { selectAc(inputId, acId, items[idx].dataset.val); if(onSelect) onSelect(items[idx].dataset.val); } | |
| else { ac.classList.remove('open'); if(onSelect) onSelect(input.value); } | |
| } | |
| else if (e.key === 'Escape') { ac.classList.remove('open'); } | |
| }); | |
| input.addEventListener('focus', () => { if (input.value) renderAc(input.value); }); | |
| document.addEventListener('click', e => { if (!input.contains(e.target) && !ac.contains(e.target)) ac.classList.remove('open'); }); | |
| } | |
| function selectAc(inputId, acId, val) { | |
| document.getElementById(inputId).value = val; | |
| document.getElementById(acId).classList.remove('open'); | |
| } | |
| setupAutocomplete('sub-input', 'sub-ac', () => runNeighbors()); | |
| setupAutocomplete('compass-input', 'compass-ac', () => runSlerp()); | |
| setupAutocomplete('modes-input', 'modes-ac', () => runModes()); | |
| // ββ Quick-try chips βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function setAndRun(panel, ingredient, cuisine) { | |
| switchTab(panel); | |
| if (panel === 'sub') { | |
| document.getElementById('sub-input').value = ingredient; | |
| runNeighbors(); | |
| } else if (panel === 'compass') { | |
| document.getElementById('compass-input').value = ingredient; | |
| if (cuisine) document.getElementById('compass-cuisine').value = cuisine; | |
| runSlerp(); | |
| } else if (panel === 'modes') { | |
| document.getElementById('modes-input').value = ingredient; | |
| runModes(); | |
| } | |
| } | |
| // ββ Shared helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function loading(containerId) { | |
| document.getElementById(containerId).innerHTML = ` | |
| <div class="state-box"> | |
| <div class="loading-dots"><span>β</span><span>β</span><span>β</span></div> | |
| <div style="margin-top:8px;font-size:12px">Computingβ¦</div> | |
| </div>`; | |
| } | |
| function error(containerId, msg) { | |
| document.getElementById(containerId).innerHTML = ` | |
| <div class="state-box" style="border-color:var(--danger);color:var(--danger)"> | |
| <div class="icon">β οΈ</div>${msg} | |
| </div>`; | |
| } | |
| function scoreColor(s) { | |
| if (s >= 0.7) return 'var(--accent2)'; | |
| if (s >= 0.5) return 'var(--accent)'; | |
| return 'var(--accent3)'; | |
| } | |
| // ββ NEIGHBORS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function runNeighbors() { | |
| const ing = document.getElementById('sub-input').value.trim(); | |
| const k = document.getElementById('sub-k').value; | |
| if (!ing) return; | |
| document.getElementById('sub-ac').classList.remove('open'); | |
| loading('sub-results'); | |
| try { | |
| const data = await fetch(`${API}/api/neighbors?ingredient=${encodeURIComponent(ing)}&k=${k}`).then(r => { | |
| if (!r.ok) return r.json().then(e => { throw new Error(e.detail || r.statusText); }); | |
| return r.json(); | |
| }); | |
| const max = data.neighbors[0]?.score ?? 1; | |
| const html = ` | |
| <div class="results-title">Nearest neighbours of <span style="color:var(--accent);font-family:var(--mono)">${data.ingredient}</span></div> | |
| <div class="result-list"> | |
| ${data.neighbors.map((n,i) => ` | |
| <div class="result-item"> | |
| <span class="result-rank">${i+1}</span> | |
| <span class="result-name">${n.name}</span> | |
| <div class="result-bar-wrap"> | |
| <div class="result-bar" style="width:${Math.round((n.score/max)*100)}%;background:${scoreColor(n.score)}"></div> | |
| </div> | |
| <span class="result-score">${n.score.toFixed(3)}</span> | |
| <button class="result-use-btn" onclick="setAndRun('sub','${n.name}')">explore β</button> | |
| </div>`).join('')} | |
| </div>`; | |
| document.getElementById('sub-results').innerHTML = html; | |
| } catch(e) { | |
| error('sub-results', e.message); | |
| } | |
| } | |
| // ββ SLERP βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function runSlerp() { | |
| const ing = document.getElementById('compass-input').value.trim(); | |
| const cuisine = document.getElementById('compass-cuisine').value; | |
| const theta = document.getElementById('compass-theta').value; | |
| if (!ing) return; | |
| document.getElementById('compass-ac').classList.remove('open'); | |
| loading('compass-results'); | |
| try { | |
| const data = await fetch( | |
| `${API}/api/slerp?ingredient=${encodeURIComponent(ing)}&direction=${encodeURIComponent(cuisine)}&theta=${theta}&k=12` | |
| ).then(r => { | |
| if (!r.ok) return r.json().then(e => { throw new Error(e.detail || r.statusText); }); | |
| return r.json(); | |
| }); | |
| const cuisineLabel = cuisine.replace(/_/g,' '); | |
| const max = data.suggestions[0]?.score ?? 1; | |
| const html = ` | |
| <div class="compass-wrap"> | |
| <div class="compass-info"> | |
| <div><span class="label">From</span> <span class="val">${data.ingredient}</span></div> | |
| <div><span class="label">Toward</span> <span class="val">${cuisineLabel}</span></div> | |
| <div><span class="label">Angle</span> <span class="val">${data.theta_deg}Β°</span></div> | |
| </div> | |
| </div> | |
| <div class="results-title">Ingredients that emerge at this point</div> | |
| <div class="result-list"> | |
| ${data.suggestions.map((n,i) => ` | |
| <div class="result-item"> | |
| <span class="result-rank">${i+1}</span> | |
| <span class="result-name">${n.name}</span> | |
| <div class="result-bar-wrap"> | |
| <div class="result-bar" style="width:${Math.round((n.score/max)*100)}%;background:var(--accent2)"></div> | |
| </div> | |
| <span class="result-score">${n.score.toFixed(3)}</span> | |
| <button class="result-use-btn" onclick="setAndRun('sub','${n.name}')">substitutes β</button> | |
| </div>`).join('')} | |
| </div>`; | |
| document.getElementById('compass-results').innerHTML = html; | |
| } catch(e) { | |
| error('compass-results', e.message); | |
| } | |
| } | |
| // ββ MODES βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function runModes() { | |
| const ing = document.getElementById('modes-input').value.trim(); | |
| const kind = document.getElementById('modes-kind').value; | |
| if (!ing) return; | |
| document.getElementById('modes-ac').classList.remove('open'); | |
| loading('modes-results'); | |
| try { | |
| const data = await fetch(`${API}/api/modes?ingredient=${encodeURIComponent(ing)}&kind=${kind}&k=5`).then(r => { | |
| if (!r.ok) return r.json().then(e => { throw new Error(e.detail || r.statusText); }); | |
| return r.json(); | |
| }); | |
| const max = data.modes[0]?.score ?? 1; | |
| const html = ` | |
| <div class="results-title">Flavour modes for <span style="color:var(--accent);font-family:var(--mono)">${data.ingredient}</span> <span style="color:var(--muted)">(${kind})</span></div> | |
| <div class="mode-list"> | |
| ${data.modes.map(m => ` | |
| <div class="mode-item"> | |
| <div class="mode-id">${m.id}</div> | |
| <div class="mode-desc">${m.description}</div> | |
| <div class="mode-score-row"> | |
| <div class="mode-bar-wrap"> | |
| <div class="mode-bar" style="width:${Math.round((m.score/max)*100)}%"></div> | |
| </div> | |
| <span class="mode-score">${m.score.toFixed(4)}</span> | |
| </div> | |
| </div>`).join('')} | |
| </div>`; | |
| document.getElementById('modes-results').innerHTML = html; | |
| } catch(e) { | |
| error('modes-results', e.message); | |
| } | |
| } | |
| // ββ Enter key shortcut ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| document.addEventListener('keydown', e => { | |
| if (e.key === 'Enter' && document.activeElement.tagName === 'INPUT') { | |
| if (activeTab === 'sub') runNeighbors(); | |
| else if (activeTab === 'compass') runSlerp(); | |
| else if (activeTab === 'modes') runModes(); | |
| else if (activeTab === 'graph') runGraph(); | |
| else if (activeTab === 'recipe') addToRecipeBasket(); | |
| } | |
| }); | |
| // ββ GRAPH βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function runGraph() { | |
| const ing = document.getElementById('graph-input').value.trim(); | |
| if (!ing) return; | |
| document.getElementById('graph-ac').classList.remove('open'); | |
| const variant = document.getElementById('graph-model').value; | |
| loading('graph-results'); | |
| fetch(`${API}/api/graph?ingredient=${encodeURIComponent(ing)}&k=15&model_variant=${variant}`) | |
| .then(r => r.json()) | |
| .then(data => renderGraph(data)) | |
| .catch(e => error('graph-results', e.message)); | |
| } | |
| function renderGraph(data) { | |
| const container = document.getElementById('graph-results'); | |
| container.innerHTML = ` | |
| <div class="graph-wrap"> | |
| <svg class="graph-svg" id="graph-svg"></svg> | |
| <div class="graph-legend"> | |
| <span><span class="legend-dot" style="background:var(--accent)"></span> centre</span> | |
| <span><span class="legend-dot" style="background:var(--accent2)"></span> neighbour</span> | |
| <span><span class="legend-dot" style="background:var(--border)"></span> cross-edge</span> | |
| </div> | |
| </div> | |
| <div class="graph-tooltip" id="graph-tooltip"></div> | |
| `; | |
| const svg = d3.select("#graph-svg"); | |
| const width = container.clientWidth; | |
| const height = 500; | |
| svg.attr("viewBox", `0 0 ${width} ${height}`); | |
| const nodes = data.nodes.map(n => ({...n})); | |
| const links = data.edges.map(e => ({...e})); | |
| const simulation = d3.forceSimulation(nodes) | |
| .force("link", d3.forceLink(links).id(d => d.id).distance(d => 120 - d.weight * 60)) | |
| .force("charge", d3.forceManyBody().strength(-300)) | |
| .force("center", d3.forceCenter(width / 2, height / 2)) | |
| .force("collide", d3.forceCollide().radius(30)); | |
| const g = svg.append("g"); | |
| // Zoom | |
| svg.call(d3.zoom().on("zoom", (e) => g.attr("transform", e.transform))); | |
| // Links | |
| const link = g.append("g") | |
| .selectAll("line") | |
| .data(links) | |
| .join("line") | |
| .attr("stroke", d => d.source === data.center || d.source.id === data.center ? "var(--accent)" : "var(--border)") | |
| .attr("stroke-opacity", d => d.source === data.center || d.source.id === data.center ? 0.6 : 0.3) | |
| .attr("stroke-width", d => Math.max(1, d.weight * 3)); | |
| // Nodes | |
| const node = g.append("g") | |
| .selectAll("g") | |
| .data(nodes) | |
| .join("g") | |
| .attr("cursor", "pointer") | |
| .call(d3.drag() | |
| .on("start", (e, d) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }) | |
| .on("drag", (e, d) => { d.fx = e.x; d.fy = e.y; }) | |
| .on("end", (e, d) => { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; })); | |
| node.append("circle") | |
| .attr("r", d => d.is_center ? 18 : 8 + d.score * 8) | |
| .attr("fill", d => d.is_center ? "var(--accent)" : d3.interpolateRgb("var(--muted)", "var(--accent2)")(d.score)) | |
| .attr("stroke", d => d.is_center ? "var(--accent2)" : "var(--surface3)") | |
| .attr("stroke-width", d => d.is_center ? 3 : 1.5); | |
| node.append("text") | |
| .text(d => d.id.replace(/_/g, ' ')) | |
| .attr("x", 0) | |
| .attr("y", d => d.is_center ? 28 : 18) | |
| .attr("text-anchor", "middle") | |
| .attr("fill", "var(--text)") | |
| .attr("font-size", d => d.is_center ? "13px" : "10px") | |
| .attr("font-family", "var(--mono)") | |
| .attr("font-weight", d => d.is_center ? "600" : "400"); | |
| // Tooltip | |
| const tooltip = d3.select("#graph-tooltip"); | |
| node.on("mouseover", (e, d) => { | |
| tooltip.style("opacity", 1) | |
| .html(`<strong>${d.id}</strong><br>score: ${d.score.toFixed(3)}${d.is_center ? '<br>(centre)' : ''}`) | |
| .style("left", (e.pageX + 10) + "px") | |
| .style("top", (e.pageY - 10) + "px"); | |
| }).on("mouseout", () => tooltip.style("opacity", 0)); | |
| // Click to re-root | |
| node.on("click", (e, d) => { | |
| if (!d.is_center) { | |
| document.getElementById('graph-input').value = d.id; | |
| runGraph(); | |
| } | |
| }); | |
| simulation.on("tick", () => { | |
| link | |
| .attr("x1", d => d.source.x) | |
| .attr("y1", d => d.source.y) | |
| .attr("x2", d => d.target.x) | |
| .attr("y2", d => d.target.y); | |
| node.attr("transform", d => `translate(${d.x},${d.y})`); | |
| }); | |
| } | |
| // ββ RECIPE LAB ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let recipeBasket = []; | |
| let recipeDebounce = null; | |
| function addToRecipeBasket() { | |
| const input = document.getElementById('recipe-input'); | |
| const name = input.value.trim(); | |
| if (!name) return; | |
| if (!vocab.includes(name)) { | |
| error('recipe-results', `"${name}" is not in the vocabulary.`); | |
| return; | |
| } | |
| if (recipeBasket.includes(name)) return; | |
| if (recipeBasket.length >= 12) { | |
| error('recipe-results', "Basket is full (max 12 ingredients)."); | |
| return; | |
| } | |
| recipeBasket.push(name); | |
| input.value = ''; | |
| document.getElementById('recipe-ac').classList.remove('open'); | |
| renderRecipeBasket(); | |
| debouncedRecipeScore(); | |
| } | |
| function removeFromRecipeBasket(name) { | |
| recipeBasket = recipeBasket.filter(i => i !== name); | |
| renderRecipeBasket(); | |
| debouncedRecipeScore(); | |
| } | |
| function renderRecipeBasket() { | |
| const el = document.getElementById('recipe-basket'); | |
| if (recipeBasket.length === 0) { | |
| el.innerHTML = '<span style="color:var(--muted);font-size:12px">No ingredients yet. Type above to add.</span>'; | |
| document.getElementById('recipe-coherence').style.display = 'none'; | |
| document.getElementById('recipe-actions').style.display = 'none'; | |
| document.getElementById('recipe-results').innerHTML = ''; | |
| return; | |
| } | |
| el.innerHTML = recipeBasket.map(name => | |
| `<span class="basket-chip">${name}<span class="remove" onclick="removeFromRecipeBasket('${name}')">×</span></span>` | |
| ).join(''); | |
| document.getElementById('recipe-coherence').style.display = 'flex'; | |
| if (recipeBasket.length >= 2) { | |
| document.getElementById('recipe-actions').style.display = 'flex'; | |
| } else { | |
| document.getElementById('recipe-actions').style.display = 'none'; | |
| } | |
| } | |
| function debouncedRecipeScore() { | |
| if (recipeDebounce) clearTimeout(recipeDebounce); | |
| recipeDebounce = setTimeout(() => recipeScore(), 300); | |
| } | |
| async function recipeScore() { | |
| if (recipeBasket.length < 2) { | |
| document.getElementById('coherence-num').textContent = 'β'; | |
| document.getElementById('coherence-num').className = 'coherence-number'; | |
| document.getElementById('coherence-label').textContent = 'β'; | |
| document.getElementById('coherence-detail').textContent = 'Add at least 2 ingredients to score coherence'; | |
| return; | |
| } | |
| try { | |
| const res = await fetch(`${API}/api/recipe`, { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ingredients: recipeBasket, action: 'score'}) | |
| }); | |
| const data = await res.json(); | |
| const num = document.getElementById('coherence-num'); | |
| num.textContent = data.score.toFixed(3); | |
| num.className = 'coherence-number ' + (data.label === 'coherent' ? 'green' : data.label === 'mixed' ? 'amber' : 'red'); | |
| document.getElementById('coherence-label').textContent = data.label; | |
| document.getElementById('coherence-detail').textContent = `${data.pairwise.length} pairs scored`; | |
| } catch(e) { | |
| console.error('score error', e); | |
| } | |
| } | |
| async function recipeSuggest() { | |
| const resArea = document.getElementById('recipe-results'); | |
| loading(resArea.id); | |
| try { | |
| const res = await fetch(`${API}/api/recipe`, { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ingredients: recipeBasket, action: 'suggest'}) | |
| }); | |
| const data = await res.json(); | |
| resArea.innerHTML = ` | |
| <div class="results-title">Suggested addition</div> | |
| <div class="result-item" style="cursor:pointer" onclick="recipeBasketAdd('${data.suggestion}')"> | |
| <span class="result-name">${data.suggestion}</span> | |
| <span class="result-score">${data.score.toFixed(3)}</span> | |
| <button class="result-use-btn">add to basket β</button> | |
| </div> | |
| <div style="margin-top:8px;font-size:12px;color:var(--muted)">${data.reason}</div> | |
| `; | |
| } catch(e) { | |
| error('recipe-results', e.message); | |
| } | |
| } | |
| async function recipeAffinity() { | |
| const resArea = document.getElementById('recipe-results'); | |
| loading(resArea.id); | |
| try { | |
| const res = await fetch(`${API}/api/recipe`, { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ingredients: recipeBasket, action: 'affinity'}) | |
| }); | |
| const data = await res.json(); | |
| const max = data.affinities[0]?.score ?? 1; | |
| resArea.innerHTML = ` | |
| <div class="results-title">Cuisine affinity</div> | |
| ${data.affinities.map(a => ` | |
| <div class="cuisine-bar-row"> | |
| <span class="cuisine-bar-label">${a.cuisine.replace(/_/g,' ')}</span> | |
| <div class="cuisine-bar-track"> | |
| <div class="cuisine-bar-fill" style="width:${Math.round((a.score/max)*100)}%"></div> | |
| </div> | |
| <span class="cuisine-bar-score">${a.score.toFixed(3)}</span> | |
| </div> | |
| `).join('')} | |
| `; | |
| } catch(e) { | |
| error('recipe-results', e.message); | |
| } | |
| } | |
| async function recipeSurprise() { | |
| const resArea = document.getElementById('recipe-results'); | |
| loading(resArea.id); | |
| try { | |
| const res = await fetch(`${API}/api/recipe`, { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ingredients: recipeBasket, action: 'surprise'}) | |
| }); | |
| const data = await res.json(); | |
| resArea.innerHTML = ` | |
| <div class="results-title">Surprise ingredient</div> | |
| <div class="result-item" style="cursor:pointer" onclick="recipeBasketAdd('${data.suggestion}')"> | |
| <span class="result-name">${data.suggestion}</span> | |
| <span class="result-score">chem ${data.chemistry_score.toFixed(3)}</span> | |
| <button class="result-use-btn">add to basket β</button> | |
| </div> | |
| <div style="margin-top:8px;font-size:12px;color:var(--muted)"> | |
| Core similarity: ${data.core_score.toFixed(3)} (low recipe-context match, high chemistry compatibility) | |
| </div> | |
| `; | |
| } catch(e) { | |
| error('recipe-results', e.message); | |
| } | |
| } | |
| function recipeBasketAdd(name) { | |
| if (recipeBasket.includes(name) || recipeBasket.length >= 12) return; | |
| recipeBasket.push(name); | |
| renderRecipeBasket(); | |
| debouncedRecipeScore(); | |
| } | |
| // ββ Wire up autocomplete for new inputs βββββββββββββββββββββββββββββββββββββββ | |
| setupAutocomplete('graph-input', 'graph-ac', () => runGraph()); | |
| setupAutocomplete('recipe-input', 'recipe-ac', () => addToRecipeBasket()); | |
| // ββ Update setAndRun for new tabs βββββββββββββββββββββββββββββββββββββββββββββ | |
| const _origSetAndRun = setAndRun; | |
| setAndRun = function(panel, ingredient, cuisine) { | |
| if (panel === 'graph') { | |
| switchTab('graph'); | |
| document.getElementById('graph-input').value = ingredient; | |
| runGraph(); | |
| } else if (panel === 'recipe') { | |
| switchTab('recipe'); | |
| recipeBasketAdd(ingredient); | |
| } else { | |
| _origSetAndRun(panel, ingredient, cuisine); | |
| } | |
| }; | |
| </script> | |
| </body> | |
| </html> | |