Ryadg commited on
Commit
f09fe58
Β·
1 Parent(s): 3aa55b2

feat: hybrid approach native Gradio file upload styled with custom CSS

Browse files
Files changed (2) hide show
  1. app.py +38 -5
  2. ui/index.html +3 -173
app.py CHANGED
@@ -40,10 +40,43 @@ body {
40
  background: #0A0F1E !important;
41
  margin: 0 !important;
42
  }
43
- /* Hide all visible Gradio components - only show our custom HTML */
44
- .gr-file, .gr-textbox, .gr-button, .gr-radio {
45
- display: none !important;
 
 
 
 
 
 
46
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  """
48
 
49
  import pathlib
@@ -313,8 +346,8 @@ document.addEventListener('DOMContentLoaded', function() {
313
  total_state = gr.State(0)
314
 
315
  with gr.Row():
316
- pdf_input = gr.File(label="πŸ“Ž Cours PDF", file_types=[".pdf"], scale=3, visible=False)
317
- load_btn = gr.Button("Load PDF", variant="primary", scale=2, visible=False, elem_id="hidden-load-btn")
318
  language_selector = gr.Radio(["English", "FranΓ§ais"], value="English", label="🌐 Language", visible=False)
319
 
320
  with gr.Row():
 
40
  background: #0A0F1E !important;
41
  margin: 0 !important;
42
  }
43
+ /* Style native Gradio file upload to match dark theme */
44
+ [data-testid="upload-zone"],
45
+ .file-drop-zone,
46
+ .upload-container {
47
+ background: rgba(124,58,237,0.05) !important;
48
+ border: 2px dashed rgba(124,58,237,0.35) !important;
49
+ border-radius: 10px !important;
50
+ min-height: 120px !important;
51
+ color: rgba(255,255,255,0.6) !important;
52
  }
53
+ [data-testid="upload-zone"]:hover,
54
+ .file-drop-zone:hover {
55
+ border-color: #06B6D4 !important;
56
+ background: rgba(6,182,212,0.04) !important;
57
+ box-shadow: 0 0 28px rgba(6,182,212,0.15) !important;
58
+ }
59
+ /* Style Load PDF button */
60
+ #hidden-load-btn button {
61
+ background: linear-gradient(135deg, #7C3AED, #06B6D4) !important;
62
+ border-radius: 50px !important;
63
+ color: white !important;
64
+ font-weight: 700 !important;
65
+ border: none !important;
66
+ box-shadow: 0 4px 20px rgba(124,58,237,0.35) !important;
67
+ padding: 12px 28px !important;
68
+ }
69
+ #hidden-load-btn button:hover {
70
+ box-shadow: 0 6px 28px rgba(124,58,237,0.5) !important;
71
+ }
72
+ /* File preview area */
73
+ .file-preview-holder, .file-name-wrapper {
74
+ color: #A78BFA !important;
75
+ background: rgba(124,58,237,0.07) !important;
76
+ border-radius: 8px !important;
77
+ }
78
+ /* Labels */
79
+ .block label span { color: rgba(255,255,255,0.6) !important; }
80
  """
81
 
82
  import pathlib
 
346
  total_state = gr.State(0)
347
 
348
  with gr.Row():
349
+ pdf_input = gr.File(label="πŸ“Ž Cours PDF", file_types=[".pdf"], scale=3)
350
+ load_btn = gr.Button("Load PDF", variant="primary", scale=2, elem_id="hidden-load-btn")
351
  language_selector = gr.Radio(["English", "FranΓ§ais"], value="English", label="🌐 Language", visible=False)
352
 
353
  with gr.Row():
ui/index.html CHANGED
@@ -94,38 +94,6 @@
94
  }
95
  .card:hover { border-color: rgba(255,255,255,.12); }
96
 
97
- /* ── Drop zone ────────────────────────────────────────────────────────── */
98
- .drop-zone {
99
- border: 2px dashed rgba(124,58,237,.35);
100
- border-radius: var(--r-sm);
101
- padding: 40px 24px;
102
- text-align: center;
103
- cursor: pointer;
104
- transition: border-color .2s, box-shadow .2s, background .2s;
105
- position: relative;
106
- }
107
- .drop-zone:hover, .drop-zone.drag-over {
108
- border-color: var(--cyan);
109
- box-shadow: 0 0 28px rgba(6,182,212,.15);
110
- background: rgba(6,182,212,.04);
111
- }
112
- .drop-zone.has-file {
113
- border-color: var(--purple);
114
- box-shadow: 0 0 28px rgba(124,58,237,.15);
115
- background: rgba(124,58,237,.04);
116
- }
117
- .upload-icon { font-size: 2.4rem; display: block; margin-bottom: 12px;
118
- transition: transform .2s; }
119
- .drop-zone:hover .upload-icon { transform: translateY(-4px); }
120
- .drop-title { font-weight: 600; font-size: 1rem; margin-bottom: 4px; }
121
- .drop-hint { color: var(--muted); font-size: .85rem; margin-bottom: 16px; }
122
- .file-name {
123
- margin-top: 12px; font-size: .9rem; font-weight: 600;
124
- color: var(--purple-l);
125
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
126
- max-width: 280px; margin-inline: auto;
127
- }
128
-
129
  /* ── Buttons ──────────────────────────────────────────────────────────── */
130
  button { cursor: pointer; font-family: inherit; font-size: .95rem;
131
  border: none; outline: none; transition: all .2s; }
@@ -156,17 +124,6 @@
156
  }
157
  .btn-ghost:disabled { opacity: .35; cursor: not-allowed; }
158
 
159
- .btn-browse {
160
- display: inline-block;
161
- padding: 9px 22px; border-radius: 50px; font-weight: 600; font-size: .9rem;
162
- border: 1px solid rgba(124,58,237,.5); color: var(--purple-l);
163
- background: rgba(124,58,237,.08);
164
- cursor: pointer; transition: all .2s;
165
- }
166
- .btn-browse:hover {
167
- background: rgba(124,58,237,.18); border-color: var(--purple);
168
- }
169
-
170
  /* ── Pill toggles ─────────────────────────────────────────────────────── */
171
  .toggle-group { display: flex; gap: 6px; }
172
 
@@ -182,19 +139,6 @@
182
  box-shadow: 0 2px 12px rgba(124,58,237,.4);
183
  }
184
 
185
- /* ── Status / alerts ──────────────────────────────────────────────────── */
186
- .status {
187
- margin-top: 14px; padding: 11px 16px; border-radius: var(--r-sm);
188
- font-size: .9rem; font-weight: 500; line-height: 1.4;
189
- animation: fadeUp .3s ease both;
190
- }
191
- .status.info { background: rgba(6,182,212,.12); border: 1px solid rgba(6,182,212,.3); color: var(--cyan-l); }
192
- .status.ok { background: rgba(16,185,129,.12); border: 1px solid rgba(16,185,129,.3); color: #6EE7B7; }
193
- .status.error { background: rgba(239,68,68,.12); border: 1px solid rgba(239,68,68,.3); color: #FCA5A5; }
194
-
195
- /* ── Upload actions row ───────────────────────────────────────────────── */
196
- .upload-actions { margin-top: 20px; display: flex; justify-content: center; }
197
-
198
  /* ── Quiz controls row ───────────────────────────────────────────────── */
199
  .controls-row {
200
  display: flex; align-items: center; flex-wrap: wrap; gap: 12px;
@@ -401,25 +345,6 @@
401
  <p class="hero-sub">Upload your course. Get quizzed by AI.</p>
402
  </header>
403
 
404
- <!-- ── Upload card ──────────────────────────────────────────────────── -->
405
- <div class="card fade-in-up" id="upload-card" style="animation-delay:.05s">
406
- <div class="drop-zone" id="drop-zone">
407
- <span class="upload-icon" id="upload-icon">πŸ“Ž</span>
408
- <p class="drop-title" id="drop-title">Drop your PDF here</p>
409
- <p class="drop-hint">or</p>
410
- <label class="btn-browse" for="file-input">Browse files</label>
411
- <input type="file" id="file-input" accept=".pdf" style="display:none">
412
- <p class="file-name hidden" id="file-name"></p>
413
- </div>
414
- <div class="upload-actions">
415
- <button class="btn-primary" id="load-btn" disabled>
416
- <span id="load-btn-text">Load PDF</span>
417
- <span class="spinner hidden" id="load-spinner"></span>
418
- </button>
419
- </div>
420
- <div class="status hidden" id="status-msg"></div>
421
- </div>
422
-
423
  <!-- ── Quiz card ─────��──────────────────────────────────────────────── -->
424
  <div class="card hidden fade-in-up" id="quiz-card" style="animation-delay:.1s">
425
 
@@ -546,9 +471,8 @@
546
  total: 0,
547
  language: 'English',
548
  difficulty: 'Normal',
549
- history: [], // [{question, verdict}]
550
  loaded: false,
551
- selectedFile: null,
552
  };
553
 
554
  // ───────────────────────────────────────────────────────────────────────────
@@ -556,16 +480,6 @@
556
  // ───────────────────────────────────────────────────────────────────────────
557
  const $ = id => document.getElementById(id);
558
 
559
- const dropZone = $('drop-zone');
560
- const fileInput = $('file-input');
561
- const fileNameEl = $('file-name');
562
- const dropTitle = $('drop-title');
563
- const uploadIcon = $('upload-icon');
564
- const loadBtn = $('load-btn');
565
- const loadBtnText = $('load-btn-text');
566
- const loadSpinner = $('load-spinner');
567
- const statusEl = $('status-msg');
568
-
569
  const quizCard = $('quiz-card');
570
  const emptyState = $('empty-state');
571
  const questionArea = $('question-area');
@@ -598,15 +512,6 @@
598
  // ───────────────────────────────────────────────────────────────────────────
599
  // API helpers
600
  // ───────────────────────────────────────────────────────────────────────────
601
- async function apiLoad(file) {
602
- const fd = new FormData();
603
- fd.append('file', file);
604
- const r = await fetch('/api/load', { method: 'POST', body: fd });
605
- const d = await r.json();
606
- if (!r.ok) throw new Error(d.detail || 'Failed to load PDF');
607
- return d;
608
- }
609
-
610
  async function apiQuestion(chunk, language, difficulty) {
611
  const r = await fetch('/api/question', {
612
  method: 'POST',
@@ -632,13 +537,6 @@
632
  // ───────────────────────────────────────────────────────────────────────────
633
  // UI helpers
634
  // ───────────────────────────────────────────────────────────────────────────
635
- function setStatus(msg, type = 'info') {
636
- statusEl.textContent = msg;
637
- statusEl.className = `status ${type}`;
638
- statusEl.classList.remove('hidden');
639
- }
640
- function clearStatus() { statusEl.classList.add('hidden'); }
641
-
642
  function setLoadingBtn(btn, textEl, spinner, loading, label) {
643
  btn.disabled = loading;
644
  textEl.textContent = label || textEl.textContent;
@@ -651,7 +549,6 @@
651
  const pct = d === 0 ? 0 : n / d;
652
  const circ = 138.2;
653
  scoreArc.style.strokeDashoffset = circ * (1 - pct);
654
- // colour: green when >70%, amber when >40%, red otherwise
655
  const color = pct >= 0.7 ? '#10B981' : pct >= 0.4 ? '#F59E0B' : '#EF4444';
656
  scoreArc.style.stroke = d === 0
657
  ? 'url(#scoreGrad)'
@@ -668,7 +565,6 @@
668
  if (l.includes('correct')) return 'correct';
669
  }
670
  }
671
- // fallback: scan first 200 chars
672
  if (lower.slice(0,200).includes('incorrect')) return 'incorrect';
673
  if (lower.slice(0,200).includes('partially') || lower.slice(0,200).includes('partiellement')) return 'partial';
674
  if (lower.slice(0,200).includes('correct')) return 'correct';
@@ -676,8 +572,6 @@
676
  }
677
 
678
  function renderFeedback(text) {
679
- // Try to split into numbered sections, fallback to raw text
680
- const sectionRe = /^(\d+)\.\s*(.*?)(?=\n\d+\.|$)/gms;
681
  const sectionLabels = ['', 'Verdict', 'What was good', 'What was missing', 'Model answer'];
682
  const frLabels = ['', 'Verdict', 'Ce qui Γ©tait bien', 'Ce qui manquait', 'RΓ©ponse modΓ¨le'];
683
  const labels = S.language === 'FranΓ§ais' ? frLabels : sectionLabels;
@@ -697,7 +591,6 @@
697
  </div>`;
698
  }).join('');
699
  } else {
700
- // Plain fallback
701
  feedbackBody.innerHTML = `<div class="feedback-raw">${
702
  text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
703
  }</div>`;
@@ -708,9 +601,9 @@
708
  const verdict = extractVerdict(text);
709
  verdictBadge.className = `verdict-badge ${verdict === 'unknown' ? '' : verdict}`;
710
  const labels = {
711
- correct: S.language === 'FranΓ§ais' ? 'βœ“ Correct' : 'βœ“ Correct',
712
  partial: S.language === 'FranΓ§ais' ? '~ Partiellement correct' : '~ Partially Correct',
713
- incorrect: S.language === 'FranΓ§ais' ? 'βœ— Incorrect' : 'βœ— Incorrect',
714
  unknown: 'β€” Unknown',
715
  };
716
  verdictBadge.textContent = labels[verdict] || labels.unknown;
@@ -737,7 +630,6 @@
737
  : ["Keep going β€” each question makes you stronger. πŸ”₯", "Continue β€” chaque question te rend plus fort. πŸ”₯"];
738
  modalMessage.textContent = S.language === 'FranΓ§ais' ? msgs[1] : msgs[0];
739
 
740
- // History list (last 8 items)
741
  const recent = S.history.slice(-8);
742
  if (recent.length > 0) {
743
  modalHistory.classList.remove('hidden');
@@ -756,63 +648,6 @@
756
  modal.classList.remove('hidden');
757
  }
758
 
759
- // ───────────────────────────────────────────────────────────────────────────
760
- // File handling
761
- // ───────────────────────────────────────────────────────────────────────────
762
- function handleFile(file) {
763
- if (!file || !file.name.toLowerCase().endsWith('.pdf')) {
764
- setStatus('Please select a PDF file.', 'error');
765
- return;
766
- }
767
- S.selectedFile = file;
768
- fileNameEl.textContent = 'πŸ“„ ' + file.name;
769
- fileNameEl.classList.remove('hidden');
770
- dropTitle.textContent = 'PDF selected';
771
- uploadIcon.textContent = 'βœ…';
772
- dropZone.classList.add('has-file');
773
- loadBtn.disabled = false;
774
- clearStatus();
775
- }
776
-
777
- dropZone.addEventListener('dragover', e => {
778
- e.preventDefault();
779
- dropZone.classList.add('drag-over');
780
- });
781
- dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
782
- dropZone.addEventListener('drop', e => {
783
- e.preventDefault();
784
- dropZone.classList.remove('drag-over');
785
- const file = e.dataTransfer.files[0];
786
- if (file) { fileInput.files = e.dataTransfer.files; handleFile(file); }
787
- });
788
- dropZone.addEventListener('click', () => fileInput.click());
789
- fileInput.addEventListener('change', () => {
790
- if (fileInput.files[0]) handleFile(fileInput.files[0]);
791
- });
792
-
793
- // ───────────────────────────────────────────────────────────────────────────
794
- // Load PDF
795
- // ───────────────────────────────────────────────────────────────────────────
796
- loadBtn.addEventListener('click', async () => {
797
- if (!S.selectedFile) return;
798
- setLoadingBtn(loadBtn, loadBtnText, loadSpinner, true, 'Loading…');
799
- setStatus('Parsing PDF…', 'info');
800
- try {
801
- const data = await apiLoad(S.selectedFile);
802
- S.chunks = data.chunks;
803
- S.loaded = true;
804
- S.correct = 0; S.total = 0; S.history = [];
805
- updateScore();
806
- setStatus(`βœ“ ${data.count} sections extracted β€” ready to quiz!`, 'ok');
807
- quizCard.classList.remove('hidden');
808
- quizCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
809
- } catch (err) {
810
- setStatus(err.message, 'error');
811
- } finally {
812
- setLoadingBtn(loadBtn, loadBtnText, loadSpinner, false, 'Load PDF');
813
- }
814
- });
815
-
816
  // ───────────────────────────────────────────────────────────────────────────
817
  // Language / difficulty toggles
818
  // ───────────────────────────────────────────────────────────────────────────
@@ -835,14 +670,12 @@
835
  async function fetchQuestion() {
836
  if (!S.chunks.length) return;
837
 
838
- // Switch from empty state to question area on first use
839
  if (emptyState && !emptyState.classList.contains('hidden')) {
840
  emptyState.classList.add('hidden');
841
  questionArea.classList.remove('hidden');
842
  endBtn.classList.remove('hidden');
843
  }
844
 
845
- // Show loading inside question box
846
  qText.textContent = '';
847
  qLoading.classList.remove('hidden');
848
  qLoadingMsg.textContent = S.total === 0
@@ -924,7 +757,6 @@
924
 
925
  modalClose.addEventListener('click', () => {
926
  modal.classList.add('hidden');
927
- // Reset session
928
  S.correct = 0; S.total = 0; S.history = [];
929
  S.currentQuestion = ''; S.currentChunk = '';
930
  updateScore();
@@ -982,7 +814,6 @@
982
  if (p.y < -5) p.y = H + 5;
983
  if (p.y > H + 5) p.y = -5;
984
 
985
- // Connections
986
  for (let j = i + 1; j < particles.length; j++) {
987
  const q = particles[j];
988
  const dx = p.x - q.x, dy = p.y - q.y;
@@ -997,7 +828,6 @@
997
  }
998
  }
999
 
1000
- // Dot
1001
  ctx.beginPath();
1002
  ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
1003
  const hex = p.hue === '#7C3AED' ? '124,58,237' : '6,182,212';
 
94
  }
95
  .card:hover { border-color: rgba(255,255,255,.12); }
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  /* ── Buttons ──────────────────────────────────────────────────────────── */
98
  button { cursor: pointer; font-family: inherit; font-size: .95rem;
99
  border: none; outline: none; transition: all .2s; }
 
124
  }
125
  .btn-ghost:disabled { opacity: .35; cursor: not-allowed; }
126
 
 
 
 
 
 
 
 
 
 
 
 
127
  /* ── Pill toggles ─────────────────────────────────────────────────────── */
128
  .toggle-group { display: flex; gap: 6px; }
129
 
 
139
  box-shadow: 0 2px 12px rgba(124,58,237,.4);
140
  }
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  /* ── Quiz controls row ───────────────────────────────────────────────── */
143
  .controls-row {
144
  display: flex; align-items: center; flex-wrap: wrap; gap: 12px;
 
345
  <p class="hero-sub">Upload your course. Get quizzed by AI.</p>
346
  </header>
347
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  <!-- ── Quiz card ─────��──────────────────────────────────────────────── -->
349
  <div class="card hidden fade-in-up" id="quiz-card" style="animation-delay:.1s">
350
 
 
471
  total: 0,
472
  language: 'English',
473
  difficulty: 'Normal',
474
+ history: [],
475
  loaded: false,
 
476
  };
477
 
478
  // ───────────────────────────────────────────────────────────────────────────
 
480
  // ───────────────────────────────────────────────────────────────────────────
481
  const $ = id => document.getElementById(id);
482
 
 
 
 
 
 
 
 
 
 
 
483
  const quizCard = $('quiz-card');
484
  const emptyState = $('empty-state');
485
  const questionArea = $('question-area');
 
512
  // ───────────────────────────────────────────────────────────────────────────
513
  // API helpers
514
  // ───────────────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
515
  async function apiQuestion(chunk, language, difficulty) {
516
  const r = await fetch('/api/question', {
517
  method: 'POST',
 
537
  // ───────────────────────────────────────────────────────────────────────────
538
  // UI helpers
539
  // ───────────────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
540
  function setLoadingBtn(btn, textEl, spinner, loading, label) {
541
  btn.disabled = loading;
542
  textEl.textContent = label || textEl.textContent;
 
549
  const pct = d === 0 ? 0 : n / d;
550
  const circ = 138.2;
551
  scoreArc.style.strokeDashoffset = circ * (1 - pct);
 
552
  const color = pct >= 0.7 ? '#10B981' : pct >= 0.4 ? '#F59E0B' : '#EF4444';
553
  scoreArc.style.stroke = d === 0
554
  ? 'url(#scoreGrad)'
 
565
  if (l.includes('correct')) return 'correct';
566
  }
567
  }
 
568
  if (lower.slice(0,200).includes('incorrect')) return 'incorrect';
569
  if (lower.slice(0,200).includes('partially') || lower.slice(0,200).includes('partiellement')) return 'partial';
570
  if (lower.slice(0,200).includes('correct')) return 'correct';
 
572
  }
573
 
574
  function renderFeedback(text) {
 
 
575
  const sectionLabels = ['', 'Verdict', 'What was good', 'What was missing', 'Model answer'];
576
  const frLabels = ['', 'Verdict', 'Ce qui Γ©tait bien', 'Ce qui manquait', 'RΓ©ponse modΓ¨le'];
577
  const labels = S.language === 'FranΓ§ais' ? frLabels : sectionLabels;
 
591
  </div>`;
592
  }).join('');
593
  } else {
 
594
  feedbackBody.innerHTML = `<div class="feedback-raw">${
595
  text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
596
  }</div>`;
 
601
  const verdict = extractVerdict(text);
602
  verdictBadge.className = `verdict-badge ${verdict === 'unknown' ? '' : verdict}`;
603
  const labels = {
604
+ correct: S.language === 'FranΓ§ais' ? 'βœ“ Correct' : 'βœ“ Correct',
605
  partial: S.language === 'FranΓ§ais' ? '~ Partiellement correct' : '~ Partially Correct',
606
+ incorrect: S.language === 'FranΓ§ais' ? 'βœ— Incorrect' : 'βœ— Incorrect',
607
  unknown: 'β€” Unknown',
608
  };
609
  verdictBadge.textContent = labels[verdict] || labels.unknown;
 
630
  : ["Keep going β€” each question makes you stronger. πŸ”₯", "Continue β€” chaque question te rend plus fort. πŸ”₯"];
631
  modalMessage.textContent = S.language === 'FranΓ§ais' ? msgs[1] : msgs[0];
632
 
 
633
  const recent = S.history.slice(-8);
634
  if (recent.length > 0) {
635
  modalHistory.classList.remove('hidden');
 
648
  modal.classList.remove('hidden');
649
  }
650
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
651
  // ───────────────────────────────────────────────────────────────────────────
652
  // Language / difficulty toggles
653
  // ───────────────────────────────────────────────────────────────────────────
 
670
  async function fetchQuestion() {
671
  if (!S.chunks.length) return;
672
 
 
673
  if (emptyState && !emptyState.classList.contains('hidden')) {
674
  emptyState.classList.add('hidden');
675
  questionArea.classList.remove('hidden');
676
  endBtn.classList.remove('hidden');
677
  }
678
 
 
679
  qText.textContent = '';
680
  qLoading.classList.remove('hidden');
681
  qLoadingMsg.textContent = S.total === 0
 
757
 
758
  modalClose.addEventListener('click', () => {
759
  modal.classList.add('hidden');
 
760
  S.correct = 0; S.total = 0; S.history = [];
761
  S.currentQuestion = ''; S.currentChunk = '';
762
  updateScore();
 
814
  if (p.y < -5) p.y = H + 5;
815
  if (p.y > H + 5) p.y = -5;
816
 
 
817
  for (let j = i + 1; j < particles.length; j++) {
818
  const q = particles[j];
819
  const dx = p.x - q.x, dy = p.y - q.y;
 
828
  }
829
  }
830
 
 
831
  ctx.beginPath();
832
  ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
833
  const hex = p.hue === '#7C3AED' ? '124,58,237' : '6,182,212';