epicure-explorer / static /index.html
0xClemo's picture
Upload static/index.html with huggingface_hub
9142f7b verified
<!DOCTYPE html>
<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 &amp; 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 &amp; 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}')">&times;</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>