prithivMLmods commited on
Commit
c212f06
Β·
verified Β·
1 Parent(s): 6201ac6

update app

Browse files
Files changed (1) hide show
  1. app.py +480 -189
app.py CHANGED
@@ -151,7 +151,6 @@ def generate_inference_stream(
151
  yield f"data: {json.dumps({'chunk': '[Error] Qwen3.5-4B model not loaded.'})}\n\n"
152
  yield "data: [DONE]\n\n"
153
  return
154
-
155
  messages = [{"role": "user", "content": [
156
  {"type": "image", "image": image},
157
  {"type": "text", "text": full_prompt},
@@ -183,7 +182,6 @@ def generate_inference_stream(
183
  yield f"data: {json.dumps({'chunk': '[Error] Qwen3.5-2B model not loaded.'})}\n\n"
184
  yield "data: [DONE]\n\n"
185
  return
186
-
187
  messages = [{"role": "user", "content": [
188
  {"type": "image", "image": image},
189
  {"type": "text", "text": full_prompt},
@@ -215,7 +213,6 @@ def generate_inference_stream(
215
  yield f"data: {json.dumps({'chunk': '[Error] Qwen3-VL model not loaded.'})}\n\n"
216
  yield "data: [DONE]\n\n"
217
  return
218
-
219
  messages = [{"role": "user", "content": [
220
  {"type": "image", "image": image},
221
  {"type": "text", "text": full_prompt},
@@ -247,7 +244,6 @@ def generate_inference_stream(
247
  yield f"data: {json.dumps({'chunk': '[Error] LFM-450M model not loaded.'})}\n\n"
248
  yield "data: [DONE]\n\n"
249
  return
250
-
251
  conversation = [{"role": "user", "content": [
252
  {"type": "image", "image": image},
253
  {"type": "text", "text": full_prompt},
@@ -276,7 +272,6 @@ def generate_inference_stream(
276
  yield f"data: {json.dumps({'chunk': '[Error] LFM-1.6B model not loaded.'})}\n\n"
277
  yield "data: [DONE]\n\n"
278
  return
279
-
280
  conversation = [{"role": "user", "content": [
281
  {"type": "image", "image": image},
282
  {"type": "text", "text": full_prompt},
@@ -353,31 +348,21 @@ async def homepage(request: Request):
353
  * { box-sizing: border-box; margin: 0; padding: 0; }
354
 
355
  html, body {
356
- min-height: 100%;
 
357
  background: var(--bg);
358
  color: var(--text);
359
  font-family: 'JetBrains Mono', monospace;
360
  }
361
 
362
- body {
363
- background-image:
364
- radial-gradient(circle at 20% 50%, rgba(124,106,247,0.04) 0%, transparent 50%),
365
- radial-gradient(circle at 80% 20%, rgba(78,205,196,0.04) 0%, transparent 50%),
366
- linear-gradient(var(--grid) 1px, transparent 1px),
367
- linear-gradient(90deg, var(--grid) 1px, transparent 1px);
368
- background-size: 100% 100%, 100% 100%, 24px 24px, 24px 24px;
369
- overflow-x: auto;
370
- overflow-y: auto;
371
- }
372
-
373
  /* ── Top Bar ── */
374
  .top-bar {
375
- position: sticky; top: 0; left: 0; right: 0;
376
  height: 42px;
377
- background: rgba(13,13,15,0.95);
378
  border-bottom: 1px solid var(--node-border);
379
  display: flex; align-items: center; padding: 0 20px;
380
- gap: 12px; z-index: 1000;
381
  backdrop-filter: blur(12px);
382
  }
383
  .top-bar .logo { font-size: 13px; font-weight: 700; color: var(--accent); letter-spacing: 0.05em; }
@@ -391,13 +376,27 @@ async def homepage(request: Request):
391
  font-size: 10px; color: var(--accent);
392
  }
393
 
394
- /* ── Canvas ── */
395
- #canvas {
396
- position: relative;
397
- width: 1360px;
398
- min-height: calc(100vh - 42px);
399
- height: 900px;
400
- margin: 0 auto;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  }
402
 
403
  svg.wires {
@@ -566,10 +565,7 @@ async def homepage(request: Request):
566
  width: 6px; height: 6px; border-radius: 50%;
567
  background: var(--muted); display: inline-block; margin-right: 6px;
568
  }
569
- .status-dot.active {
570
- background: var(--accent2);
571
- box-shadow: 0 0 5px var(--accent2);
572
- }
573
 
574
  /* ── Model badges ── */
575
  .model-badge {
@@ -589,11 +585,105 @@ async def homepage(request: Request):
589
  flex-shrink: 0;
590
  }
591
 
592
- .canvas-footer { height: 36px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
593
  </style>
594
  </head>
595
  <body>
596
 
 
597
  <div class="top-bar">
598
  <span class="logo">MULTIMODAL EDGE</span>
599
  <span class="sep">|</span>
@@ -601,146 +691,357 @@ async def homepage(request: Request):
601
  <span class="badge">v2.3 β€” PENTA MODEL</span>
602
  </div>
603
 
604
- <div id="canvas">
605
- <svg class="wires">
606
- <path id="wire-img-task" class="wire" />
607
- <path id="wire-model-task" class="wire" />
608
- <path id="wire-task-out" class="wire" />
609
- <path id="wire-task-gnd" class="wire" />
610
- </svg>
611
-
612
- <!-- ─── ID 01 : Image Input ─── -->
613
- <div class="node fixed-height" id="node-img" style="left:40px; top:52px;">
614
- <div class="node-header">
615
- <span><span class="status-dot" id="dot-img"></span>Input Image</span>
616
- <span class="id">ID: 01</span>
617
- </div>
618
- <div class="node-body">
619
- <div>
620
- <label>Upload Image</label>
621
- <div class="file-upload" id="dropZone">
622
- <svg width="30" height="30" viewBox="0 0 24 24" fill="none"
623
- stroke="#7c6af7" stroke-width="1.5"
624
- stroke-linecap="round" stroke-linejoin="round">
625
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
626
- <circle cx="8.5" cy="8.5" r="1.5"/>
627
- <polyline points="21 15 16 10 5 21"/>
628
- </svg>
629
- <span>Click or drop image here</span>
630
- <input type="file" id="fileInput" accept="image/*">
 
 
 
 
631
  </div>
632
- <img id="imgPreview" class="img-preview" />
633
  </div>
 
634
  </div>
635
- <div class="port out" id="port-img-out" style="top:50%;transform:translateY(-50%);"></div>
636
- </div>
637
 
638
- <!-- ─── ID 02 : Model Selector ─── -->
639
- <div class="node fixed-height" id="node-model" style="left:40px; top:402px;">
640
- <div class="node-header">
641
- <span><span class="status-dot" id="dot-model"></span>Model Selector</span>
642
- <span class="id">ID: 02</span>
643
- </div>
644
- <div class="node-body">
645
- <div>
646
- <label>Active Model</label>
647
- <select id="modelSelect">
648
- <option value="qwen_4b">Qwen3.5-4B</option>
649
- <option value="qwen_2b">Qwen3.5-2B</option>
650
- <option value="qwen_vl">Qwen3-VL-2B-Instruct</option>
651
- <option value="lfm_450">LFM2.5-VL-450M (LiquidAI)</option>
652
- <option value="lfm_16">LFM2.5-VL-1.6B (LiquidAI)</option>
653
- </select>
654
  </div>
655
- <div id="modelInfoBox" class="model-info-box"
656
- style="background:rgba(255,200,80,0.07);border:1px solid rgba(255,200,80,0.3);">
657
- <span class="model-badge q4b">QWEN 3.5 Β· 4B</span><br><br>
658
- Qwen3.5 4B multimodal model by Alibaba Cloud.
659
- Enhanced capacity over 2B β€” richer reasoning, better instruction following.
 
 
 
 
 
 
 
 
 
 
 
 
 
660
  </div>
661
- <div style="flex:1;"></div>
662
  </div>
663
- <div class="port out" id="port-model-out" style="top:50%;transform:translateY(-50%);"></div>
664
- </div>
665
 
666
- <!-- ─── ID 03 : Task Config ─── -->
667
- <div class="node fixed-height" id="node-task" style="left:425px; top:52px;">
668
- <div class="port in" id="port-task-in" style="top:50%;transform:translateY(-50%);"></div>
669
- <div class="node-header">
670
- <span><span class="status-dot" id="dot-task"></span>Task Config</span>
671
- <span class="id">ID: 03</span>
672
- </div>
673
- <div class="node-body">
674
- <div>
675
- <label>Task Category</label>
676
- <select id="categorySelect">
677
- <option value="Query">Query</option>
678
- <option value="Caption">Caption</option>
679
- <option value="Point">Point</option>
680
- <option value="Detect">Detect</option>
681
- </select>
682
  </div>
683
- <div>
684
- <label>Prompt Directive</label>
685
- <textarea id="promptInput" rows="4"
686
- placeholder="e.g., Count the total number of boats and describe the environment."></textarea>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
687
  </div>
688
- <button class="run-btn" id="runBtn">
689
- <span>Execute</span>
690
- <span class="loader" id="btnLoader"></span>
691
- </button>
692
  </div>
693
- <div class="port out" id="port-task-out" style="top:50%;transform:translateY(-50%);"></div>
694
- </div>
695
 
696
- <!-- ─── ID 04 : Output Stream ─── -->
697
- <div class="node fixed-height" id="node-out" style="left:810px; top:52px;">
698
- <div class="port in" id="port-out-in" style="top:50%;transform:translateY(-50%);"></div>
699
- <div class="node-header">
700
- <span><span class="status-dot" id="dot-out"></span>Output Stream</span>
701
- <span class="id">ID: 04</span>
702
- </div>
703
- <div class="node-body">
704
- <label>Streamed Result</label>
705
- <div class="output-box" id="outputBox">Results will stream here...</div>
 
706
  </div>
707
- </div>
708
 
709
- <!-- ─── ID 05 : Grounding Visualiser ─── -->
710
- <div class="node fixed-height" id="node-gnd" style="left:810px; top:402px;">
711
- <div class="port in" id="port-gnd-in" style="top:50%;transform:translateY(-50%);"></div>
712
- <div class="node-header">
713
- <span><span class="status-dot" id="dot-gnd"></span>View Grounding</span>
714
- <span class="id">ID: 05</span>
715
- </div>
716
- <div class="node-body">
717
- <label>Point / Detect Overlay</label>
718
- <div class="ground-canvas-wrap">
719
- <canvas id="groundCanvas"></canvas>
720
- <div class="ground-placeholder" id="groundPlaceholder">
721
- Active for Point / Detect tasks.<br>Run inference to visualise.
 
722
  </div>
723
  </div>
724
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
725
  </div>
726
 
727
- <div class="canvas-footer"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
728
  </div>
729
 
730
  <script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
731
  // ══════════════════════════════════════════════
732
- // WIRE DRAWING
733
  // ══════════════════════════════════════════════
734
- const canvasEl = document.getElementById('canvas');
735
-
736
  function portCenter(id) {
737
  const el = document.getElementById(id);
738
  if (!el) return { x: 0, y: 0 };
739
- const er = el.getBoundingClientRect();
740
- const cr = canvasEl.getBoundingClientRect();
 
 
 
 
741
  return {
742
- x: er.left + er.width / 2 - cr.left,
743
- y: er.top + er.height / 2 - cr.top
744
  };
745
  }
746
 
@@ -750,45 +1051,51 @@ function bezier(p1, p2) {
750
  }
751
 
752
  function updateWires() {
753
- const wires = [
754
  ['wire-img-task', 'port-img-out', 'port-task-in'],
755
  ['wire-model-task', 'port-model-out', 'port-task-in'],
756
  ['wire-task-out', 'port-task-out', 'port-out-in'],
757
  ['wire-task-gnd', 'port-task-out', 'port-gnd-in'],
758
  ];
759
- for (const [id, from, to] of wires) {
760
  const el = document.getElementById(id);
761
  if (el) el.setAttribute('d', bezier(portCenter(from), portCenter(to)));
762
  }
763
  }
764
 
765
  // ══════════════════════════════════════════════
766
- // DRAGGING
767
  // ══════════════════════════════════════════════
768
  document.querySelectorAll('.node').forEach(node => {
769
  const header = node.querySelector('.node-header');
770
- let drag = false, sx, sy, il, it;
 
771
  header.addEventListener('mousedown', e => {
772
- drag = true; sx = e.clientX; sy = e.clientY;
 
773
  il = parseInt(node.style.left) || 0;
774
  it = parseInt(node.style.top) || 0;
775
- node.style.zIndex = 100; e.preventDefault();
 
 
776
  });
777
- document.addEventListener('mousemove', e => {
778
- if (!drag) return;
779
- node.style.left = `${il + e.clientX - sx}px`;
780
- node.style.top = `${it + e.clientY - sy}px`;
 
781
  updateWires();
782
  });
783
- document.addEventListener('mouseup', () => {
784
- if (drag) { drag = false; node.style.zIndex = 10; }
785
  });
786
  });
787
 
788
- window.addEventListener('resize', updateWires);
789
- window.addEventListener('scroll', updateWires);
790
- document.addEventListener('scroll', updateWires, true);
791
- requestAnimationFrame(updateWires);
 
792
 
793
  // ══════════════════════════════════════════════
794
  // FILE UPLOAD
@@ -831,46 +1138,40 @@ const MODEL_INFO = {
831
  html: `<span class="model-badge q4b">QWEN 3.5 Β· 4B</span><br><br>
832
  Qwen3.5 4B multimodal model by Alibaba Cloud.
833
  Enhanced capacity over 2B β€” richer reasoning &amp; better instruction following.`,
834
- bg: 'rgba(255,200,80,0.07)',
835
- border: 'rgba(255,200,80,0.30)',
836
  },
837
  qwen_2b: {
838
  html: `<span class="model-badge q2b">QWEN 3.5 Β· 2B</span><br><br>
839
  Qwen3.5 2B multimodal model by Alibaba Cloud.
840
  Lightweight &amp; fast β€” ideal for quick Query, Caption, Point &amp; Detect tasks.`,
841
- bg: 'rgba(124,106,247,0.07)',
842
- border: 'rgba(124,106,247,0.25)',
843
  },
844
  qwen_vl: {
845
  html: `<span class="model-badge qvl">QWEN3-VL Β· 2B</span><br><br>
846
  Qwen3-VL-2B-Instruct β€” dedicated vision-language model by Alibaba Cloud.
847
  Strong spatial grounding, OCR &amp; instruction-following.`,
848
- bg: 'rgba(255,150,50,0.07)',
849
- border: 'rgba(255,150,50,0.25)',
850
  },
851
  lfm_450: {
852
  html: `<span class="model-badge lfm450">LFM Β· 450M</span><br><br>
853
  LFM2.5-VL 450M by LiquidAI. Ultra-lightweight edge model
854
  with solid grounding capabilities.`,
855
- bg: 'rgba(78,205,196,0.07)',
856
- border: 'rgba(78,205,196,0.25)',
857
  },
858
  lfm_16: {
859
  html: `<span class="model-badge lfm16">LFM Β· 1.6B</span><br><br>
860
  LFM2.5-VL 1.6B by LiquidAI. Larger liquid-state model offering
861
  enhanced reasoning &amp; richer visual understanding.`,
862
- bg: 'rgba(107,203,119,0.07)',
863
- border: 'rgba(107,203,119,0.25)',
864
  },
865
  };
866
 
867
  modelSelect.onchange = () => {
868
  const info = MODEL_INFO[modelSelect.value];
869
  if (!info) return;
870
- modelInfoBox.innerHTML = info.html;
871
- modelInfoBox.style.background = info.bg;
872
- modelInfoBox.style.borderColor = info.border;
873
- modelInfoBox.style.border = `1px solid ${info.border}`;
874
  };
875
 
876
  // ══════════════════════════════════════════════
@@ -951,7 +1252,6 @@ function drawGrounding(imgSrc, jsonText) {
951
  gCtx.font = `bold ${fs}px JetBrains Mono, monospace`;
952
 
953
  const items = Array.isArray(parsed) ? parsed : [parsed];
954
-
955
  items.forEach((item, i) => {
956
  const col = PALETTE[i % PALETTE.length];
957
 
@@ -964,17 +1264,11 @@ function drawGrounding(imgSrc, jsonText) {
964
 
965
  if (bbox) {
966
  let [x1,y1,x2,y2] = bbox;
967
- if (x1 <= 1 && y1 <= 1 && x2 <= 1 && y2 <= 1) {
968
- x1*=W; y1*=H; x2*=W; y2*=H;
969
- }
970
- const bw = x2-x1, bh = y2-y1;
971
  const lbl = item?.label || `${i+1}`;
972
-
973
- gCtx.fillStyle = hexToRgba(col, 0.18);
974
- gCtx.fillRect(x1, y1, bw, bh);
975
- gCtx.strokeStyle = col;
976
- gCtx.strokeRect(x1, y1, bw, bh);
977
-
978
  const tw = gCtx.measureText(lbl).width;
979
  const ph = fs*1.4, pw = tw+10;
980
  const lx = x1, ly = Math.max(0, y1-ph);
@@ -997,16 +1291,13 @@ function drawGrounding(imgSrc, jsonText) {
997
  if (x <= 1 && y <= 1) { x*=W; y*=H; }
998
  const r = Math.max(8, W/60);
999
  const lbl = item?.label || `${i+1}`;
1000
-
1001
  gCtx.beginPath();
1002
  gCtx.arc(x, y, r*1.6, 0, Math.PI*2);
1003
  gCtx.fillStyle = hexToRgba(col, 0.15); gCtx.fill();
1004
-
1005
  gCtx.beginPath();
1006
  gCtx.arc(x, y, r, 0, Math.PI*2);
1007
- gCtx.fillStyle = col; gCtx.fill();
1008
  gCtx.strokeStyle = '#fff'; gCtx.stroke();
1009
-
1010
  gCtx.fillStyle = '#fff';
1011
  gCtx.fillText(lbl, x+r+4, y+fs*0.4);
1012
  }
 
151
  yield f"data: {json.dumps({'chunk': '[Error] Qwen3.5-4B model not loaded.'})}\n\n"
152
  yield "data: [DONE]\n\n"
153
  return
 
154
  messages = [{"role": "user", "content": [
155
  {"type": "image", "image": image},
156
  {"type": "text", "text": full_prompt},
 
182
  yield f"data: {json.dumps({'chunk': '[Error] Qwen3.5-2B model not loaded.'})}\n\n"
183
  yield "data: [DONE]\n\n"
184
  return
 
185
  messages = [{"role": "user", "content": [
186
  {"type": "image", "image": image},
187
  {"type": "text", "text": full_prompt},
 
213
  yield f"data: {json.dumps({'chunk': '[Error] Qwen3-VL model not loaded.'})}\n\n"
214
  yield "data: [DONE]\n\n"
215
  return
 
216
  messages = [{"role": "user", "content": [
217
  {"type": "image", "image": image},
218
  {"type": "text", "text": full_prompt},
 
244
  yield f"data: {json.dumps({'chunk': '[Error] LFM-450M model not loaded.'})}\n\n"
245
  yield "data: [DONE]\n\n"
246
  return
 
247
  conversation = [{"role": "user", "content": [
248
  {"type": "image", "image": image},
249
  {"type": "text", "text": full_prompt},
 
272
  yield f"data: {json.dumps({'chunk': '[Error] LFM-1.6B model not loaded.'})}\n\n"
273
  yield "data: [DONE]\n\n"
274
  return
 
275
  conversation = [{"role": "user", "content": [
276
  {"type": "image", "image": image},
277
  {"type": "text", "text": full_prompt},
 
348
  * { box-sizing: border-box; margin: 0; padding: 0; }
349
 
350
  html, body {
351
+ width: 100%; height: 100%;
352
+ overflow: hidden;
353
  background: var(--bg);
354
  color: var(--text);
355
  font-family: 'JetBrains Mono', monospace;
356
  }
357
 
 
 
 
 
 
 
 
 
 
 
 
358
  /* ── Top Bar ── */
359
  .top-bar {
360
+ position: fixed; top: 0; left: 0; right: 0;
361
  height: 42px;
362
+ background: rgba(13,13,15,0.97);
363
  border-bottom: 1px solid var(--node-border);
364
  display: flex; align-items: center; padding: 0 20px;
365
+ gap: 12px; z-index: 2000;
366
  backdrop-filter: blur(12px);
367
  }
368
  .top-bar .logo { font-size: 13px; font-weight: 700; color: var(--accent); letter-spacing: 0.05em; }
 
376
  font-size: 10px; color: var(--accent);
377
  }
378
 
379
+ /* ── Viewport (the scrollable/zoomable area) ── */
380
+ #viewport {
381
+ position: fixed;
382
+ top: 42px; left: 0; right: 0; bottom: 0;
383
+ overflow: hidden;
384
+ cursor: default;
385
+ }
386
+
387
+ /* ── World (the zoomable container) ── */
388
+ #world {
389
+ position: absolute;
390
+ top: 0; left: 0;
391
+ width: 2400px;
392
+ height: 1600px;
393
+ transform-origin: 0 0;
394
+ background-image:
395
+ radial-gradient(circle at 20% 50%, rgba(124,106,247,0.04) 0%, transparent 50%),
396
+ radial-gradient(circle at 80% 20%, rgba(78,205,196,0.04) 0%, transparent 50%),
397
+ linear-gradient(var(--grid) 1px, transparent 1px),
398
+ linear-gradient(90deg, var(--grid) 1px, transparent 1px);
399
+ background-size: 100% 100%, 100% 100%, 24px 24px, 24px 24px;
400
  }
401
 
402
  svg.wires {
 
565
  width: 6px; height: 6px; border-radius: 50%;
566
  background: var(--muted); display: inline-block; margin-right: 6px;
567
  }
568
+ .status-dot.active { background: var(--accent2); box-shadow: 0 0 5px var(--accent2); }
 
 
 
569
 
570
  /* ── Model badges ── */
571
  .model-badge {
 
585
  flex-shrink: 0;
586
  }
587
 
588
+ /* ══════════════════════════════════════════
589
+ ZOOM CONTROLS β€” fixed bottom-right
590
+ ══════════════════════════════════════════ */
591
+ .zoom-bar {
592
+ position: fixed;
593
+ bottom: 24px; right: 24px;
594
+ z-index: 3000;
595
+ display: flex; align-items: center; gap: 0;
596
+ background: rgba(19,19,26,0.92);
597
+ border: 1px solid var(--node-border);
598
+ border-radius: 10px;
599
+ backdrop-filter: blur(14px);
600
+ box-shadow: 0 8px 32px rgba(0,0,0,0.55);
601
+ overflow: hidden;
602
+ user-select: none;
603
+ }
604
+
605
+ .zoom-btn {
606
+ width: 36px; height: 36px;
607
+ display: flex; align-items: center; justify-content: center;
608
+ background: transparent;
609
+ border: none; cursor: pointer;
610
+ color: var(--muted);
611
+ font-size: 18px; font-weight: 700;
612
+ transition: background 0.15s, color 0.15s;
613
+ font-family: 'JetBrains Mono', monospace;
614
+ flex-shrink: 0;
615
+ }
616
+ .zoom-btn:hover { background: rgba(124,106,247,0.15); color: var(--accent); }
617
+ .zoom-btn:active { background: rgba(124,106,247,0.28); }
618
+
619
+ .zoom-divider {
620
+ width: 1px; height: 20px;
621
+ background: var(--node-border);
622
+ flex-shrink: 0;
623
+ }
624
+
625
+ .zoom-track {
626
+ position: relative;
627
+ width: 100px; height: 36px;
628
+ display: flex; align-items: center;
629
+ padding: 0 10px;
630
+ cursor: pointer;
631
+ }
632
+ .zoom-track-bg {
633
+ width: 100%; height: 3px;
634
+ background: var(--node-border);
635
+ border-radius: 3px;
636
+ position: relative;
637
+ }
638
+ .zoom-track-fill {
639
+ position: absolute; left: 0; top: 0; height: 100%;
640
+ background: linear-gradient(90deg, var(--accent), var(--accent2));
641
+ border-radius: 3px;
642
+ transition: width 0.1s;
643
+ }
644
+ .zoom-thumb {
645
+ position: absolute;
646
+ width: 13px; height: 13px;
647
+ background: #fff;
648
+ border: 2px solid var(--accent);
649
+ border-radius: 50%;
650
+ top: 50%; transform: translateY(-50%);
651
+ box-shadow: 0 0 6px rgba(124,106,247,0.5);
652
+ transition: left 0.1s;
653
+ pointer-events: none;
654
+ }
655
+
656
+ .zoom-label {
657
+ min-width: 42px; height: 36px;
658
+ display: flex; align-items: center; justify-content: center;
659
+ font-size: 10px; font-weight: 700;
660
+ color: var(--accent);
661
+ font-family: 'JetBrains Mono', monospace;
662
+ letter-spacing: 0.04em;
663
+ border-left: 1px solid var(--node-border);
664
+ padding: 0 8px;
665
+ }
666
+
667
+ .zoom-reset {
668
+ height: 36px;
669
+ display: flex; align-items: center; justify-content: center;
670
+ background: transparent;
671
+ border: none; border-left: 1px solid var(--node-border);
672
+ cursor: pointer;
673
+ color: var(--muted);
674
+ font-size: 9px; font-weight: 700;
675
+ font-family: 'JetBrains Mono', monospace;
676
+ letter-spacing: 0.06em;
677
+ padding: 0 10px;
678
+ transition: background 0.15s, color 0.15s;
679
+ text-transform: uppercase;
680
+ }
681
+ .zoom-reset:hover { background: rgba(78,205,196,0.12); color: var(--accent2); }
682
  </style>
683
  </head>
684
  <body>
685
 
686
+ <!-- Top Bar -->
687
  <div class="top-bar">
688
  <span class="logo">MULTIMODAL EDGE</span>
689
  <span class="sep">|</span>
 
691
  <span class="badge">v2.3 β€” PENTA MODEL</span>
692
  </div>
693
 
694
+ <!-- Viewport + World -->
695
+ <div id="viewport">
696
+ <div id="world">
697
+ <svg class="wires">
698
+ <path id="wire-img-task" class="wire" />
699
+ <path id="wire-model-task" class="wire" />
700
+ <path id="wire-task-out" class="wire" />
701
+ <path id="wire-task-gnd" class="wire" />
702
+ </svg>
703
+
704
+ <!-- ─── ID 01 : Image Input ─── -->
705
+ <div class="node fixed-height" id="node-img" style="left:60px; top:60px;">
706
+ <div class="node-header">
707
+ <span><span class="status-dot" id="dot-img"></span>Input Image</span>
708
+ <span class="id">ID: 01</span>
709
+ </div>
710
+ <div class="node-body">
711
+ <div>
712
+ <label>Upload Image</label>
713
+ <div class="file-upload" id="dropZone">
714
+ <svg width="30" height="30" viewBox="0 0 24 24" fill="none"
715
+ stroke="#7c6af7" stroke-width="1.5"
716
+ stroke-linecap="round" stroke-linejoin="round">
717
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
718
+ <circle cx="8.5" cy="8.5" r="1.5"/>
719
+ <polyline points="21 15 16 10 5 21"/>
720
+ </svg>
721
+ <span>Click or drop image here</span>
722
+ <input type="file" id="fileInput" accept="image/*">
723
+ </div>
724
+ <img id="imgPreview" class="img-preview" />
725
  </div>
 
726
  </div>
727
+ <div class="port out" id="port-img-out" style="top:50%;transform:translateY(-50%);"></div>
728
  </div>
 
 
729
 
730
+ <!-- ─── ID 02 : Model Selector ─── -->
731
+ <div class="node fixed-height" id="node-model" style="left:60px; top:410px;">
732
+ <div class="node-header">
733
+ <span><span class="status-dot" id="dot-model"></span>Model Selector</span>
734
+ <span class="id">ID: 02</span>
 
 
 
 
 
 
 
 
 
 
 
735
  </div>
736
+ <div class="node-body">
737
+ <div>
738
+ <label>Active Model</label>
739
+ <select id="modelSelect">
740
+ <option value="qwen_4b">Qwen3.5-4B</option>
741
+ <option value="qwen_2b">Qwen3.5-2B</option>
742
+ <option value="qwen_vl">Qwen3-VL-2B-Instruct</option>
743
+ <option value="lfm_450">LFM2.5-VL-450M (LiquidAI)</option>
744
+ <option value="lfm_16">LFM2.5-VL-1.6B (LiquidAI)</option>
745
+ </select>
746
+ </div>
747
+ <div id="modelInfoBox" class="model-info-box"
748
+ style="background:rgba(255,200,80,0.07);border:1px solid rgba(255,200,80,0.3);">
749
+ <span class="model-badge q4b">QWEN 3.5 Β· 4B</span><br><br>
750
+ Qwen3.5 4B multimodal model by Alibaba Cloud.
751
+ Enhanced capacity over 2B β€” richer reasoning &amp; better instruction following.
752
+ </div>
753
+ <div style="flex:1;"></div>
754
  </div>
755
+ <div class="port out" id="port-model-out" style="top:50%;transform:translateY(-50%);"></div>
756
  </div>
 
 
757
 
758
+ <!-- ─── ID 03 : Task Config ─── -->
759
+ <div class="node fixed-height" id="node-task" style="left:450px; top:60px;">
760
+ <div class="port in" id="port-task-in" style="top:50%;transform:translateY(-50%);"></div>
761
+ <div class="node-header">
762
+ <span><span class="status-dot" id="dot-task"></span>Task Config</span>
763
+ <span class="id">ID: 03</span>
 
 
 
 
 
 
 
 
 
 
764
  </div>
765
+ <div class="node-body">
766
+ <div>
767
+ <label>Task Category</label>
768
+ <select id="categorySelect">
769
+ <option value="Query">Query</option>
770
+ <option value="Caption">Caption</option>
771
+ <option value="Point">Point</option>
772
+ <option value="Detect">Detect</option>
773
+ </select>
774
+ </div>
775
+ <div>
776
+ <label>Prompt Directive</label>
777
+ <textarea id="promptInput" rows="4"
778
+ placeholder="e.g., Count the total number of boats and describe the environment."></textarea>
779
+ </div>
780
+ <button class="run-btn" id="runBtn">
781
+ <span>Execute</span>
782
+ <span class="loader" id="btnLoader"></span>
783
+ </button>
784
  </div>
785
+ <div class="port out" id="port-task-out" style="top:50%;transform:translateY(-50%);"></div>
 
 
 
786
  </div>
 
 
787
 
788
+ <!-- ─── ID 04 : Output Stream ─── -->
789
+ <div class="node fixed-height" id="node-out" style="left:840px; top:60px;">
790
+ <div class="port in" id="port-out-in" style="top:50%;transform:translateY(-50%);"></div>
791
+ <div class="node-header">
792
+ <span><span class="status-dot" id="dot-out"></span>Output Stream</span>
793
+ <span class="id">ID: 04</span>
794
+ </div>
795
+ <div class="node-body">
796
+ <label>Streamed Result</label>
797
+ <div class="output-box" id="outputBox">Results will stream here...</div>
798
+ </div>
799
  </div>
 
800
 
801
+ <!-- ─── ID 05 : Grounding Visualiser ─── -->
802
+ <div class="node fixed-height" id="node-gnd" style="left:840px; top:410px;">
803
+ <div class="port in" id="port-gnd-in" style="top:50%;transform:translateY(-50%);"></div>
804
+ <div class="node-header">
805
+ <span><span class="status-dot" id="dot-gnd"></span>View Grounding</span>
806
+ <span class="id">ID: 05</span>
807
+ </div>
808
+ <div class="node-body">
809
+ <label>Point / Detect Overlay</label>
810
+ <div class="ground-canvas-wrap">
811
+ <canvas id="groundCanvas"></canvas>
812
+ <div class="ground-placeholder" id="groundPlaceholder">
813
+ Active for Point / Detect tasks.<br>Run inference to visualise.
814
+ </div>
815
  </div>
816
  </div>
817
  </div>
818
+
819
+ </div><!-- /#world -->
820
+ </div><!-- /#viewport -->
821
+
822
+ <!-- ══ ZOOM BAR ══ -->
823
+ <div class="zoom-bar" id="zoomBar">
824
+ <!-- Zoom out -->
825
+ <button class="zoom-btn" id="zoomOut" title="Zoom Out">
826
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none"
827
+ stroke="currentColor" stroke-width="2" stroke-linecap="round">
828
+ <line x1="2" y1="7" x2="12" y2="7"/>
829
+ </svg>
830
+ </button>
831
+
832
+ <div class="zoom-divider"></div>
833
+
834
+ <!-- Slider track -->
835
+ <div class="zoom-track" id="zoomTrack">
836
+ <div class="zoom-track-bg">
837
+ <div class="zoom-track-fill" id="zoomFill"></div>
838
+ </div>
839
+ <div class="zoom-thumb" id="zoomThumb"></div>
840
  </div>
841
 
842
+ <div class="zoom-divider"></div>
843
+
844
+ <!-- Zoom in -->
845
+ <button class="zoom-btn" id="zoomIn" title="Zoom In">
846
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none"
847
+ stroke="currentColor" stroke-width="2" stroke-linecap="round">
848
+ <line x1="7" y1="2" x2="7" y2="12"/>
849
+ <line x1="2" y1="7" x2="12" y2="7"/>
850
+ </svg>
851
+ </button>
852
+
853
+ <div class="zoom-divider"></div>
854
+
855
+ <!-- Percentage label -->
856
+ <div class="zoom-label" id="zoomLabel">100%</div>
857
+
858
+ <!-- Reset -->
859
+ <button class="zoom-reset" id="zoomReset" title="Reset Zoom">FIT</button>
860
  </div>
861
 
862
  <script>
863
+ // ══════════════════════════════════════════════════════════
864
+ // PAN + ZOOM ENGINE
865
+ // ══════════════════════════════════════════════════════════
866
+ const viewport = document.getElementById('viewport');
867
+ const world = document.getElementById('world');
868
+
869
+ const ZOOM_MIN = 0.25;
870
+ const ZOOM_MAX = 2.0;
871
+ const ZOOM_STEP = 0.1;
872
+
873
+ let scale = 1.0;
874
+ let panX = 0;
875
+ let panY = 0;
876
+ let isPanning = false;
877
+ let panStartX, panStartY, panOriginX, panOriginY;
878
+
879
+ function clamp(v, lo, hi) { return Math.min(Math.max(v, lo), hi); }
880
+
881
+ function applyTransform() {
882
+ world.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
883
+ updateZoomUI();
884
+ updateWires();
885
+ }
886
+
887
+ // Centre the canvas initially
888
+ function fitToView() {
889
+ const vw = viewport.clientWidth;
890
+ const vh = viewport.clientHeight;
891
+ scale = clamp(Math.min(vw / 1360, vh / 800), ZOOM_MIN, ZOOM_MAX);
892
+ panX = (vw - 1360 * scale) / 2;
893
+ panY = (vh - 800 * scale) / 2;
894
+ applyTransform();
895
+ }
896
+
897
+ // ── Zoom toward a point ──
898
+ function zoomAt(newScale, cx, cy) {
899
+ newScale = clamp(newScale, ZOOM_MIN, ZOOM_MAX);
900
+ const ratio = newScale / scale;
901
+ panX = cx - ratio * (cx - panX);
902
+ panY = cy - ratio * (cy - panY);
903
+ scale = newScale;
904
+ applyTransform();
905
+ }
906
+
907
+ // ── Mouse wheel ──
908
+ viewport.addEventListener('wheel', e => {
909
+ e.preventDefault();
910
+ const rect = viewport.getBoundingClientRect();
911
+ const cx = e.clientX - rect.left;
912
+ const cy = e.clientY - rect.top;
913
+ const delta = e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP;
914
+ zoomAt(scale + delta, cx, cy);
915
+ }, { passive: false });
916
+
917
+ // ── Middle-mouse / space-drag pan ──
918
+ let spaceDown = false;
919
+ document.addEventListener('keydown', e => { if (e.code === 'Space') { spaceDown = true; viewport.style.cursor = 'grab'; } });
920
+ document.addEventListener('keyup', e => { if (e.code === 'Space') { spaceDown = false; viewport.style.cursor = 'default'; } });
921
+
922
+ viewport.addEventListener('mousedown', e => {
923
+ if (e.button === 1 || spaceDown) {
924
+ e.preventDefault();
925
+ isPanning = true;
926
+ panStartX = e.clientX; panStartY = e.clientY;
927
+ panOriginX = panX; panOriginY = panY;
928
+ viewport.style.cursor = 'grabbing';
929
+ }
930
+ });
931
+ window.addEventListener('mousemove', e => {
932
+ if (!isPanning) return;
933
+ panX = panOriginX + (e.clientX - panStartX);
934
+ panY = panOriginY + (e.clientY - panStartY);
935
+ applyTransform();
936
+ });
937
+ window.addEventListener('mouseup', e => {
938
+ if (isPanning) {
939
+ isPanning = false;
940
+ viewport.style.cursor = spaceDown ? 'grab' : 'default';
941
+ }
942
+ });
943
+
944
+ // ── Touch pan / pinch ──
945
+ let lastTouches = null;
946
+ viewport.addEventListener('touchstart', e => {
947
+ lastTouches = e.touches;
948
+ }, { passive: true });
949
+ viewport.addEventListener('touchmove', e => {
950
+ e.preventDefault();
951
+ if (e.touches.length === 1 && lastTouches?.length === 1) {
952
+ const dx = e.touches[0].clientX - lastTouches[0].clientX;
953
+ const dy = e.touches[0].clientY - lastTouches[0].clientY;
954
+ panX += dx; panY += dy;
955
+ applyTransform();
956
+ } else if (e.touches.length === 2 && lastTouches?.length === 2) {
957
+ const d0 = Math.hypot(lastTouches[0].clientX - lastTouches[1].clientX,
958
+ lastTouches[0].clientY - lastTouches[1].clientY);
959
+ const d1 = Math.hypot(e.touches[0].clientX - e.touches[1].clientX,
960
+ e.touches[0].clientY - e.touches[1].clientY);
961
+ const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
962
+ const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
963
+ const rect = viewport.getBoundingClientRect();
964
+ zoomAt(scale * (d1 / d0), midX - rect.left, midY - rect.top);
965
+ }
966
+ lastTouches = e.touches;
967
+ }, { passive: false });
968
+
969
+ // ══════════════════════════════════════════════════════════
970
+ // ZOOM BAR UI
971
+ // ══════════════════════════════════════════════════════════
972
+ const zoomInBtn = document.getElementById('zoomIn');
973
+ const zoomOutBtn = document.getElementById('zoomOut');
974
+ const zoomReset = document.getElementById('zoomReset');
975
+ const zoomLabel = document.getElementById('zoomLabel');
976
+ const zoomFill = document.getElementById('zoomFill');
977
+ const zoomThumb = document.getElementById('zoomThumb');
978
+ const zoomTrack = document.getElementById('zoomTrack');
979
+
980
+ function updateZoomUI() {
981
+ const pct = Math.round(scale * 100);
982
+ zoomLabel.textContent = `${pct}%`;
983
+
984
+ // Map scale [ZOOM_MIN … ZOOM_MAX] β†’ [0 … 100]
985
+ const t = (scale - ZOOM_MIN) / (ZOOM_MAX - ZOOM_MIN);
986
+ const trackW = zoomTrack.getBoundingClientRect().width || 100;
987
+ const thumbPx = t * (trackW - 20) + 10; // 10px padding each side
988
+
989
+ zoomFill.style.width = `${t * 100}%`;
990
+ zoomThumb.style.left = `${thumbPx}px`;
991
+ }
992
+
993
+ // Zoom in / out buttons
994
+ zoomInBtn.addEventListener('click', () => {
995
+ const cx = viewport.clientWidth / 2;
996
+ const cy = viewport.clientHeight / 2;
997
+ zoomAt(scale + ZOOM_STEP, cx, cy);
998
+ });
999
+ zoomOutBtn.addEventListener('click', () => {
1000
+ const cx = viewport.clientWidth / 2;
1001
+ const cy = viewport.clientHeight / 2;
1002
+ zoomAt(scale - ZOOM_STEP, cx, cy);
1003
+ });
1004
+
1005
+ // Reset / fit
1006
+ zoomReset.addEventListener('click', fitToView);
1007
+
1008
+ // Drag on the slider track
1009
+ let sliderDragging = false;
1010
+ zoomTrack.addEventListener('mousedown', e => {
1011
+ sliderDragging = true;
1012
+ handleSliderMove(e);
1013
+ e.stopPropagation();
1014
+ });
1015
+ window.addEventListener('mousemove', e => {
1016
+ if (!sliderDragging) return;
1017
+ handleSliderMove(e);
1018
+ });
1019
+ window.addEventListener('mouseup', () => { sliderDragging = false; });
1020
+
1021
+ function handleSliderMove(e) {
1022
+ const rect = zoomTrack.getBoundingClientRect();
1023
+ const t = clamp((e.clientX - rect.left - 10) / (rect.width - 20), 0, 1);
1024
+ const newScale = ZOOM_MIN + t * (ZOOM_MAX - ZOOM_MIN);
1025
+ const cx = viewport.clientWidth / 2;
1026
+ const cy = viewport.clientHeight / 2;
1027
+ zoomAt(newScale, cx, cy);
1028
+ }
1029
+
1030
  // ══════════════════════════════════════════════
1031
+ // WIRE DRAWING (world-relative)
1032
  // ══════════════════════════════════════════════
 
 
1033
  function portCenter(id) {
1034
  const el = document.getElementById(id);
1035
  if (!el) return { x: 0, y: 0 };
1036
+ // Get position relative to #world (un-transformed)
1037
+ let ox = 0, oy = 0, cur = el;
1038
+ while (cur && cur !== world) {
1039
+ ox += cur.offsetLeft; oy += cur.offsetTop;
1040
+ cur = cur.offsetParent;
1041
+ }
1042
  return {
1043
+ x: ox + el.offsetWidth / 2,
1044
+ y: oy + el.offsetHeight / 2,
1045
  };
1046
  }
1047
 
 
1051
  }
1052
 
1053
  function updateWires() {
1054
+ const pairs = [
1055
  ['wire-img-task', 'port-img-out', 'port-task-in'],
1056
  ['wire-model-task', 'port-model-out', 'port-task-in'],
1057
  ['wire-task-out', 'port-task-out', 'port-out-in'],
1058
  ['wire-task-gnd', 'port-task-out', 'port-gnd-in'],
1059
  ];
1060
+ for (const [id, from, to] of pairs) {
1061
  const el = document.getElementById(id);
1062
  if (el) el.setAttribute('d', bezier(portCenter(from), portCenter(to)));
1063
  }
1064
  }
1065
 
1066
  // ══════════════════════════════════════════════
1067
+ // NODE DRAGGING
1068
  // ══════════════════════════════════════════════
1069
  document.querySelectorAll('.node').forEach(node => {
1070
  const header = node.querySelector('.node-header');
1071
+ let dragging = false, sx, sy, il, it;
1072
+
1073
  header.addEventListener('mousedown', e => {
1074
+ dragging = true;
1075
+ sx = e.clientX; sy = e.clientY;
1076
  il = parseInt(node.style.left) || 0;
1077
  it = parseInt(node.style.top) || 0;
1078
+ node.style.zIndex = 100;
1079
+ e.preventDefault();
1080
+ e.stopPropagation(); // don't trigger pan
1081
  });
1082
+ window.addEventListener('mousemove', e => {
1083
+ if (!dragging) return;
1084
+ // Account for current scale when dragging
1085
+ node.style.left = `${il + (e.clientX - sx) / scale}px`;
1086
+ node.style.top = `${it + (e.clientY - sy) / scale}px`;
1087
  updateWires();
1088
  });
1089
+ window.addEventListener('mouseup', () => {
1090
+ if (dragging) { dragging = false; node.style.zIndex = 10; }
1091
  });
1092
  });
1093
 
1094
+ // ══════════════════════════════════════════════
1095
+ // INIT
1096
+ // ══════════════════════════════════════════════
1097
+ window.addEventListener('resize', () => { applyTransform(); });
1098
+ requestAnimationFrame(() => { fitToView(); });
1099
 
1100
  // ══════════════════════════════════════════════
1101
  // FILE UPLOAD
 
1138
  html: `<span class="model-badge q4b">QWEN 3.5 Β· 4B</span><br><br>
1139
  Qwen3.5 4B multimodal model by Alibaba Cloud.
1140
  Enhanced capacity over 2B β€” richer reasoning &amp; better instruction following.`,
1141
+ bg: 'rgba(255,200,80,0.07)', border: 'rgba(255,200,80,0.30)',
 
1142
  },
1143
  qwen_2b: {
1144
  html: `<span class="model-badge q2b">QWEN 3.5 Β· 2B</span><br><br>
1145
  Qwen3.5 2B multimodal model by Alibaba Cloud.
1146
  Lightweight &amp; fast β€” ideal for quick Query, Caption, Point &amp; Detect tasks.`,
1147
+ bg: 'rgba(124,106,247,0.07)', border: 'rgba(124,106,247,0.25)',
 
1148
  },
1149
  qwen_vl: {
1150
  html: `<span class="model-badge qvl">QWEN3-VL Β· 2B</span><br><br>
1151
  Qwen3-VL-2B-Instruct β€” dedicated vision-language model by Alibaba Cloud.
1152
  Strong spatial grounding, OCR &amp; instruction-following.`,
1153
+ bg: 'rgba(255,150,50,0.07)', border: 'rgba(255,150,50,0.25)',
 
1154
  },
1155
  lfm_450: {
1156
  html: `<span class="model-badge lfm450">LFM Β· 450M</span><br><br>
1157
  LFM2.5-VL 450M by LiquidAI. Ultra-lightweight edge model
1158
  with solid grounding capabilities.`,
1159
+ bg: 'rgba(78,205,196,0.07)', border: 'rgba(78,205,196,0.25)',
 
1160
  },
1161
  lfm_16: {
1162
  html: `<span class="model-badge lfm16">LFM Β· 1.6B</span><br><br>
1163
  LFM2.5-VL 1.6B by LiquidAI. Larger liquid-state model offering
1164
  enhanced reasoning &amp; richer visual understanding.`,
1165
+ bg: 'rgba(107,203,119,0.07)', border: 'rgba(107,203,119,0.25)',
 
1166
  },
1167
  };
1168
 
1169
  modelSelect.onchange = () => {
1170
  const info = MODEL_INFO[modelSelect.value];
1171
  if (!info) return;
1172
+ modelInfoBox.innerHTML = info.html;
1173
+ modelInfoBox.style.background = info.bg;
1174
+ modelInfoBox.style.border = `1px solid ${info.border}`;
 
1175
  };
1176
 
1177
  // ══════════════════════════════════════════════
 
1252
  gCtx.font = `bold ${fs}px JetBrains Mono, monospace`;
1253
 
1254
  const items = Array.isArray(parsed) ? parsed : [parsed];
 
1255
  items.forEach((item, i) => {
1256
  const col = PALETTE[i % PALETTE.length];
1257
 
 
1264
 
1265
  if (bbox) {
1266
  let [x1,y1,x2,y2] = bbox;
1267
+ if (x1 <= 1 && y1 <= 1 && x2 <= 1 && y2 <= 1) { x1*=W; y1*=H; x2*=W; y2*=H; }
1268
+ const bw = x2-x1, bh = y2-y1;
 
 
1269
  const lbl = item?.label || `${i+1}`;
1270
+ gCtx.fillStyle = hexToRgba(col, 0.18); gCtx.fillRect(x1, y1, bw, bh);
1271
+ gCtx.strokeStyle = col; gCtx.strokeRect(x1, y1, bw, bh);
 
 
 
 
1272
  const tw = gCtx.measureText(lbl).width;
1273
  const ph = fs*1.4, pw = tw+10;
1274
  const lx = x1, ly = Math.max(0, y1-ph);
 
1291
  if (x <= 1 && y <= 1) { x*=W; y*=H; }
1292
  const r = Math.max(8, W/60);
1293
  const lbl = item?.label || `${i+1}`;
 
1294
  gCtx.beginPath();
1295
  gCtx.arc(x, y, r*1.6, 0, Math.PI*2);
1296
  gCtx.fillStyle = hexToRgba(col, 0.15); gCtx.fill();
 
1297
  gCtx.beginPath();
1298
  gCtx.arc(x, y, r, 0, Math.PI*2);
1299
+ gCtx.fillStyle = col; gCtx.fill();
1300
  gCtx.strokeStyle = '#fff'; gCtx.stroke();
 
1301
  gCtx.fillStyle = '#fff';
1302
  gCtx.fillText(lbl, x+r+4, y+fs*0.4);
1303
  }