Skip to content

Commit 792efae

Browse files
feat(ui): group notes into perceptual families + filter every chip (#246)
The Studio picker rendered 305 chips, 178 of them odour notes in one flat alphabetical row. Notes now group into 15 perceptual families (Citrus, Fruity, Floral, Green & vegetal, Spice, Wood/resin/ amber, Sweet & bakery, Dairy & fatty, Savoury & sulfur, Earthy/tobacco/leather, Fresh/cool/marine, Animalic & musk, Sharp & chemical, Base taste), each with a count; the largest block is 27 instead of 178. Coverage validated against the live term list — all 178 map, no dupes, no typos — and unmapped heads fall to "Other" so nothing is silently dropped. Adds one filter across every chip; empty rows, families and sections collapse as you type, selected chips stay visible, and a "no match" hint shows when nothing survives. Note chips gained tooltips. Closes #223.
1 parent 4364334 commit 792efae

1 file changed

Lines changed: 91 additions & 6 deletions

File tree

training/workbench.html

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,17 @@
503503
.studio-count.on{min-width:18px;width:auto;padding:0 6px;margin-left:8px;opacity:1}
504504
.schip{font:inherit;font-size:12px;padding:3px 11px;border:1px solid var(--line);border-radius:20px;background:var(--panel);color:var(--ink);cursor:pointer;text-transform:capitalize}
505505
.schip:hover{border-color:var(--brand-2)}
506+
/* one filter across all 300+ chips, plus perceptual family blocks so notes are scannable */
507+
.chip-filter{position:relative;margin:0 0 12px}
508+
.chip-filter input{width:100%;font:inherit;font-size:13px;padding:9px 13px;border-radius:9px;
509+
border:1px solid var(--line);background:var(--panel);color:var(--ink)}
510+
.chip-filter input:focus{outline:none;border-color:var(--brand-2)}
511+
.cf-none{position:absolute;right:12px;top:50%;transform:translateY(-50%);font-family:var(--mono);
512+
font-size:11px;color:var(--muted)}
513+
.fam{margin:0 0 10px}
514+
.fam-label{font-family:var(--mono);font-size:10.5px;letter-spacing:.07em;text-transform:uppercase;
515+
color:var(--muted);margin:0 0 5px;display:flex;align-items:baseline;gap:7px}
516+
.fam-n{font-size:10px;opacity:.6}
506517
.schip-note{border-style:dashed}
507518
/* mouthfeel chips: a distinct dotted edge + amber cast so the sensation modality reads apart
508519
from odour notes at a glance (cooling/pungent legitimately appear in both rows) */
@@ -1210,6 +1221,37 @@ <h4>Software &amp; type</h4>
12101221
alcoholic:'#9FB6C4',musky:'#8B7BA5',putrid:'#6E7A4A',soapy:'#B9C6D0',vegetable:'#6FA36B',bready:'#C7A06A',odorless:'#9AA4AC',
12111222
onion:'#B7A64B',neroli:'#F0A5A0',aldehydic:'#E8C070',animalic:'#8A6B5A',amber:'#C08A4E',leathery:'#6E5544',powdery:'#D9C6D0',resinous:'#7A8B5A',terpenic:'#6B9E5A',ozonic:'#6FC2C4',hyacinth:'#8FC46A',cooling:'#6FD0C0',metallic:'#9BB0BE',lactonic:'#E3C99A',popcorn:'#D9B87A',celery:'#A8C46A',maple:'#B5773A',chamomile:'#E8D888',tobacco:'#8A6B4A',carnation:'#D9607A',mimosa:'#E8D77A',magnolia:'#E6E29A',narcissus:'#E0C878',saffron:'#E0700E',tuberose:'#EBA9C0',gardenia:'#E8C9D6',ylang:'#D9A6C4',lilac:'#C9A6D6',osmanthus:'#E8B48A',geranium:'#D77A8E',eucalyptus:'#5FA98A',corky:'#8A8A6E',violetleaf:'#7DB36A',fir:'#3E7A5A',oakmoss:'#6E7A4A',vetiver:'#7A6B4A',patchouli:'#8A5A3A',sandalwood:'#C9A87A',cedarwood:'#9C6B4A',cognac:'#A85A2E',grapefruit:'#E8846A',bergamot:'#C8D45A',mandarin:'#EE9A2E',lime:'#8FC44A',yuzu:'#D4D46A',honeysuckle:'#E8C86A',freesia:'#E0D0A0',cyclamen:'#D98AB0',linden:'#C8D488',coriander:'#9DBE6A',cumin:'#B5883A',passionfruit:'#E0C84A',rosemary:'#5A8A6A',blackpepper:'#6E5A4A',nutmeg:'#A8703A',sage:'#8AA07A',thyme:'#7A9A5A',oregano:'#8A9A4A',mustard:'#D9C84A',pineapple:'#EBD24A',strawberry:'#E0506A',raspberry:'#C0396B',myrrh:'#8A6B4E',frankincense:'#B0A07A',musty:'#8AA07A',hazelnut:'#C6B07A',blueberry:'#C08A6A',guava:'#9AA6B0',juniper:'#C6B07A',allspice:'#B0A07A',dill:'#8AA07A',tomato:'#C08A6A',fig:'#C08A6A',mango:'#B0A07A',champaca:'#C6B07A',styrax:'#8AA07A',davana:'#B0A07A',truffle:'#8AA07A',clarysage:'#C08A6A',opoponax:'#8AA07A',turmeric:'#9AA6B0',costus:'#C6B07A',elemi:'#9AA6B0',labdanum:'#C6B07A',};
12121223
const aromaColor = n => AROMA_COLORS[n] || 'var(--aroma)';
1224+
// Perceptual families for the 160+ odour notes. A flat alphabetical wall of chips is unusable —
1225+
// a flavorist thinks in families, so the picker groups by them. Anything not listed lands in
1226+
// "Other", so a newly-trained head is never silently dropped from the UI.
1227+
const AROMA_FAMILIES = [
1228+
['Citrus', ['citrus','orange','lime','grapefruit','bergamot','mandarin','yuzu','neroli']],
1229+
['Fruity', ['fruity','apple','pear','quince','peach','apricot','plum','cherry','berry','strawberry',
1230+
'raspberry','blueberry','blackberry','cassis','grape','banana','melon','watermelon','lychee','fig',
1231+
'mango','pineapple','tropical','passionfruit','guava']],
1232+
['Floral', ['floral','rose','jasmine','violet','lilac','lavender','muguet','ylang','gardenia','tuberose',
1233+
'honeysuckle','magnolia','mimosa','narcissus','hyacinth','freesia','cyclamen','osmanthus','champaca',
1234+
'linden','carnation','geranium','elderflower','boronia','chamomile','immortelle','tagetes']],
1235+
['Green &amp; vegetal', ['green','grassy','hay','herbal','vegetable','tomato','celery','cucumber',
1236+
'violetleaf','coriander','angelica','wormwood','tea']],
1237+
['Spice', ['spicy','cinnamon','clove','nutmeg','allspice','blackpepper','cumin','cardamom','ginger',
1238+
'anise','fennel','dill','saffron','turmeric','mustard','thyme','oregano','sage','rosemary','juniper',
1239+
'clarysage']],
1240+
['Wood, resin &amp; amber', ['woody','cedarwood','sandalwood','vetiver','patchouli','pine','fir','terpenic',
1241+
'resinous','amber','labdanum','frankincense','myrrh','opoponax','elemi','styrax','oakmoss','costus',
1242+
'davana','balsamic']],
1243+
['Sweet &amp; bakery', ['vanilla','caramel','maple','honey','tonka','bready','popcorn','malty','nutty',
1244+
'hazelnut','almond','cocoa','coffee','coconut','lactonic']],
1245+
['Dairy &amp; fatty', ['buttery','creamy','cheesy','fatty','waxy','rancid']],
1246+
['Savoury &amp; sulfur', ['meaty','truffle','garlic','onion','sulfurous','mushroom','fishy']],
1247+
['Earthy, tobacco &amp; leather', ['earthy','musty','corky','tobacco','leathery']],
1248+
['Fresh, cool &amp; marine', ['fresh','marine','ozonic','aldehydic','minty','cooling','camphor',
1249+
'eucalyptus','wintergreen']],
1250+
['Animalic &amp; musk', ['animalic','musky','powdery']],
1251+
['Sharp &amp; chemical', ['acidic','alcoholic','ammoniacal','ethereal','petroleum','phenolic','medicinal',
1252+
'smoky','tarry','burnt','putrid','metallic','soapy','pungent','winey','cognac']],
1253+
['Base taste', ['sweet','bitter']],
1254+
];
12131255

12141256
const $ = id => document.getElementById(id);
12151257
const go = $('go'), q = $('q');
@@ -2302,16 +2344,31 @@ <h4>Software &amp; type</h4>
23022344
other:'other',misc:'other'};
23032345
const svgIcon = c => `<svg class="ic"><use href="#ic-${CAT_ICON[String(c).toLowerCase()]||'other'}"/></svg>`;
23042346
// Flavors — named flavors grouped by category, each with its glyph
2305-
let html = `<div class="studio-section"><div class="ss-head"><svg class="ic"><use href="#ic-flavors"/></svg> Flavors <span class="ss-sub">a flavor you know → the molecule that makes it</span></div><div class="ss-body">`;
2347+
// one filter across every chip — with 300+ of them, scanning is not a search strategy
2348+
let html = `<div class="chip-filter"><input id="studioFilter" type="search" autocomplete="off"
2349+
placeholder="Filter ${cats.reduce((a,c)=>a+(c.flavors||[]).length,0)+notes.length+mouth.length} flavors, notes &amp; sensations — e.g. citrus, vanilla, cool">
2350+
<span id="studioFilterNone" class="cf-none" hidden>no match</span></div>`;
2351+
html += `<div class="studio-section"><div class="ss-head"><svg class="ic"><use href="#ic-flavors"/></svg> Flavors <span class="ss-sub">a flavor you know → the molecule that makes it</span></div><div class="ss-body">`;
23062352
html += cats.map(c=>`<div class="studio-group"><span class="sg-label" title="${esc(c.category)}"><span class="sg-ico">${svgIcon(c.category)}</span>${c.category}</span>`+
23072353
c.flavors.map(f=>`<button type="button" class="schip schip-flavor" data-term="${esc(f)}">${f}</button>`).join('')+`</div>`).join('');
23082354
html += `</div></div>`;
2309-
// Notes — model-predicted aroma/taste qualities
2355+
// Notes — grouped into perceptual families, because 170+ chips in one alphabetical row is
2356+
// unreadable. Unlisted heads fall into "Other" so nothing is silently dropped.
23102357
if(notes.length){
2311-
html += `<div class="studio-section"><div class="ss-head"><svg class="ic"><use href="#ic-notes"/></svg> Notes <span class="ss-sub">an aroma/taste quality → molecules that carry it</span></div><div class="ss-body">`+
2312-
`<div class="studio-group studio-group-notes">`+
2313-
notes.map(n=>`<button type="button" class="schip schip-note" data-term="${esc(n)}">${n}</button>`).join('')+
2314-
`</div></div></div>`;
2358+
const left = new Set(notes), groups = [];
2359+
for(const [fam, members] of AROMA_FAMILIES){
2360+
const have = members.filter(m=>left.has(m));
2361+
have.forEach(m=>left.delete(m));
2362+
if(have.length) groups.push([fam, have]);
2363+
}
2364+
if(left.size) groups.push(['Other', [...left].sort()]);
2365+
html += `<div class="studio-section"><div class="ss-head"><svg class="ic"><use href="#ic-notes"/></svg> Notes <span class="ss-sub">an aroma quality → molecules that carry it</span></div><div class="ss-body">`+
2366+
groups.map(([fam, ms])=>
2367+
`<div class="fam"><div class="fam-label">${fam} <span class="fam-n">${ms.length}</span></div>`+
2368+
`<div class="studio-group studio-group-notes">`+
2369+
ms.map(n=>`<button type="button" class="schip schip-note" data-term="${esc(n)}" title="${esc(AROMA_HELP[n]||n)}">${n}</button>`).join('')+
2370+
`</div></div>`).join('')+
2371+
`</div></div>`;
23152372
}
23162373
// Mouthfeel — trained trigeminal sensations (a different modality from taste and aroma)
23172374
if(mouth.length){
@@ -2326,8 +2383,36 @@ <h4>Software &amp; type</h4>
23262383
const t=b.dataset.term; if(_studioSel.has(t)){ _studioSel.delete(t); b.classList.remove('on'); } else { _studioSel.add(t); b.classList.add('on'); }
23272384
syncStudioCount();
23282385
}));
2386+
wireStudioFilter();
23292387
}catch(_){ setTimeout(initStudio, 4000); }
23302388
}
2389+
// Live filter over every chip. Hides non-matching chips, then collapses any family / category /
2390+
// section left empty, so filtering never leaves a trail of orphan headings. A selected chip always
2391+
// stays visible — otherwise your own picks vanish as you type and the count looks wrong.
2392+
function wireStudioFilter(){
2393+
const inp=$('studioFilter'); if(!inp) return;
2394+
const apply=()=>{
2395+
const q=inp.value.trim().toLowerCase();
2396+
let shown=0;
2397+
$('studioChips').querySelectorAll('.schip').forEach(b=>{
2398+
const hit = !q || b.textContent.toLowerCase().includes(q) || b.classList.contains('on');
2399+
b.hidden = !hit; if(hit) shown++;
2400+
});
2401+
// roll empties up: chip rows, then family blocks / flavor categories, then whole sections
2402+
$('studioChips').querySelectorAll('.studio-group').forEach(g=>{
2403+
g.hidden = !g.querySelector('.schip:not([hidden])');
2404+
});
2405+
$('studioChips').querySelectorAll('.fam').forEach(f=>{
2406+
f.hidden = !f.querySelector('.schip:not([hidden])');
2407+
});
2408+
$('studioChips').querySelectorAll('.studio-section').forEach(s=>{
2409+
s.hidden = !s.querySelector('.schip:not([hidden])');
2410+
});
2411+
const none=$('studioFilterNone'); if(none) none.hidden = shown>0;
2412+
};
2413+
inp.addEventListener('input', apply);
2414+
inp.addEventListener('search', apply); // clicking the native clear (×)
2415+
}
23312416
function syncStudioCount(){
23322417
const n=_studioSel.size, el=$('studioGoCount'); if(!el) return;
23332418
el.textContent = n ? String(n) : '';

0 commit comments

Comments
 (0)