Files
html/vault-manager.html
WEVIA a69e648051
Some checks failed
WEVAL NonReg / nonreg (push) Has been cancelled
feat-vault-manager-page-semantic-search-graph-editor
2026-04-13 04:29:25 +02:00

510 lines
23 KiB
HTML

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>WEVIA Vault — Sovereign Memory Manager</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#080b12;--bg2:#0d1117;--bg3:#151b25;--bg4:#1c2333;--fg:#c9d1d9;--fg2:#8b949e;--fg3:#484f58;--accent:#58a6ff;--accent2:#1f6feb;--green:#3fb950;--orange:#d29922;--red:#f85149;--purple:#bc8cff;--cyan:#39d353;--border:#21262d;--r:8px;--glow:0 0 20px rgba(88,166,255,0.15)}
body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--fg);height:100vh;display:grid;grid-template-columns:260px 1fr 320px;grid-template-rows:56px 1fr;overflow:hidden}
::selection{background:var(--accent2);color:#fff}
::-webkit-scrollbar{width:6px}
::-webkit-scrollbar-track{background:var(--bg2)}
::-webkit-scrollbar-thumb{background:var(--fg3);border-radius:3px}
/* HEADER */
.hdr{grid-column:1/-1;background:var(--bg2);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 20px;z-index:10}
.hdr-left{display:flex;align-items:center;gap:12px}
.hdr-logo{font-size:15px;font-weight:700;letter-spacing:1px;display:flex;align-items:center;gap:10px}
.hdr-logo .dot{width:8px;height:8px;border-radius:50%;background:var(--green);box-shadow:0 0 8px var(--green);animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
.hdr-logo span{color:var(--accent)}
.hdr-stats{display:flex;gap:16px;font-size:12px;color:var(--fg2)}
.hdr-stats b{color:var(--fg);font-weight:600}
.hdr-right{display:flex;gap:10px}
.btn{padding:6px 14px;border-radius:var(--r);border:1px solid var(--border);background:var(--bg3);color:var(--fg);font-size:12px;cursor:pointer;font-family:inherit;transition:all .2s}
.btn:hover{border-color:var(--accent);color:var(--accent);box-shadow:var(--glow)}
.btn-primary{background:var(--accent2);border-color:var(--accent2);color:#fff}
.btn-primary:hover{background:var(--accent);box-shadow:0 0 20px rgba(88,166,255,.3)}
/* SIDEBAR */
.sidebar{background:var(--bg2);border-right:1px solid var(--border);overflow-y:auto;padding:16px}
.sidebar h3{font-size:11px;text-transform:uppercase;letter-spacing:2px;color:var(--fg3);margin:16px 0 8px;font-weight:600}
.sidebar h3:first-child{margin-top:0}
.dir-item{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:6px;cursor:pointer;font-size:13px;transition:all .15s;color:var(--fg2)}
.dir-item:hover{background:var(--bg4);color:var(--fg)}
.dir-item.active{background:var(--bg4);color:var(--accent);border-left:2px solid var(--accent)}
.dir-item .icon{font-size:16px;width:20px;text-align:center}
.dir-item .count{margin-left:auto;font-size:11px;background:var(--bg);padding:2px 6px;border-radius:10px;color:var(--fg3)}
.file-item{display:flex;align-items:center;gap:8px;padding:6px 10px 6px 28px;border-radius:6px;cursor:pointer;font-size:12px;color:var(--fg2);transition:all .15s}
.file-item:hover{background:var(--bg4);color:var(--fg)}
.file-item.active{color:var(--accent)}
.file-item .ext{font-size:10px;color:var(--fg3);margin-left:auto;font-family:'JetBrains Mono',monospace}
/* MAIN */
.main{overflow-y:auto;padding:24px;display:flex;flex-direction:column;gap:20px}
/* Search bar */
.search-wrap{position:relative}
.search-wrap input{width:100%;padding:12px 16px 12px 40px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);color:var(--fg);font-size:14px;font-family:inherit;outline:none;transition:border .2s}
.search-wrap input:focus{border-color:var(--accent);box-shadow:var(--glow)}
.search-wrap::before{content:'🔍';position:absolute;left:14px;top:50%;transform:translateY(-50%);font-size:14px}
.search-type{display:flex;gap:6px;margin-top:8px}
.search-type label{font-size:11px;color:var(--fg2);display:flex;align-items:center;gap:4px;cursor:pointer}
.search-type input[type=radio]{accent-color:var(--accent)}
/* Stats cards */
.stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
.stat-card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);padding:16px;text-align:center}
.stat-card .val{font-size:28px;font-weight:700;color:var(--accent);font-family:'JetBrains Mono',monospace}
.stat-card .label{font-size:11px;color:var(--fg2);margin-top:4px;text-transform:uppercase;letter-spacing:1px}
/* Results */
.results{display:flex;flex-direction:column;gap:8px}
.result-item{background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);padding:14px 16px;cursor:pointer;transition:all .15s}
.result-item:hover{border-color:var(--accent);transform:translateX(4px)}
.result-item .file{font-size:13px;font-weight:600;color:var(--accent);font-family:'JetBrains Mono',monospace}
.result-item .score{float:right;font-size:11px;padding:2px 8px;border-radius:10px;background:var(--accent2);color:#fff}
.result-item .snippet{font-size:12px;color:var(--fg2);margin-top:6px;line-height:1.5}
.result-item .tags{margin-top:6px;display:flex;gap:4px;flex-wrap:wrap}
.result-item .tag{font-size:10px;padding:2px 6px;border-radius:4px;background:var(--bg4);color:var(--purple)}
/* Note viewer */
.note-view{background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);padding:20px;flex:1;min-height:300px}
.note-view .note-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid var(--border)}
.note-view .note-title{font-size:16px;font-weight:700;color:var(--accent)}
.note-view .note-meta{font-size:11px;color:var(--fg3)}
.note-view .note-content{font-size:13px;line-height:1.8;color:var(--fg);white-space:pre-wrap;font-family:'JetBrains Mono',monospace}
.note-view textarea{width:100%;min-height:250px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:12px;color:var(--fg);font-size:13px;font-family:'JetBrains Mono',monospace;line-height:1.6;resize:vertical;outline:none}
.note-view textarea:focus{border-color:var(--accent)}
/* RIGHT PANEL */
.panel{background:var(--bg2);border-left:1px solid var(--border);overflow-y:auto;padding:16px}
.panel h3{font-size:11px;text-transform:uppercase;letter-spacing:2px;color:var(--fg3);margin:16px 0 8px;font-weight:600}
.panel h3:first-child{margin-top:0}
/* Graph mini */
.graph-mini{width:100%;height:200px;background:var(--bg);border-radius:var(--r);border:1px solid var(--border);position:relative;overflow:hidden}
.graph-mini canvas{width:100%;height:100%}
/* Activity feed */
.activity{display:flex;flex-direction:column;gap:6px}
.activity-item{font-size:11px;color:var(--fg2);padding:6px 8px;border-radius:4px;background:var(--bg3);display:flex;gap:8px;align-items:center}
.activity-item .time{color:var(--fg3);font-family:'JetBrains Mono',monospace;min-width:40px}
.activity-item .act{color:var(--green)}
/* Crons */
.cron-list{display:flex;flex-direction:column;gap:4px}
.cron-item{font-size:11px;padding:6px 8px;background:var(--bg3);border-radius:4px;display:flex;justify-content:space-between;color:var(--fg2)}
.cron-item .freq{color:var(--cyan);font-family:'JetBrains Mono',monospace}
/* New note modal */
.modal-bg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:100;align-items:center;justify-content:center}
.modal-bg.show{display:flex}
.modal{background:var(--bg2);border:1px solid var(--border);border-radius:12px;padding:24px;width:500px;max-width:90vw}
.modal h2{font-size:16px;margin-bottom:16px;color:var(--accent)}
.modal input,.modal select,.modal textarea{width:100%;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--fg);font-size:13px;font-family:inherit;margin-bottom:12px;outline:none}
.modal input:focus,.modal select:focus,.modal textarea:focus{border-color:var(--accent)}
.modal textarea{min-height:150px;font-family:'JetBrains Mono',monospace;resize:vertical}
.modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:8px}
/* Responsive */
@media(max-width:900px){body{grid-template-columns:1fr;grid-template-rows:56px 1fr}.sidebar,.panel{display:none}}
</style>
</head>
<body>
<!-- HEADER -->
<div class="hdr">
<div class="hdr-left">
<div class="hdr-logo"><div class="dot"></div>WEVIA <span>VAULT</span></div>
<div class="hdr-stats">
<span>Notes: <b id="hFiles"></b></span>
<span>Dirs: <b id="hDirs"></b></span>
<span>Size: <b id="hSize"></b></span>
<span>Qdrant: <b id="hQdrant">16 vectors</b></span>
</div>
</div>
<div class="hdr-right">
<button class="btn" onclick="reEmbed()">🧠 Re-Embed</button>
<button class="btn btn-primary" onclick="showNewNote()">+ New Note</button>
</div>
</div>
<!-- SIDEBAR -->
<div class="sidebar" id="sidebar">
<h3>Vault Explorer</h3>
<div id="dirList"></div>
</div>
<!-- MAIN -->
<div class="main" id="main">
<!-- Search -->
<div class="search-wrap">
<input type="text" id="searchInput" placeholder="Search vault... (semantic or full-text)" onkeydown="if(event.key==='Enter')doSearch()">
<div class="search-type">
<label><input type="radio" name="stype" value="semantic" checked> Semantic (AI)</label>
<label><input type="radio" name="stype" value="fulltext"> Full-text</label>
</div>
</div>
<!-- Stats -->
<div class="stats-grid" id="statsGrid">
<div class="stat-card"><div class="val" id="sNotes"></div><div class="label">Notes</div></div>
<div class="stat-card"><div class="val" id="sDirs"></div><div class="label">Directories</div></div>
<div class="stat-card"><div class="val" id="sSize"></div><div class="label">Total Size</div></div>
<div class="stat-card"><div class="val" id="sTools">238</div><div class="label">Tools Wired</div></div>
</div>
<!-- Results / Note Viewer -->
<div id="results" class="results"></div>
<div id="noteView" class="note-view" style="display:none">
<div class="note-header">
<div>
<div class="note-title" id="noteTitle"></div>
<div class="note-meta" id="noteMeta"></div>
</div>
<div>
<button class="btn" id="editBtn" onclick="toggleEdit()">✏️ Edit</button>
<button class="btn" id="saveBtn" onclick="saveNote()" style="display:none">💾 Save</button>
</div>
</div>
<div class="note-content" id="noteContent"></div>
<textarea id="noteEditor" style="display:none"></textarea>
</div>
</div>
<!-- RIGHT PANEL -->
<div class="panel">
<h3>Vault Graph</h3>
<div class="graph-mini"><canvas id="graphCanvas"></canvas></div>
<h3>Directories</h3>
<div id="dirStats"></div>
<h3>Crons</h3>
<div class="cron-list">
<div class="cron-item"><span>Daily metrics log</span><span class="freq">*/4h</span></div>
<div class="cron-item"><span>Qdrant re-embed</span><span class="freq">5AM</span></div>
<div class="cron-item"><span>Auto-heal FPM</span><span class="freq">*/5min</span></div>
<div class="cron-item"><span>CPU hog killer</span><span class="freq">*/10min</span></div>
</div>
<h3>Semantic Engines</h3>
<div class="activity">
<div class="activity-item"><span class="act"></span> Qdrant (obsidian_vault collection)</div>
<div class="activity-item"><span class="act"></span> Ollama all-minilm (384-dim)</div>
<div class="activity-item"><span class="act"></span> Full-text PHP search</div>
</div>
<h3>Quick Actions</h3>
<div style="display:flex;flex-direction:column;gap:6px">
<button class="btn" onclick="masterCmd('vault obsidian stats')" style="width:100%">📊 Master: Vault Stats</button>
<button class="btn" onclick="masterCmd('nonreg')" style="width:100%">🧪 Master: NonReg</button>
<button class="btn" onclick="masterCmd('diagnostic complet')" style="width:100%">🔍 Master: Diagnostic</button>
</div>
</div>
<!-- NEW NOTE MODAL -->
<div class="modal-bg" id="modalBg" onclick="if(event.target===this)this.classList.remove('show')">
<div class="modal">
<h2>📝 New Note</h2>
<select id="newDir">
<option value="doctrines">doctrines/</option>
<option value="decisions">decisions/</option>
<option value="sessions">sessions/</option>
<option value="ethica">ethica/</option>
<option value="infra">infra/</option>
<option value="kb">kb/</option>
<option value="tools">tools/</option>
<option value="daily">daily/</option>
<option value="arena">arena/</option>
</select>
<input type="text" id="newFilename" placeholder="filename (without .md)">
<input type="text" id="newTags" placeholder="tags: doctrine, critical, backup">
<textarea id="newContent" placeholder="# Title\n\nContent here..."></textarea>
<div class="modal-actions">
<button class="btn" onclick="document.getElementById('modalBg').classList.remove('show')">Cancel</button>
<button class="btn btn-primary" onclick="createNote()">Create Note</button>
</div>
</div>
</div>
<script>
const API_VAULT = '/api/wevia-vault.php';
const API_SEMANTIC = '/api/wevia-vault-search.php';
const API_MASTER = '/api/wevia-master-api.php';
let currentFile = null;
let currentDir = null;
// === INIT ===
async function init() {
await loadStats();
await loadDirs();
drawGraph();
}
// === STATS ===
async function loadStats() {
try {
const r = await fetch(API_VAULT + '?action=stats');
const d = await r.json();
document.getElementById('sNotes').textContent = d.files || 0;
document.getElementById('hFiles').textContent = d.files || 0;
document.getElementById('sDirs').textContent = (d.dirs||[]).length;
document.getElementById('hDirs').textContent = (d.dirs||[]).length;
const kb = Math.round((d.bytes||0)/1024);
document.getElementById('sSize').textContent = kb + 'KB';
document.getElementById('hSize').textContent = kb + 'KB';
// Dir stats panel
const ds = document.getElementById('dirStats');
ds.innerHTML = (d.dirs||[]).map(dr =>
`<div class="activity-item"><span style="min-width:80px">${dr.name}/</span><b>${dr.files}</b> notes</div>`
).join('');
} catch(e) { console.error(e); }
}
// === DIRS ===
async function loadDirs() {
try {
const r = await fetch(API_VAULT + '?action=list');
const d = await r.json();
const sb = document.getElementById('dirList');
const icons = {doctrines:'📜',tools:'🔧',sessions:'📅',decisions:'⚖️',ethica:'💊',infra:'🖥️',kb:'📚',arena:'⚡',daily:'📊'};
sb.innerHTML = `<div class="dir-item ${!currentDir?'active':''}" onclick="loadDir('')"><span class="icon">🏠</span> All<span class="count">${d.count}</span></div>`;
(d.files||[]).filter(f=>f.type==='dir').forEach(f => {
sb.innerHTML += `<div class="dir-item ${currentDir===f.name?'active':''}" onclick="loadDir('${f.name}')"><span class="icon">${icons[f.name]||'📁'}</span> ${f.name}<span class="count" id="dc_${f.name}">…</span></div>`;
// Load file count
fetch(API_VAULT+'?action=list&dir='+f.name).then(r=>r.json()).then(dd=>{
const el = document.getElementById('dc_'+f.name);
if(el) el.textContent = dd.count;
});
});
} catch(e) { console.error(e); }
}
async function loadDir(dir) {
currentDir = dir;
loadDirs();
const r = await fetch(API_VAULT + '?action=list&dir=' + dir);
const d = await r.json();
const res = document.getElementById('results');
document.getElementById('noteView').style.display = 'none';
res.innerHTML = '';
(d.files||[]).filter(f=>f.type==='file'&&f.name.endsWith('.md')).forEach(f => {
const path = dir ? dir+'/'+f.name : f.name;
res.innerHTML += `<div class="result-item" onclick="loadNote('${path}')">
<div class="file">${f.name}</div>
<div class="snippet">${(f.size/1024).toFixed(1)}KB · ${dir||'root'}</div>
</div>`;
});
}
// === SEARCH ===
async function doSearch() {
const q = document.getElementById('searchInput').value.trim();
if (!q) return;
const type = document.querySelector('input[name=stype]:checked').value;
const res = document.getElementById('results');
document.getElementById('noteView').style.display = 'none';
res.innerHTML = '<div style="text-align:center;color:var(--fg3);padding:20px">Searching...</div>';
try {
let url = type === 'semantic'
? API_SEMANTIC + '?q=' + encodeURIComponent(q)
: API_VAULT + '?action=search&q=' + encodeURIComponent(q);
const r = await fetch(url);
const d = await r.json();
if (!d.results || d.results.length === 0) {
res.innerHTML = '<div style="text-align:center;color:var(--fg3);padding:20px">No results</div>';
return;
}
res.innerHTML = `<div style="font-size:12px;color:var(--fg3);margin-bottom:4px">${d.count} results for "${d.query}" (${type})</div>`;
d.results.forEach(r => {
const score = r.score ? `<span class="score">${r.score}</span>` : '';
const tags = (r.tags||'').split(',').filter(t=>t.trim()).map(t=>`<span class="tag">${t.trim()}</span>`).join('');
res.innerHTML += `<div class="result-item" onclick="loadNote('${r.file}')">
<div class="file">${r.file} ${score}</div>
<div class="snippet">${(r.snippet||'').substring(0,200)}</div>
${tags?`<div class="tags">${tags}</div>`:''}
</div>`;
});
} catch(e) {
res.innerHTML = `<div style="color:var(--red);padding:20px">Error: ${e.message}</div>`;
}
}
// === NOTE VIEWER ===
async function loadNote(file) {
try {
const r = await fetch(API_VAULT + '?action=read&file=' + encodeURIComponent(file));
const d = await r.json();
if (d.error) { alert(d.error); return; }
currentFile = file;
document.getElementById('results').innerHTML = '';
const nv = document.getElementById('noteView');
nv.style.display = 'block';
document.getElementById('noteTitle').textContent = file.split('/').pop();
document.getElementById('noteMeta').textContent = file;
document.getElementById('noteContent').textContent = d.content;
document.getElementById('noteContent').style.display = 'block';
document.getElementById('noteEditor').style.display = 'none';
document.getElementById('editBtn').style.display = '';
document.getElementById('saveBtn').style.display = 'none';
} catch(e) { alert(e.message); }
}
function toggleEdit() {
const content = document.getElementById('noteContent');
const editor = document.getElementById('noteEditor');
content.style.display = 'none';
editor.style.display = 'block';
editor.value = content.textContent;
document.getElementById('editBtn').style.display = 'none';
document.getElementById('saveBtn').style.display = '';
}
async function saveNote() {
const content = document.getElementById('noteEditor').value;
try {
const r = await fetch(API_VAULT, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `action=write&file=${encodeURIComponent(currentFile)}&content=${encodeURIComponent(content)}`
});
const d = await r.json();
if (d.ok) {
document.getElementById('noteContent').textContent = content;
document.getElementById('noteContent').style.display = 'block';
document.getElementById('noteEditor').style.display = 'none';
document.getElementById('editBtn').style.display = '';
document.getElementById('saveBtn').style.display = 'none';
}
} catch(e) { alert(e.message); }
}
// === NEW NOTE ===
function showNewNote() { document.getElementById('modalBg').classList.add('show'); }
async function createNote() {
const dir = document.getElementById('newDir').value;
const name = document.getElementById('newFilename').value.trim().replace(/\s+/g,'-');
const tags = document.getElementById('newTags').value.trim();
const body = document.getElementById('newContent').value;
if (!name) { alert('Filename required'); return; }
const content = `---\ntags: [${tags}]\ncreated: ${new Date().toISOString().split('T')[0]}\n---\n${body}`;
try {
const r = await fetch(API_VAULT, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `action=write&file=${encodeURIComponent(dir+'/'+name+'.md')}&content=${encodeURIComponent(content)}`
});
const d = await r.json();
if (d.ok) {
document.getElementById('modalBg').classList.remove('show');
document.getElementById('newFilename').value = '';
document.getElementById('newTags').value = '';
document.getElementById('newContent').value = '';
loadStats();
loadDir(dir);
}
} catch(e) { alert(e.message); }
}
// === RE-EMBED ===
async function reEmbed() {
const btn = event.target;
btn.textContent = '🧠 Embedding...';
btn.disabled = true;
try {
const r = await fetch('/api/wevia-action-engine.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'action=exec_s204&cmd=timeout+30+python3+/opt/weval-l99/tools/vault-embed.py+2>&1'
});
const d = await r.json();
btn.textContent = '🧠 Re-Embed ✅';
setTimeout(()=>{ btn.textContent='🧠 Re-Embed'; btn.disabled=false; }, 3000);
} catch(e) { btn.textContent = '🧠 Error'; btn.disabled = false; }
}
// === MASTER CMD ===
async function masterCmd(msg) {
const res = document.getElementById('results');
document.getElementById('noteView').style.display = 'none';
res.innerHTML = '<div style="text-align:center;color:var(--fg3);padding:20px">⏳ Asking Master...</div>';
try {
const r = await fetch(API_MASTER, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: msg})
});
const d = await r.json();
res.innerHTML = `<div class="result-item"><div class="file">Master Response (${d.source||'?'})</div><pre class="snippet" style="white-space:pre-wrap;max-height:400px;overflow:auto">${typeof d.content==='string'?d.content:JSON.stringify(d.content,null,2)}</pre></div>`;
} catch(e) {
res.innerHTML = `<div style="color:var(--red);padding:20px">Master timeout</div>`;
}
}
// === MINI GRAPH ===
function drawGraph() {
const canvas = document.getElementById('graphCanvas');
const ctx = canvas.getContext('2d');
canvas.width = canvas.offsetWidth * 2;
canvas.height = canvas.offsetHeight * 2;
ctx.scale(2, 2);
const W = canvas.offsetWidth, H = canvas.offsetHeight;
const dirs = ['doctrines','tools','sessions','infra','kb','ethica','arena','daily','decisions'];
const colors = ['#58a6ff','#3fb950','#d29922','#f85149','#bc8cff','#39d353','#ff7b72','#79c0ff','#d2a8ff'];
const cx = W/2, cy = H/2;
// Center node
ctx.beginPath();
ctx.arc(cx, cy, 12, 0, Math.PI*2);
ctx.fillStyle = '#58a6ff';
ctx.fill();
ctx.font = '8px DM Sans';
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.fillText('VAULT', cx, cy+3);
// Directory nodes
dirs.forEach((d, i) => {
const angle = (i / dirs.length) * Math.PI * 2 - Math.PI/2;
const r = Math.min(W, H) * 0.35;
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
// Edge
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(x, y);
ctx.strokeStyle = colors[i] + '40';
ctx.lineWidth = 1;
ctx.stroke();
// Node
ctx.beginPath();
ctx.arc(x, y, 8, 0, Math.PI*2);
ctx.fillStyle = colors[i];
ctx.fill();
// Label
ctx.font = '7px DM Sans';
ctx.fillStyle = '#8b949e';
ctx.textAlign = 'center';
ctx.fillText(d, x, y + 16);
});
}
window.addEventListener('resize', drawGraph);
init();
</script>
</body>
</html>