0xClemo commited on
Commit
5eb360a
Β·
verified Β·
1 Parent(s): 8fb83a0

Upload static/index.html with huggingface_hub

Browse files
Files changed (1) hide show
  1. static/index.html +715 -0
static/index.html CHANGED
@@ -437,7 +437,344 @@
437
  .input-group { flex-direction: column; }
438
  .btn { width: 100%; }
439
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
  </style>
 
 
441
  </head>
442
  <body>
443
 
@@ -462,6 +799,12 @@
462
  <button class="tab" onclick="switchTab('modes')" id="tab-modes">
463
  <span class="tab-icon">🎨</span>Flavour Profile
464
  </button>
 
 
 
 
 
 
465
  </nav>
466
 
467
  <main>
@@ -612,6 +955,84 @@
612
  </div>
613
  </div>
614
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
615
  </main>
616
 
617
  <script>
@@ -845,6 +1266,300 @@ document.addEventListener('keydown', e => {
845
  else if (activeTab === 'modes') runModes();
846
  }
847
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
848
  </script>
849
  </body>
850
  </html>
 
437
  .input-group { flex-direction: column; }
438
  .btn { width: 100%; }
439
  }
440
+
441
+ /* ── Graph panel ── */
442
+ #graph-svg-wrap {
443
+ width: 100%;
444
+ height: 500px;
445
+ background: var(--surface2);
446
+ border: 1px solid var(--border);
447
+ border-radius: var(--radius);
448
+ overflow: hidden;
449
+ position: relative;
450
+ margin-bottom: 12px;
451
+ }
452
+ #graph-svg-wrap svg { width: 100%; height: 100%; display: block; }
453
+ #graph-tooltip {
454
+ position: fixed;
455
+ background: var(--surface3);
456
+ border: 1px solid var(--border);
457
+ border-radius: 6px;
458
+ padding: 5px 10px;
459
+ font-family: var(--mono);
460
+ font-size: 12px;
461
+ color: var(--text);
462
+ pointer-events: none;
463
+ opacity: 0;
464
+ transition: opacity 0.12s;
465
+ z-index: 200;
466
+ }
467
+
468
+ /* ── Recipe Lab ── */
469
+ .basket-area {
470
+ background: var(--surface2);
471
+ border: 1px solid var(--border);
472
+ border-radius: var(--radius);
473
+ padding: 14px;
474
+ min-height: 54px;
475
+ display: flex;
476
+ flex-wrap: wrap;
477
+ gap: 6px;
478
+ align-items: center;
479
+ margin-bottom: 16px;
480
+ }
481
+ .basket-empty { color: var(--muted); font-size: 13px; }
482
+ .basket-chip {
483
+ display: inline-flex;
484
+ align-items: center;
485
+ gap: 5px;
486
+ background: var(--surface3);
487
+ border: 1px solid var(--border);
488
+ border-radius: 20px;
489
+ padding: 4px 10px 4px 12px;
490
+ font-family: var(--mono);
491
+ font-size: 12px;
492
+ color: var(--text);
493
+ }
494
+ .basket-chip-remove {
495
+ background: none;
496
+ border: none;
497
+ color: var(--muted);
498
+ cursor: pointer;
499
+ padding: 0 2px;
500
+ font-size: 13px;
501
+ line-height: 1;
502
+ transition: color 0.1s;
503
+ }
504
+ .basket-chip-remove:hover { color: var(--danger); }
505
+
506
+ .coherence-box {
507
+ background: var(--surface2);
508
+ border: 1px solid var(--border);
509
+ border-radius: var(--radius);
510
+ padding: 16px 20px;
511
+ display: flex;
512
+ align-items: center;
513
+ gap: 20px;
514
+ margin-bottom: 16px;
515
+ flex-wrap: wrap;
516
+ }
517
+ .coherence-score-num {
518
+ font-family: var(--mono);
519
+ font-size: 36px;
520
+ font-weight: 700;
521
+ line-height: 1;
522
+ transition: color 0.3s;
523
+ }
524
+ .coherence-label {
525
+ font-size: 12px;
526
+ color: var(--muted);
527
+ text-transform: uppercase;
528
+ letter-spacing: 0.8px;
529
+ margin-top: 4px;
530
+ font-weight: 600;
531
+ }
532
+ .coherence-details { flex: 1; min-width: 200px; }
533
+ details.pair-table summary {
534
+ font-size: 12px;
535
+ color: var(--muted);
536
+ cursor: pointer;
537
+ user-select: none;
538
+ margin-bottom: 8px;
539
+ }
540
+ details.pair-table summary:hover { color: var(--text); }
541
+ .pair-grid {
542
+ display: grid;
543
+ grid-template-columns: 1fr 1fr auto;
544
+ gap: 4px 12px;
545
+ font-size: 12px;
546
+ font-family: var(--mono);
547
+ max-height: 200px;
548
+ overflow-y: auto;
549
+ }
550
+ .pair-grid .ph { color: var(--muted); font-size: 11px; font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 4px; }
551
+ .pair-grid .pv { color: var(--text); padding: 2px 0; }
552
+ .pair-grid .ps { padding: 2px 0; }
553
+
554
+ .recipe-actions {
555
+ display: flex;
556
+ gap: 8px;
557
+ flex-wrap: wrap;
558
+ margin-bottom: 20px;
559
+ }
560
+
561
+ .cuisine-bars { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
562
+ .cuisine-bar-row {
563
+ display: flex;
564
+ align-items: center;
565
+ gap: 10px;
566
+ }
567
+ .cuisine-bar-label {
568
+ font-family: var(--mono);
569
+ font-size: 12px;
570
+ color: var(--text);
571
+ width: 130px;
572
+ flex-shrink: 0;
573
+ white-space: nowrap;
574
+ overflow: hidden;
575
+ text-overflow: ellipsis;
576
+ }
577
+ .cuisine-bar-track {
578
+ flex: 1;
579
+ height: 8px;
580
+ background: var(--surface3);
581
+ border-radius: 4px;
582
+ overflow: hidden;
583
+ }
584
+ .cuisine-bar-fill {
585
+ height: 100%;
586
+ background: var(--accent2);
587
+ border-radius: 4px;
588
+ transition: width 0.5s ease;
589
+ }
590
+ .cuisine-bar-pct {
591
+ font-family: var(--mono);
592
+ font-size: 11px;
593
+ color: var(--muted);
594
+ width: 38px;
595
+ text-align: right;
596
+ flex-shrink: 0;
597
+ }
598
+ .recipe-result-card {
599
+ background: var(--surface2);
600
+ border: 1px solid var(--border);
601
+ border-radius: var(--radius);
602
+ padding: 16px 18px;
603
+ margin-top: 8px;
604
+ }
605
+ .recipe-result-card .card-title {
606
+ font-family: var(--mono);
607
+ font-size: 14px;
608
+ font-weight: 600;
609
+ color: var(--accent);
610
+ margin-bottom: 6px;
611
+ }
612
+ .recipe-result-card .card-reason {
613
+ font-size: 13px;
614
+ color: var(--text);
615
+ line-height: 1.6;
616
+ }
617
+
618
+ /* ── Graph ── */
619
+ .graph-wrap {
620
+ background: var(--surface2);
621
+ border: 1px solid var(--border);
622
+ border-radius: var(--radius);
623
+ overflow: hidden;
624
+ margin-bottom: 20px;
625
+ }
626
+ .graph-svg {
627
+ width: 100%;
628
+ height: 500px;
629
+ display: block;
630
+ }
631
+ .graph-tooltip {
632
+ position: absolute;
633
+ background: var(--surface3);
634
+ border: 1px solid var(--border);
635
+ border-radius: 6px;
636
+ padding: 6px 10px;
637
+ font-size: 12px;
638
+ color: var(--text);
639
+ pointer-events: none;
640
+ opacity: 0;
641
+ transition: opacity 0.15s;
642
+ z-index: 200;
643
+ font-family: var(--mono);
644
+ }
645
+ .graph-legend {
646
+ display: flex;
647
+ gap: 16px;
648
+ padding: 10px 14px;
649
+ font-size: 11px;
650
+ color: var(--muted);
651
+ border-top: 1px solid var(--border);
652
+ }
653
+ .graph-legend span { display: flex; align-items: center; gap: 5px; }
654
+ .legend-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
655
+
656
+ /* ── Recipe Lab ── */
657
+ .basket-row {
658
+ display: flex;
659
+ flex-wrap: wrap;
660
+ gap: 8px;
661
+ margin-bottom: 20px;
662
+ min-height: 36px;
663
+ }
664
+ .basket-chip {
665
+ display: inline-flex;
666
+ align-items: center;
667
+ gap: 6px;
668
+ padding: 5px 12px;
669
+ border-radius: 20px;
670
+ background: var(--surface2);
671
+ border: 1px solid var(--border);
672
+ font-family: var(--mono);
673
+ font-size: 13px;
674
+ color: var(--text);
675
+ }
676
+ .basket-chip .remove {
677
+ cursor: pointer;
678
+ color: var(--muted);
679
+ font-size: 14px;
680
+ line-height: 1;
681
+ padding: 0 2px;
682
+ }
683
+ .basket-chip .remove:hover { color: var(--danger); }
684
+ .coherence-box {
685
+ background: var(--surface2);
686
+ border: 1px solid var(--border);
687
+ border-radius: var(--radius);
688
+ padding: 16px 20px;
689
+ margin-bottom: 20px;
690
+ display: flex;
691
+ align-items: center;
692
+ gap: 20px;
693
+ flex-wrap: wrap;
694
+ }
695
+ .coherence-number {
696
+ font-size: 32px;
697
+ font-weight: 700;
698
+ font-family: var(--mono);
699
+ line-height: 1;
700
+ }
701
+ .coherence-number.green { color: var(--accent2); }
702
+ .coherence-number.amber { color: var(--accent); }
703
+ .coherence-number.red { color: var(--danger); }
704
+ .coherence-label {
705
+ font-size: 12px;
706
+ color: var(--muted);
707
+ text-transform: uppercase;
708
+ letter-spacing: 0.8px;
709
+ }
710
+ .coherence-detail {
711
+ font-size: 12px;
712
+ color: var(--muted);
713
+ margin-top: 4px;
714
+ }
715
+ .recipe-actions {
716
+ display: flex;
717
+ gap: 8px;
718
+ flex-wrap: wrap;
719
+ margin-bottom: 20px;
720
+ }
721
+ .cuisine-bar-row {
722
+ display: flex;
723
+ align-items: center;
724
+ gap: 10px;
725
+ margin-bottom: 6px;
726
+ }
727
+ .cuisine-bar-label {
728
+ width: 140px;
729
+ font-size: 12px;
730
+ color: var(--muted);
731
+ text-align: right;
732
+ flex-shrink: 0;
733
+ }
734
+ .cuisine-bar-track {
735
+ flex: 1;
736
+ height: 6px;
737
+ background: var(--surface3);
738
+ border-radius: 3px;
739
+ overflow: hidden;
740
+ }
741
+ .cuisine-bar-fill {
742
+ height: 100%;
743
+ border-radius: 3px;
744
+ background: var(--accent2);
745
+ transition: width 0.5s ease;
746
+ }
747
+ .cuisine-bar-score {
748
+ width: 50px;
749
+ font-family: var(--mono);
750
+ font-size: 11px;
751
+ color: var(--muted);
752
+ text-align: right;
753
+ flex-shrink: 0;
754
+ }
755
+ .pairwise-table {
756
+ width: 100%;
757
+ border-collapse: collapse;
758
+ font-size: 12px;
759
+ margin-top: 8px;
760
+ }
761
+ .pairwise-table th, .pairwise-table td {
762
+ padding: 6px 10px;
763
+ text-align: left;
764
+ border-bottom: 1px solid var(--border);
765
+ }
766
+ .pairwise-table th {
767
+ color: var(--muted);
768
+ font-weight: 500;
769
+ font-size: 11px;
770
+ text-transform: uppercase;
771
+ letter-spacing: 0.5px;
772
+ }
773
+ .pairwise-table td { font-family: var(--mono); }
774
+
775
  </style>
776
+ <script src="https://d3js.org/d3.v7.min.js"></script>
777
+ <script src="https://d3js.org/d3.v7.min.js"></script>
778
  </head>
779
  <body>
780
 
 
799
  <button class="tab" onclick="switchTab('modes')" id="tab-modes">
800
  <span class="tab-icon">🎨</span>Flavour Profile
801
  </button>
802
+ <button class="tab" onclick="switchTab('graph')" id="tab-graph">
803
+ <span class="tab-icon">πŸ•ΈοΈ</span>Ingredient Graph
804
+ </button>
805
+ <button class="tab" onclick="switchTab('recipe')" id="tab-recipe">
806
+ <span class="tab-icon">πŸ§ͺ</span>Recipe Lab
807
+ </button>
808
  </nav>
809
 
810
  <main>
 
955
  </div>
956
  </div>
957
 
958
+ <!-- ═══════════════════ PANEL 4: INGREDIENT GRAPH ═══════════════════ -->
959
+ <div class="panel" id="panel-graph">
960
+ <div class="panel-title">Ingredient Graph</div>
961
+ <div class="panel-desc">
962
+ Force-directed neighbourhood map. The centre ingredient pulls its closest companions;
963
+ cross-edges show which neighbours are also similar to each other β€” revealing natural clusters.
964
+ Click any node to re-root the graph there.
965
+ </div>
966
+
967
+ <div class="input-group">
968
+ <div class="input-wrap" style="flex:2">
969
+ <input type="text" id="graph-input" placeholder="Type an ingredient…" autocomplete="off" spellcheck="false">
970
+ <div class="autocomplete-list" id="graph-ac"></div>
971
+ </div>
972
+ <select id="graph-model" style="max-width:160px">
973
+ <option value="core" selected>Core (blend)</option>
974
+ <option value="cooc">Cooc (recipe)</option>
975
+ <option value="chem">Chem (chemistry)</option>
976
+ </select>
977
+ <button class="btn" onclick="runGraph()">Map β†’</button>
978
+ </div>
979
+
980
+ <div class="chip-row">
981
+ <span style="font-size:11px;color:var(--muted);align-self:center">Try:</span>
982
+ <span class="chip" onclick="setAndRun('graph', 'truffle')">truffle</span>
983
+ <span class="chip" onclick="setAndRun('graph', 'chocolate')">chocolate</span>
984
+ <span class="chip" onclick="setAndRun('graph', 'kimchi')">kimchi</span>
985
+ <span class="chip" onclick="setAndRun('graph', 'miso')">miso</span>
986
+ <span class="chip" onclick="setAndRun('graph', 'vanilla')">vanilla</span>
987
+ <span class="chip" onclick="setAndRun('graph', 'saffron')">saffron</span>
988
+ </div>
989
+
990
+ <div class="results-area" id="graph-results">
991
+ <div class="state-box">
992
+ <div class="icon">πŸ•ΈοΈ</div>
993
+ Enter an ingredient to explore its flavour neighbourhood
994
+ </div>
995
+ </div>
996
+ </div>
997
+
998
+ <!-- ═══════════════════ PANEL 5: RECIPE LAB ═══════════════════ -->
999
+ <div class="panel" id="panel-recipe">
1000
+ <div class="panel-title">Recipe Lab</div>
1001
+ <div class="panel-desc">
1002
+ Build a basket of ingredients and analyse coherence, get suggestions,
1003
+ discover cuisine affinity, or find a surprising chemistry-compatible addition.
1004
+ </div>
1005
+
1006
+ <div class="input-group">
1007
+ <div class="input-wrap" style="flex:2">
1008
+ <input type="text" id="recipe-input" placeholder="Add an ingredient…" autocomplete="off" spellcheck="false">
1009
+ <div class="autocomplete-list" id="recipe-ac"></div>
1010
+ </div>
1011
+ <button class="btn" onclick="addToRecipeBasket()">Add</button>
1012
+ </div>
1013
+
1014
+ <div class="basket-row" id="recipe-basket"></div>
1015
+
1016
+ <div class="coherence-box" id="recipe-coherence" style="display:none">
1017
+ <div>
1018
+ <div class="coherence-number" id="coherence-num">β€”</div>
1019
+ <div class="coherence-label">Coherence</div>
1020
+ </div>
1021
+ <div style="flex:1;min-width:200px">
1022
+ <div id="coherence-label" style="font-size:14px;font-weight:600;margin-bottom:4px">β€”</div>
1023
+ <div class="coherence-detail" id="coherence-detail">Add ingredients to see how well they work together</div>
1024
+ </div>
1025
+ </div>
1026
+
1027
+ <div class="recipe-actions" id="recipe-actions" style="display:none">
1028
+ <button class="btn btn-secondary" onclick="recipeSuggest()">πŸ’‘ Suggest next</button>
1029
+ <button class="btn btn-secondary" onclick="recipeAffinity()">🌍 Cuisine affinity</button>
1030
+ <button class="btn btn-secondary" onclick="recipeSurprise()">🎲 Surprise me</button>
1031
+ </div>
1032
+
1033
+ <div class="results-area" id="recipe-results"></div>
1034
+ </div>
1035
+
1036
  </main>
1037
 
1038
  <script>
 
1266
  else if (activeTab === 'modes') runModes();
1267
  }
1268
  });
1269
+
1270
+ // ── GRAPH ─────────────────────────────────────────────────────────────────────
1271
+ function runGraph() {
1272
+ const ing = document.getElementById('graph-input').value.trim();
1273
+ if (!ing) return;
1274
+ document.getElementById('graph-ac').classList.remove('open');
1275
+ const variant = document.getElementById('graph-model').value;
1276
+ loading('graph-results');
1277
+ fetch(`${API}/api/graph?ingredient=${encodeURIComponent(ing)}&k=15&model_variant=${variant}`)
1278
+ .then(r => r.json())
1279
+ .then(data => renderGraph(data))
1280
+ .catch(e => error('graph-results', e.message));
1281
+ }
1282
+
1283
+ function renderGraph(data) {
1284
+ const container = document.getElementById('graph-results');
1285
+ container.innerHTML = `
1286
+ <div class="graph-wrap">
1287
+ <svg class="graph-svg" id="graph-svg"></svg>
1288
+ <div class="graph-legend">
1289
+ <span><span class="legend-dot" style="background:var(--accent)"></span> centre</span>
1290
+ <span><span class="legend-dot" style="background:var(--accent2)"></span> neighbour</span>
1291
+ <span><span class="legend-dot" style="background:var(--border)"></span> cross-edge</span>
1292
+ </div>
1293
+ </div>
1294
+ <div class="graph-tooltip" id="graph-tooltip"></div>
1295
+ `;
1296
+
1297
+ const svg = d3.select("#graph-svg");
1298
+ const width = container.clientWidth;
1299
+ const height = 500;
1300
+ svg.attr("viewBox", `0 0 ${width} ${height}`);
1301
+
1302
+ const nodes = data.nodes.map(n => ({...n}));
1303
+ const links = data.edges.map(e => ({...e}));
1304
+
1305
+ const simulation = d3.forceSimulation(nodes)
1306
+ .force("link", d3.forceLink(links).id(d => d.id).distance(d => 120 - d.weight * 60))
1307
+ .force("charge", d3.forceManyBody().strength(-300))
1308
+ .force("center", d3.forceCenter(width / 2, height / 2))
1309
+ .force("collide", d3.forceCollide().radius(30));
1310
+
1311
+ const g = svg.append("g");
1312
+
1313
+ // Zoom
1314
+ svg.call(d3.zoom().on("zoom", (e) => g.attr("transform", e.transform)));
1315
+
1316
+ // Links
1317
+ const link = g.append("g")
1318
+ .selectAll("line")
1319
+ .data(links)
1320
+ .join("line")
1321
+ .attr("stroke", d => d.source === data.center || d.source.id === data.center ? "var(--accent)" : "var(--border)")
1322
+ .attr("stroke-opacity", d => d.source === data.center || d.source.id === data.center ? 0.6 : 0.3)
1323
+ .attr("stroke-width", d => Math.max(1, d.weight * 3));
1324
+
1325
+ // Nodes
1326
+ const node = g.append("g")
1327
+ .selectAll("g")
1328
+ .data(nodes)
1329
+ .join("g")
1330
+ .attr("cursor", "pointer")
1331
+ .call(d3.drag()
1332
+ .on("start", (e, d) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
1333
+ .on("drag", (e, d) => { d.fx = e.x; d.fy = e.y; })
1334
+ .on("end", (e, d) => { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }));
1335
+
1336
+ node.append("circle")
1337
+ .attr("r", d => d.is_center ? 18 : 8 + d.score * 8)
1338
+ .attr("fill", d => d.is_center ? "var(--accent)" : d3.interpolateRgb("var(--muted)", "var(--accent2)")(d.score))
1339
+ .attr("stroke", d => d.is_center ? "var(--accent2)" : "var(--surface3)")
1340
+ .attr("stroke-width", d => d.is_center ? 3 : 1.5);
1341
+
1342
+ node.append("text")
1343
+ .text(d => d.id.replace(/_/g, ' '))
1344
+ .attr("x", 0)
1345
+ .attr("y", d => d.is_center ? 28 : 18)
1346
+ .attr("text-anchor", "middle")
1347
+ .attr("fill", "var(--text)")
1348
+ .attr("font-size", d => d.is_center ? "13px" : "10px")
1349
+ .attr("font-family", "var(--mono)")
1350
+ .attr("font-weight", d => d.is_center ? "600" : "400");
1351
+
1352
+ // Tooltip
1353
+ const tooltip = d3.select("#graph-tooltip");
1354
+ node.on("mouseover", (e, d) => {
1355
+ tooltip.style("opacity", 1)
1356
+ .html(`<strong>${d.id}</strong><br>score: ${d.score.toFixed(3)}${d.is_center ? '<br>(centre)' : ''}`)
1357
+ .style("left", (e.pageX + 10) + "px")
1358
+ .style("top", (e.pageY - 10) + "px");
1359
+ }).on("mouseout", () => tooltip.style("opacity", 0));
1360
+
1361
+ // Click to re-root
1362
+ node.on("click", (e, d) => {
1363
+ if (!d.is_center) {
1364
+ document.getElementById('graph-input').value = d.id;
1365
+ runGraph();
1366
+ }
1367
+ });
1368
+
1369
+ simulation.on("tick", () => {
1370
+ link
1371
+ .attr("x1", d => d.source.x)
1372
+ .attr("y1", d => d.source.y)
1373
+ .attr("x2", d => d.target.x)
1374
+ .attr("y2", d => d.target.y);
1375
+ node.attr("transform", d => `translate(${d.x},${d.y})`);
1376
+ });
1377
+ }
1378
+
1379
+ // ── RECIPE LAB ────────────────────────────────────────────────────────────────
1380
+ let recipeBasket = [];
1381
+ let recipeDebounce = null;
1382
+
1383
+ function addToRecipeBasket() {
1384
+ const input = document.getElementById('recipe-input');
1385
+ const name = input.value.trim();
1386
+ if (!name) return;
1387
+ if (!ingredients_list.includes(name)) {
1388
+ error('recipe-results', `"${name}" is not in the vocabulary.`);
1389
+ return;
1390
+ }
1391
+ if (recipeBasket.includes(name)) return;
1392
+ if (recipeBasket.length >= 12) {
1393
+ error('recipe-results', "Basket is full (max 12 ingredients).");
1394
+ return;
1395
+ }
1396
+ recipeBasket.push(name);
1397
+ input.value = '';
1398
+ document.getElementById('recipe-ac').classList.remove('open');
1399
+ renderRecipeBasket();
1400
+ debouncedRecipeScore();
1401
+ }
1402
+
1403
+ function removeFromRecipeBasket(name) {
1404
+ recipeBasket = recipeBasket.filter(i => i !== name);
1405
+ renderRecipeBasket();
1406
+ debouncedRecipeScore();
1407
+ }
1408
+
1409
+ function renderRecipeBasket() {
1410
+ const el = document.getElementById('recipe-basket');
1411
+ if (recipeBasket.length === 0) {
1412
+ el.innerHTML = '<span style="color:var(--muted);font-size:12px">No ingredients yet. Type above to add.</span>';
1413
+ document.getElementById('recipe-coherence').style.display = 'none';
1414
+ document.getElementById('recipe-actions').style.display = 'none';
1415
+ document.getElementById('recipe-results').innerHTML = '';
1416
+ return;
1417
+ }
1418
+ el.innerHTML = recipeBasket.map(name =>
1419
+ `<span class="basket-chip">${name}<span class="remove" onclick="removeFromRecipeBasket('${name}')">&times;</span></span>`
1420
+ ).join('');
1421
+ document.getElementById('recipe-coherence').style.display = 'flex';
1422
+ if (recipeBasket.length >= 2) {
1423
+ document.getElementById('recipe-actions').style.display = 'flex';
1424
+ } else {
1425
+ document.getElementById('recipe-actions').style.display = 'none';
1426
+ }
1427
+ }
1428
+
1429
+ function debouncedRecipeScore() {
1430
+ if (recipeDebounce) clearTimeout(recipeDebounce);
1431
+ recipeDebounce = setTimeout(() => recipeScore(), 300);
1432
+ }
1433
+
1434
+ async function recipeScore() {
1435
+ if (recipeBasket.length < 2) {
1436
+ document.getElementById('coherence-num').textContent = 'β€”';
1437
+ document.getElementById('coherence-num').className = 'coherence-number';
1438
+ document.getElementById('coherence-label').textContent = 'β€”';
1439
+ document.getElementById('coherence-detail').textContent = 'Add at least 2 ingredients to score coherence';
1440
+ return;
1441
+ }
1442
+ try {
1443
+ const res = await fetch(`${API}/api/recipe`, {
1444
+ method: 'POST',
1445
+ headers: {'Content-Type': 'application/json'},
1446
+ body: JSON.stringify({ingredients: recipeBasket, action: 'score'})
1447
+ });
1448
+ const data = await res.json();
1449
+ const num = document.getElementById('coherence-num');
1450
+ num.textContent = data.score.toFixed(3);
1451
+ num.className = 'coherence-number ' + (data.label === 'coherent' ? 'green' : data.label === 'mixed' ? 'amber' : 'red');
1452
+ document.getElementById('coherence-label').textContent = data.label;
1453
+ document.getElementById('coherence-detail').textContent = `${data.pairwise.length} pairs scored`;
1454
+ } catch(e) {
1455
+ console.error('score error', e);
1456
+ }
1457
+ }
1458
+
1459
+ async function recipeSuggest() {
1460
+ const resArea = document.getElementById('recipe-results');
1461
+ loading(resArea.id);
1462
+ try {
1463
+ const res = await fetch(`${API}/api/recipe`, {
1464
+ method: 'POST',
1465
+ headers: {'Content-Type': 'application/json'},
1466
+ body: JSON.stringify({ingredients: recipeBasket, action: 'suggest'})
1467
+ });
1468
+ const data = await res.json();
1469
+ resArea.innerHTML = `
1470
+ <div class="results-title">Suggested addition</div>
1471
+ <div class="result-item" style="cursor:pointer" onclick="recipeBasketAdd('${data.suggestion}')">
1472
+ <span class="result-name">${data.suggestion}</span>
1473
+ <span class="result-score">${data.score.toFixed(3)}</span>
1474
+ <button class="result-use-btn">add to basket β†’</button>
1475
+ </div>
1476
+ <div style="margin-top:8px;font-size:12px;color:var(--muted)">${data.reason}</div>
1477
+ `;
1478
+ } catch(e) {
1479
+ error('recipe-results', e.message);
1480
+ }
1481
+ }
1482
+
1483
+ async function recipeAffinity() {
1484
+ const resArea = document.getElementById('recipe-results');
1485
+ loading(resArea.id);
1486
+ try {
1487
+ const res = await fetch(`${API}/api/recipe`, {
1488
+ method: 'POST',
1489
+ headers: {'Content-Type': 'application/json'},
1490
+ body: JSON.stringify({ingredients: recipeBasket, action: 'affinity'})
1491
+ });
1492
+ const data = await res.json();
1493
+ const max = data.affinities[0]?.score ?? 1;
1494
+ resArea.innerHTML = `
1495
+ <div class="results-title">Cuisine affinity</div>
1496
+ ${data.affinities.map(a => `
1497
+ <div class="cuisine-bar-row">
1498
+ <span class="cuisine-bar-label">${a.cuisine.replace(/_/g,' ')}</span>
1499
+ <div class="cuisine-bar-track">
1500
+ <div class="cuisine-bar-fill" style="width:${Math.round((a.score/max)*100)}%"></div>
1501
+ </div>
1502
+ <span class="cuisine-bar-score">${a.score.toFixed(3)}</span>
1503
+ </div>
1504
+ `).join('')}
1505
+ `;
1506
+ } catch(e) {
1507
+ error('recipe-results', e.message);
1508
+ }
1509
+ }
1510
+
1511
+ async function recipeSurprise() {
1512
+ const resArea = document.getElementById('recipe-results');
1513
+ loading(resArea.id);
1514
+ try {
1515
+ const res = await fetch(`${API}/api/recipe`, {
1516
+ method: 'POST',
1517
+ headers: {'Content-Type': 'application/json'},
1518
+ body: JSON.stringify({ingredients: recipeBasket, action: 'surprise'})
1519
+ });
1520
+ const data = await res.json();
1521
+ resArea.innerHTML = `
1522
+ <div class="results-title">Surprise ingredient</div>
1523
+ <div class="result-item" style="cursor:pointer" onclick="recipeBasketAdd('${data.suggestion}')">
1524
+ <span class="result-name">${data.suggestion}</span>
1525
+ <span class="result-score">chem ${data.chemistry_score.toFixed(3)}</span>
1526
+ <button class="result-use-btn">add to basket β†’</button>
1527
+ </div>
1528
+ <div style="margin-top:8px;font-size:12px;color:var(--muted)">
1529
+ Core similarity: ${data.core_score.toFixed(3)} (low recipe-context match, high chemistry compatibility)
1530
+ </div>
1531
+ `;
1532
+ } catch(e) {
1533
+ error('recipe-results', e.message);
1534
+ }
1535
+ }
1536
+
1537
+ function recipeBasketAdd(name) {
1538
+ if (recipeBasket.includes(name) || recipeBasket.length >= 12) return;
1539
+ recipeBasket.push(name);
1540
+ renderRecipeBasket();
1541
+ debouncedRecipeScore();
1542
+ }
1543
+
1544
+ // ── Wire up autocomplete for new inputs ───────────────────────────────────────
1545
+ setupAutocomplete('graph-input', 'graph-ac', () => runGraph());
1546
+ setupAutocomplete('recipe-input', 'recipe-ac', () => addToRecipeBasket());
1547
+
1548
+ // ── Update setAndRun for new tabs ─────────────────────────────────────────────
1549
+ const _origSetAndRun = setAndRun;
1550
+ setAndRun = function(panel, ingredient, cuisine) {
1551
+ if (panel === 'graph') {
1552
+ switchTab('graph');
1553
+ document.getElementById('graph-input').value = ingredient;
1554
+ runGraph();
1555
+ } else if (panel === 'recipe') {
1556
+ switchTab('recipe');
1557
+ recipeBasketAdd(ingredient);
1558
+ } else {
1559
+ _origSetAndRun(panel, ingredient, cuisine);
1560
+ }
1561
+ };
1562
+
1563
  </script>
1564
  </body>
1565
  </html>