<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Introspectre - Actionable Schema Visualization</title>
{{VENDOR_JS}}
<style>
:root {
--bg: #0a0a0f;
--bg-elev: #12121a;
--panel: #0f1119;
--border: #22253a;
--text: #d7e0ea;
--muted: #7a8296;
--accent: #FCEE0A;
--cyan: #00F0FF;
--magenta: #FF2A6D;
--high: #FF2A6D;
--med: #FCEE0A;
--low: #1BE7B5;
--info: #00F0FF;
--mono: ui-monospace, "Cascadia Code", "JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
}
body {
margin: 0;
padding: 0;
font-family: var(--sans);
background-color: var(--bg);
color: var(--text);
display: flex;
height: 100vh;
overflow: hidden;
}
h1, h2, h3, h4, h5, h6,
button, .tag, .tab-btn, .toggle-btn, .graph-btn, .view-report-btn,
.close-modal, .report-stat strong, .seed-group h4, .tree-label,
#node-search, .instruction-toast b {
font-family: var(--mono);
}
#cy {
flex-grow: 1;
height: 100%;
}
#sidebar {
width: 450px;
background-color: var(--panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 20px;
overflow-y: auto;
box-shadow: 5px 0 15px rgba(0,0,0,0.6), 0 0 24px rgba(0,240,255,0.06);
}
.header {
border-bottom: 1px solid var(--border);
padding-bottom: 15px;
margin-bottom: 20px;
}
.header h1 {
font-size: 1.2rem;
margin: 0;
color: var(--accent);
letter-spacing: 0.02em;
text-shadow: 0 0 8px rgba(252,238,10,0.45), 0 0 20px rgba(252,238,10,0.15);
}
.header .meta {
font-size: 0.8rem;
color: var(--muted);
margin-top: 5px;
}
.card {
background-color: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 6px;
padding: 15px;
margin-bottom: 15px;
}
.card h3 {
margin-top: 0;
font-size: 1rem;
}
.severity-high { border-left: 4px solid var(--high); box-shadow: inset 0 0 20px rgba(255,42,109,0.08); }
.severity-medium { border-left: 4px solid var(--med); box-shadow: inset 0 0 20px rgba(252,238,10,0.06); }
.severity-low { border-left: 4px solid var(--low); }
.severity-info { border-left: 4px solid var(--info); }
pre {
background-color: var(--bg-elev);
padding: 10px;
border-radius: 4px;
overflow-x: auto;
font-size: 0.85rem;
border: 1px solid var(--border);
font-family: var(--mono);
}
button {
background-color: var(--bg-elev);
border: 1px solid var(--border);
color: var(--accent);
padding: 5px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
margin-right: 5px;
margin-top: 5px;
}
button:hover {
background-color: var(--border);
border-color: var(--accent);
}
.finding-item {
cursor: pointer;
padding: 8px;
border-radius: 4px;
margin-bottom: 5px;
border: 1px solid transparent;
}
.finding-item:hover {
background-color: var(--bg-elev);
border-color: var(--border);
}
.finding-item.active {
background-color: rgba(0,240,255,0.12);
border-color: var(--cyan);
}
.search-container {
padding: 10px 0;
position: relative;
}
#node-search {
width: 100%;
padding: 8px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--bg-elev);
color: var(--text);
box-sizing: border-box;
}
#node-search:focus {
outline: none;
border-color: var(--cyan);
box-shadow: 0 0 0 1px var(--cyan), 0 0 10px rgba(0,240,255,0.25);
}
#search-suggestions {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--panel);
border: 1px solid var(--border);
max-height: 200px;
overflow-y: auto;
z-index: 10;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
border-radius: 4px;
}
.suggestion-item {
padding: 8px;
cursor: pointer;
border-bottom: 1px solid var(--border);
}
.suggestion-item:hover {
background-color: var(--border);
}
.semitransparent {
opacity: 0.1 !important;
}
.toggle-group {
display: inline-flex;
margin-bottom: 10px;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border);
}
.toggle-btn {
background-color: var(--bg-elev);
color: var(--muted);
border: none;
padding: 5px 12px;
cursor: pointer;
font-size: 0.8rem;
margin: 0;
border-radius: 0;
}
.toggle-btn.active {
background-color: var(--accent);
color: var(--bg);
}
.highlighted-edge {
line-color: var(--cyan) !important;
target-arrow-color: var(--cyan) !important;
width: 4px !important;
}
.tag {
font-size: 0.7rem;
padding: 2px 6px;
border-radius: 10px;
text-transform: uppercase;
font-weight: bold;
margin-right: 5px;
}
.tag-high { background-color: rgba(255,42,109,0.18); color: var(--high); }
.tag-med { background-color: rgba(252,238,10,0.16); color: var(--med); }
.tag-info { background-color: rgba(0,240,255,0.16); color: var(--info); }
.tag-low { background-color: rgba(27,231,181,0.16); color: var(--low); }
#details-pane {
display: none;
}
.back-btn {
margin-bottom: 15px;
display: inline-block;
}
.graph-controls {
position: absolute;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 100;
}
.graph-btn {
background-color: var(--panel);
border: 1px solid var(--border);
color: var(--text);
padding: 10px;
border-radius: 8px;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
font-size: 0.8rem;
display: flex;
align-items: center;
gap: 8px;
}
.graph-btn:hover {
background-color: var(--bg-elev);
border-color: var(--accent);
}
.instruction-toast {
position: absolute;
top: 20px;
right: 20px;
max-width: 340px;
background-color: rgba(15, 17, 25, 0.9);
border: 1px solid var(--accent);
padding: 10px 26px 10px 15px;
border-radius: 8px;
font-size: 0.8rem;
color: var(--text);
z-index: 100;
pointer-events: none;
box-shadow: 0 4px 15px rgba(0,0,0,0.5), 0 0 12px rgba(252,238,10,0.12);
}
.toast-close {
position: absolute;
top: 4px;
right: 8px;
cursor: pointer;
pointer-events: auto;
color: var(--muted);
font-size: 1rem;
line-height: 1;
}
.toast-close:hover {
color: var(--accent);
}
#toast-reopen-btn {
position: absolute;
top: 20px;
right: 20px;
display: none;
font-weight: bold;
}
@media (max-width: 700px) {
.instruction-toast {
top: auto;
bottom: 20px;
left: 10px;
right: 10px;
max-width: none;
font-size: 0.72rem;
}
#toast-reopen-btn {
top: auto;
bottom: 20px;
right: 10px;
}
}
.legend {
position: absolute;
top: auto;
bottom: 20px;
left: 20px;
background-color: rgba(15, 17, 25, 0.9);
border: 1px solid var(--border);
border-radius: 8px;
z-index: 100;
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
font-size: 0.78rem;
overflow: hidden;
max-width: 220px;
}
.legend-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 12px;
cursor: pointer;
color: var(--accent);
font-family: var(--mono);
font-weight: bold;
user-select: none;
}
.legend-body {
display: none;
padding: 4px 12px 10px 12px;
border-top: 1px solid var(--border);
}
.legend-body.expanded {
display: block;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 0;
color: var(--text);
}
.legend-swatch {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 0 4px rgba(0,0,0,0.6);
}
@media (max-width: 700px) {
.legend {
top: auto;
bottom: 10px;
left: 10px;
max-width: 180px;
}
}
#ctx-menu {
position: absolute;
display: none;
background-color: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 5px 0;
z-index: 2000;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
min-width: 160px;
}
.ctx-item {
padding: 8px 15px;
font-size: 0.85rem;
color: var(--text);
cursor: pointer;
transition: background-color 0.2s;
}
.ctx-item:hover {
background-color: var(--accent);
color: var(--bg);
}
.ctx-divider {
height: 1px;
background-color: var(--border);
margin: 5px 0;
}
.btn-active {
border-color: var(--accent) !important;
color: var(--accent) !important;
}
#report-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.85);
z-index: 1000;
overflow-y: auto;
padding: 40px;
box-sizing: border-box;
}
.modal-content {
background-color: var(--panel);
max-width: 1000px;
margin: 40px auto;
border: 1px solid var(--border);
border-radius: 12px;
padding: 40px;
position: relative;
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
}
.close-modal {
position: fixed;
top: 25px;
right: 25px;
width: 44px;
height: 44px;
background-color: var(--accent);
color: var(--bg);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.8rem;
cursor: pointer;
z-index: 2000;
box-shadow: 0 4px 15px rgba(0,0,0,0.5), 0 0 16px rgba(252,238,10,0.35);
transition: all 0.2s ease;
line-height: 1;
}
.close-modal:hover {
transform: scale(1.1);
background-color: var(--cyan);
box-shadow: 0 4px 15px rgba(0,0,0,0.5), 0 0 16px rgba(0,240,255,0.4);
}
.modal-footer {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid var(--border);
display: flex;
justify-content: center;
}
.report-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 30px;
}
.report-stat {
background-color: var(--bg);
padding: 15px;
border-radius: 8px;
border: 1px solid var(--border);
}
.report-stat strong {
display: block;
font-size: 0.8rem;
color: var(--muted);
margin-bottom: 5px;
}
.report-stat span {
font-size: 1.2rem;
font-weight: bold;
}
.view-report-btn {
background-color: var(--accent);
color: var(--bg);
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
margin-top: 10px;
width: 100%;
}
.view-report-btn:hover {
opacity: 0.9;
box-shadow: 0 0 14px rgba(252,238,10,0.35);
}
.seed-group {
margin-bottom: 15px;
padding: 10px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 6px;
}
.seed-group h4 {
margin: 0 0 8px 0;
font-size: 0.85rem;
color: var(--accent);
}
.seed-item {
font-size: 0.8rem;
padding: 4px 8px;
background: var(--bg);
border-radius: 4px;
margin-bottom: 4px;
word-break: break-all;
border: 1px solid transparent;
font-family: var(--mono);
}
.seed-item:hover {
border-color: var(--accent);
}
.swappable-value {
color: var(--med);
font-weight: bold;
text-decoration: underline dotted;
cursor: pointer;
padding: 0 2px;
border-radius: 2px;
}
.swappable-value:hover {
background-color: rgba(252,238,10,0.18);
}
.value-dropdown {
position: absolute;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
z-index: 2000;
max-height: 150px;
overflow-y: auto;
min-width: 150px;
}
.dropdown-item {
padding: 8px 12px;
font-size: 0.8rem;
cursor: pointer;
border-bottom: 1px solid var(--border);
font-family: var(--mono);
}
.dropdown-item:hover {
background: var(--bg-elev);
color: var(--accent);
}
.tab-header {
display: flex;
border-bottom: 1px solid var(--border);
margin-bottom: 15px;
gap: 5px;
}
.tab-btn {
background: transparent;
border: none;
color: var(--muted);
padding: 8px 12px;
font-size: 0.8rem;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab-btn:hover {
color: var(--text);
}
.tab-btn.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.tree-container {
font-family: var(--mono);
font-size: 0.85rem;
}
.tree-node {
margin-left: 15px;
border-left: 1px solid var(--border);
}
.tree-item {
padding: 4px 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
border-radius: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tree-item:hover {
background: var(--bg-elev);
}
.tree-item.selected {
background: rgba(0,240,255,0.14);
color: var(--cyan);
}
.tree-toggle {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s;
}
.tree-toggle.collapsed {
transform: rotate(-90deg);
}
.tree-icon {
font-size: 0.7rem;
opacity: 0.7;
}
.tree-label {
flex: 1;
}
.tree-type {
color: var(--muted);
font-size: 0.75rem;
margin-left: auto;
}
</style>
</head>
<body>
<div id="report-modal" onclick="if(event.target === this) closeReport()">
<div class="close-modal" onclick="closeReport()">×</div>
<div class="modal-content">
<h2 style="margin-top:0">Full Security Analysis Report</h2>
<div class="meta" id="report-meta-text"></div>
<hr style="border: 0; border-top: 1px solid var(--border); margin: 20px 0;">
<h3>Schema Statistics</h3>
<div class="report-grid" id="report-stats-grid"></div>
<h3>Vulnerability Details</h3>
<div id="report-findings-content"></div>
<div class="modal-footer">
<button class="view-report-btn" style="width: auto; padding: 10px 30px;" onclick="closeReport()">Close Report</button>
</div>
</div>
</div>
<div id="sidebar">
<div class="header">
<h1>Introspectre Visual</h1>
<div class="meta">Source: {{SOURCE}}</div>
<button class="view-report-btn" onclick="openReport()">View Full Report Summary</button>
</div>
<div class="search-container">
<input type="text" id="node-search" placeholder="Search types or fields..." autocomplete="off">
<div id="search-suggestions"></div>
</div>
<div id="summary-pane">
<div class="tab-header">
<button class="tab-btn active" onclick="switchTab('findings')">Findings</button>
<button class="tab-btn" onclick="switchTab('seeds')">Seeds</button>
<button class="tab-btn" onclick="switchTab('schema')">Schema</button>
</div>
<div id="tab-findings" class="tab-content active">
<div id="findings-list"></div>
</div>
<div id="tab-seeds" class="tab-content">
<div id="seeds-list">
<div style="font-size: 0.8rem; color: var(--muted);">No seed data yet. Supply real values with <code>--seed-traffic <burp.xml|.har></code> or <code>--seeds <file.json></code> so active probes use realistic input instead of placeholders.</div>
</div>
</div>
<div id="tab-schema" class="tab-content">
<div id="schema-tree" class="tree-container"></div>
</div>
</div>
<div id="details-pane">
<button class="back-btn" onclick="showSummary()">← Back to list</button>
<div id="details-content"></div>
</div>
</div>
<div id="cy"></div>
<div id="ctx-menu">
<div class="ctx-item" onclick="ctxExpandNode()">Expand Relations</div>
<div class="ctx-item" onclick="ctxExpandAll()">Expand All Children</div>
<div class="ctx-divider"></div>
<div class="ctx-item" onclick="ctxTraceRoot()">Trace Path to Root</div>
<div class="ctx-item" onclick="ctxHideNode()">Hide Node</div>
</div>
<div class="graph-controls">
<button class="graph-btn" id="filter-scalars-btn" onclick="toggleScalars()">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8z"/></svg>
Scalars: SHOWN
</button>
<button class="graph-btn" id="isolate-mode-btn" onclick="toggleIsolateMode()">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 8a3 3 0 100-6 3 3 0 000 6zm6 5c0-2.21-3.13-4-7-4s-7 1.79-7 4v1h14v-1z"/></svg>
Isolate Mode: OFF
</button>
<button class="graph-btn" onclick="resetGraph()">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 3.5a4.5 4.5 0 104.5 4.5H11l3 3.5 3-3.5h-1.5a6 6 0 11-6-6V3.5z"/></svg>
Reset Graph
</button>
<button class="graph-btn" onclick="showAllNodes()">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8 8 0 100 16A8 8 0 008 0zM1.5 8a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0z"/></svg>
Show All
</button>
<button class="graph-btn" onclick="undoGraph()" title="Undo (Ctrl+Z)">
↶ Back
</button>
<button class="graph-btn" onclick="redoGraph()" title="Redo (Ctrl+Y)">
↷ Forward
</button>
</div>
<div class="legend" id="legend-panel">
<div class="legend-header" onclick="toggleLegend()">
<span>Legend</span>
<span id="legend-toggle-icon">▸</span>
</div>
<div class="legend-body" id="legend-body">
<div class="legend-item"><span class="legend-swatch" style="background:#FF2A6D;"></span> High risk</div>
<div class="legend-item"><span class="legend-swatch" style="background:#FCEE0A;"></span> Medium risk</div>
<div class="legend-item"><span class="legend-swatch" style="background:#00F0FF;"></span> Info</div>
<div class="legend-item"><span class="legend-swatch" style="background:#B967FF;"></span> Mutation-type field</div>
<div class="legend-item" style="margin-top:4px;opacity:.7;font-size:11px;">Ring color = type kind:</div>
<div class="legend-item"><span class="legend-swatch" style="background:#B7C3E8; border-radius:50%; border:2px solid #C9D2E8; box-sizing:border-box;"></span> grey ring = Object</div>
<div class="legend-item"><span class="legend-swatch" style="background:#B7C3E8; border-radius:50%; border:2px solid #F5A623; box-sizing:border-box;"></span> amber ring = Input object</div>
<div class="legend-item"><span class="legend-swatch" style="background:#B7C3E8; border-radius:50%; border:2px solid #58C97A; box-sizing:border-box;"></span> green ring = Interface</div>
<div class="legend-item"><span class="legend-swatch" style="background:#B7C3E8; border-radius:50%; border:2px solid #FF8AC4; box-sizing:border-box;"></span> pink ring = Union</div>
<div class="legend-item"><span class="legend-swatch" style="background:#B7C3E8; border-radius:50%; border:2px solid #FF8A3D; box-sizing:border-box;"></span> orange ring = Enum / Scalar</div>
<div class="legend-item"><span class="legend-swatch" style="background:#FFFFFF; border-radius:50%; border:2px solid #00F0FF; box-sizing:border-box;"></span> ◎ cyan ring = Root Query</div>
<div class="legend-item"><span class="legend-swatch" style="background:#FFFFFF; border-radius:50%; border:2px solid #B967FF; box-sizing:border-box;"></span> ◎ purple ring = Root Mutation</div>
</div>
</div>
<div class="instruction-toast" id="instruction-toast">
<span class="toast-close" onclick="hideToastPermanently()" title="Dismiss">×</span>
<b>Tip:</b> Double-click a node to expand · single-click to inspect · hover to highlight.<br>
Nodes are <b>types</b> (shown once); edges are field names.<br>
Scroll to zoom, drag to pan, drag a node to reposition it. Use <b>Isolate Mode</b> to focus one path.
<br>Scalars & enums (leaf value types) are <b>hidden by default</b> to cut clutter and keep the focus on the object/relationship attack surface — click <b>Scalars: HIDDEN</b> in the toolbar to reveal them.
</div>
<button class="graph-btn" id="toast-reopen-btn" onclick="showInstructionToast()" title="Show tips">?</button>
<script>
const graphData = {{GRAPH_DATA}};
const findingsData = {{FINDINGS_DATA}};
const statsData = {{STATS}};
const metaData = {{META}};
const seedsData = {{SEEDS_DATA}};
const masterNodes = graphData.nodes;
const masterEdges = graphData.edges.map(e => ({
...e,
id: `${e.source}-${e.target}-${e.label}`
}));
function openReport() {
const modal = document.getElementById('report-modal');
const statsGrid = document.getElementById('report-stats-grid');
const findingsContent = document.getElementById('report-findings-content');
const metaText = document.getElementById('report-meta-text');
metaText.innerHTML = `Source: ${metaData.source} | Mode: ${metaData.offline ? 'Offline' : 'Live'}`;
statsGrid.innerHTML = `
<div class="report-stat"><strong>Total Types</strong><span>${statsData.total_types}</span></div>
<div class="report-stat"><strong>Queries</strong><span>${statsData.queries}</span></div>
<div class="report-stat"><strong>Mutations</strong><span>${statsData.mutations}</span></div>
<div class="report-stat"><strong>Total Fields</strong><span>${statsData.total_fields}</span></div>
`;
let html = "";
findingsData.forEach(f => {
html += `
<div class="card severity-${f.severity.toLowerCase()}">
<h3>[${f.severity}] ${f.title} <span class="id">${f.id}</span></h3>
<p>${f.description}</p>
<h4>Affected</h4>
<ul>${f.affected.map(a => `<li>${a}</li>`).join('')}</ul>
<h4>Remediation</h4>
<p style="color: var(--low)">${f.remediation}</p>
</div>
`;
});
findingsContent.innerHTML = html;
modal.style.display = 'block';
}
function closeReport() {
document.getElementById('report-modal').style.display = 'none';
}
let graph = new graphology.Graph({ multi: true, type: "directed" });
let renderer = null;
let highlightedNodes = new Set();
let hoveredNode = null;
let draggedNode = null;
let didDrag = false;
function nodeSize(n) {
let base = 9; if (n.risk === 'high') base = 15;
else if (n.risk === 'medium') base = 12;
else if (n.kind === 'SCALAR' || n.kind === 'ENUM') base = 7;
if (n.isRoot) base += 6;
return base;
}
function nodeColor(n) {
if (n.isRoot) return '#FFFFFF';
if (n.risk === 'high') return '#FF2A6D';
if (n.risk === 'medium') return '#FCEE0A';
if (n.opType === 'mutation') return '#B967FF';
if (n.risk === 'info') return '#00F0FF';
if (n.kind === 'SCALAR' || n.kind === 'ENUM') return '#8792AC';
return '#B7C3E8';
}
function nodeRingColor(n) {
if (n.isRoot) return n.opType === 'mutation' ? '#B967FF' : '#00F0FF';
switch (n.kind) {
case 'INPUT_OBJECT': return '#F5A623'; case 'INTERFACE': return '#58C97A'; case 'UNION': return '#FF8AC4'; case 'ENUM':
case 'SCALAR': return '#FF8A3D'; default: return '#C9D2E8'; }
}
function nodeAttrs(n, x, y) {
const color = nodeColor(n);
return {
label: n.label || n.id,
size: nodeSize(n),
color: color,
type: 'bordered',
borderColor: nodeRingColor(n),
x: x,
y: y,
kind: n.kind,
risk: n.risk,
isRoot: !!n.isRoot,
isSensitive: !!n.isSensitive,
authRequired: !!n.authRequired,
opType: n.opType,
hasHidden: false,
forceLabel: !!n.isRoot || n.risk === 'high'
};
}
function edgeAttrs(e) {
return {
label: e.label,
size: 3,
color: e.isDeprecated ? '#555' : '#5A6580',
isDeprecated: !!e.isDeprecated,
args: e.args,
weight: e.weight
};
}
function addMasterNodeToGraph(id, nearPos, exact) {
const n = masterNodes.find(m => m.id === id);
if (!n) return false;
let x, y;
if (nearPos && exact) {
x = nearPos.x;
y = nearPos.y;
} else if (nearPos) {
x = nearPos.x + (Math.random() - 0.5) * 0.6;
y = nearPos.y + (Math.random() - 0.5) * 0.6;
} else {
x = Math.random();
y = Math.random();
}
graph.addNode(id, nodeAttrs(n, x, y));
return true;
}
function addMasterEdgeToGraph(e) {
graph.addEdgeWithKey(e.id, e.source, e.target, edgeAttrs(e));
}
function runForceAtlas(opts) {
const n = graph.order;
if (n === 0) return;
const iterations = (opts && opts.iterations) ? opts.iterations : (n < 400 ? 200 : 100);
let snapshot = null;
if (opts && opts.preserveExisting) {
snapshot = {};
graph.forEachNode((id, a) => { snapshot[id] = { x: a.x, y: a.y }; });
}
try {
const settings = graphologyLibrary.layoutForceAtlas2.inferSettings(graph);
graphologyLibrary.layoutForceAtlas2.assign(graph, { iterations, settings });
} catch (err) {
console.error('ForceAtlas2 layout failed:', err);
}
if (snapshot) {
for (const id in snapshot) {
if (graph.hasNode(id)) {
graph.setNodeAttribute(id, 'x', snapshot[id].x);
graph.setNodeAttribute(id, 'y', snapshot[id].y);
}
}
}
}
function rebuildVisibleGraph(nodes, edges) {
graph.clear();
nodes.forEach(n => {
if (!graph.hasNode(n.id)) {
addMasterNodeToGraph(n.id, null);
}
});
edges.forEach(e => {
if (graph.hasNode(e.source) && graph.hasNode(e.target) && !graph.hasEdge(e.id)) {
addMasterEdgeToGraph(e);
}
});
runForceAtlas();
updateExpandableMarkers();
if (renderer) renderer.refresh();
}
function clearHighlight() {
highlightedNodes = new Set();
if (renderer) renderer.refresh();
}
function setHighlight(ids) {
highlightedNodes = new Set(ids);
if (renderer) renderer.refresh();
}
function addHighlight(id) {
highlightedNodes.add(id);
if (renderer) renderer.refresh();
}
function nodeReducer(node, data) {
const res = { ...data };
if (highlightedNodes.size > 0) {
if (highlightedNodes.has(node)) {
res.highlighted = true;
res.zIndex = 1;
res.size = data.size + 2;
} else {
res.color = '#2a2f3f';
res.label = '';
res.zIndex = 0;
}
}
if (data.hasHidden) {
res.size = (res.size || data.size) + 2;
}
return res;
}
function edgeReducer(edge, data) {
const res = { ...data };
if (highlightedNodes.size > 0) {
const extremities = graph.extremities(edge);
const touches = extremities.every(n => highlightedNodes.has(n));
if (touches) {
res.color = '#00F0FF';
res.size = 4;
res.zIndex = 1;
} else {
res.color = '#33384a';
res.zIndex = 0;
}
}
return res;
}
function defaultDrawNodeHover(context, data, settings) {
const size = settings.labelSize;
const font = settings.labelFont;
const weight = settings.labelWeight;
context.font = `${weight} ${size}px ${font}`;
context.fillStyle = '#0a0a0f';
context.strokeStyle = '#00F0FF';
context.lineWidth = 1;
context.shadowOffsetX = 0;
context.shadowOffsetY = 0;
context.shadowBlur = 8;
context.shadowColor = '#000';
const PADDING = 2;
if (typeof data.label === 'string' && data.label.length > 0) {
const textWidth = context.measureText(data.label).width;
const boxWidth = Math.round(textWidth + 5);
const boxHeight = Math.round(size + 2 * PADDING);
const radius = Math.max(data.size, size / 2) + PADDING;
const angleRadian = Math.asin(boxHeight / 2 / radius);
const xDeltaCoord = Math.sqrt(Math.abs(Math.pow(radius, 2) - Math.pow(boxHeight / 2, 2)));
context.beginPath();
context.moveTo(data.x + xDeltaCoord, data.y + boxHeight / 2);
context.lineTo(data.x + radius + boxWidth, data.y + boxHeight / 2);
context.lineTo(data.x + radius + boxWidth, data.y - boxHeight / 2);
context.lineTo(data.x + xDeltaCoord, data.y - boxHeight / 2);
context.arc(data.x, data.y, radius, angleRadian, -angleRadian);
context.closePath();
context.fill();
context.shadowOffsetX = 0;
context.shadowOffsetY = 0;
context.shadowBlur = 0;
context.stroke();
} else {
context.beginPath();
context.arc(data.x, data.y, data.size + PADDING, 0, Math.PI * 2);
context.closePath();
context.fill();
context.shadowOffsetX = 0;
context.shadowOffsetY = 0;
context.shadowBlur = 0;
context.stroke();
}
if (data.label) {
const labelColorAttr = settings.labelColor && settings.labelColor.attribute
? (data[settings.labelColor.attribute] || settings.labelColor.color || '#d7e0ea')
: (settings.labelColor ? settings.labelColor.color : '#d7e0ea');
context.fillStyle = labelColorAttr;
context.font = `${weight} ${size}px ${font}`;
context.fillText(data.label, data.x + data.size + 3, data.y + size / 3);
}
}
const R = Sigma.rendering;
const BorderedProgram = R.createNodeBorderProgram({
borders: [
{ size: { value: 0.18 }, color: { attribute: 'borderColor' } },
{ size: { fill: true }, color: { attribute: 'color' } }
]
});
const sigmaSettings = {
defaultEdgeType: "arrow",
renderLabels: true,
labelColor: { color: "#d7e0ea" },
labelFont: 'ui-monospace, "Cascadia Code", "JetBrains Mono", Consolas, monospace',
labelRenderedSizeThreshold: 4,
labelDensity: 0.8,
labelGridCellSize: 60,
defaultEdgeColor: "#5A6580",
minCameraRatio: 0.05,
maxCameraRatio: 12,
allowInvalidContainer: true,
nodeProgramClasses: { circle: R.NodeCircleProgram, bordered: BorderedProgram },
nodeReducer,
edgeReducer,
defaultDrawNodeHover
};
try {
renderer = new Sigma(graph, document.getElementById('cy'), sigmaSettings);
} catch (err) {
console.warn('Sigma failed to init with arrow edge type, retrying with line edges', err);
sigmaSettings.defaultEdgeType = 'line';
renderer = new Sigma(graph, document.getElementById('cy'), sigmaSettings);
}
function centerOnNode(nodeId, ratio) {
if (!graph.hasNode(nodeId) || !renderer) return;
const nd = renderer.getNodeDisplayData(nodeId);
if (nd) {
renderer.getCamera().animate({ x: nd.x, y: nd.y, ratio: ratio || 0.3 }, { duration: 500 });
}
}
const instructionToastEl = document.getElementById('instruction-toast');
const toastReopenBtnEl = document.getElementById('toast-reopen-btn');
const defaultToastHTML = instructionToastEl ? instructionToastEl.innerHTML : '';
let toastTimer = null;
function armAutoHide() {
clearTimeout(toastTimer);
toastTimer = setTimeout(hideToastPermanently, 8000);
}
function hideToastPermanently() {
clearTimeout(toastTimer);
if (instructionToastEl) instructionToastEl.style.display = 'none';
if (toastReopenBtnEl) toastReopenBtnEl.style.display = 'flex';
}
function showInstructionToast() {
if (!instructionToastEl) return;
instructionToastEl.innerHTML = defaultToastHTML;
instructionToastEl.style.display = 'block';
if (toastReopenBtnEl) toastReopenBtnEl.style.display = 'none';
armAutoHide();
}
function showToast(msg, duration) {
if (!instructionToastEl) return;
instructionToastEl.style.display = 'block';
if (toastReopenBtnEl) toastReopenBtnEl.style.display = 'none';
instructionToastEl.innerHTML = `<span class="toast-close" onclick="hideToastPermanently()" title="Dismiss">×</span><b>Note:</b> ${msg}`;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
instructionToastEl.innerHTML = defaultToastHTML;
armAutoHide();
}, duration || 3000);
}
armAutoHide();
function toggleLegend() {
const body = document.getElementById('legend-body');
const icon = document.getElementById('legend-toggle-icon');
if (!body) return;
const expanded = body.classList.toggle('expanded');
if (icon) icon.textContent = expanded ? '▾' : '▸';
}
function makeNodeShim(nodeObj) {
return { id: () => nodeObj.id, data: (key) => nodeObj[key] };
}
function makeEdgeShim(edgeData) {
return {
data: () => edgeData,
source: () => ({ id: () => edgeData.source }),
target: () => ({ id: () => edgeData.target })
};
}
let historyStack = [];
let redoStack = [];
const HISTORY_MAX = 50;
function snapshotGraph() {
return { nodeIds: graph.nodes(), edgeIds: graph.edges() };
}
function pushHistory() {
historyStack.push(snapshotGraph());
if (historyStack.length > HISTORY_MAX) historyStack.shift();
redoStack = [];
}
function restoreSnapshot(snap) {
const nodes = snap.nodeIds.map(id => masterNodes.find(n => n.id === id)).filter(Boolean);
const edges = snap.edgeIds.map(id => masterEdges.find(e => e.id === id)).filter(Boolean);
rebuildVisibleGraph(nodes, edges);
}
function undoGraph() {
if (!historyStack.length) return;
redoStack.push(snapshotGraph());
restoreSnapshot(historyStack.pop());
}
function redoGraph() {
if (!redoStack.length) return;
historyStack.push(snapshotGraph());
restoreSnapshot(redoStack.pop());
}
function resetGraph() {
pushHistory();
const rootNodes = masterNodes.filter(n => n.isRoot);
const nodesToAdd = hideScalars
? rootNodes.filter(n => n.kind !== 'SCALAR' && n.kind !== 'ENUM')
: rootNodes;
rebuildVisibleGraph(nodesToAdd, []);
}
function showAllNodes() {
if (masterNodes.length > 150 && !confirm(`This schema has ${masterNodes.length} types and ${masterEdges.length} relations. Rendering everything at once may be heavy. Continue?`)) return;
pushHistory();
let nodesToAdd = masterNodes;
let edgesToAdd = masterEdges;
if (hideScalars) {
nodesToAdd = masterNodes.filter(n => n.kind !== 'SCALAR' && n.kind !== 'ENUM');
edgesToAdd = masterEdges.filter(e => {
const target = masterNodes.find(n => n.id === e.target);
return target && target.kind !== 'SCALAR' && target.kind !== 'ENUM';
});
}
rebuildVisibleGraph(nodesToAdd, edgesToAdd);
}
let hideScalars = true; function toggleScalars() {
hideScalars = !hideScalars;
const btn = document.getElementById('filter-scalars-btn');
btn.innerHTML = hideScalars ?
`<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8z"/></svg> Scalars: HIDDEN` :
`<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8z"/></svg> Scalars: SHOWN`;
btn.classList.toggle('btn-active', hideScalars);
if (hideScalars) {
pushHistory();
const toRemove = [];
graph.forEachNode((id, attrs) => {
if (attrs.kind === 'SCALAR' || attrs.kind === 'ENUM') toRemove.push(id);
});
toRemove.forEach(id => graph.dropNode(id));
updateExpandableMarkers();
if (renderer) renderer.refresh();
}
}
document.getElementById('filter-scalars-btn').classList.add('btn-active');
document.getElementById('filter-scalars-btn').innerHTML = `<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8z"/></svg> Scalars: HIDDEN`;
const EXPAND_CAP = 15;
function rankByRisk(edges) {
const riskRank = r => r === 'high' ? 3 : r === 'medium' ? 2 : r === 'info' ? 1 : 0;
return edges.slice().sort((a, b) => {
const an = masterNodes.find(n => n.id === a.target);
const bn = masterNodes.find(n => n.id === b.target);
const ar = an ? riskRank(an.risk) : 0;
const br = bn ? riskRank(bn.risk) : 0;
if (br !== ar) return br - ar;
return a.target.localeCompare(b.target);
});
}
function revealFromNode(nodeId, capLimit) {
let outgoing = masterEdges.filter(e => e.source === nodeId);
if (hideScalars) {
outgoing = outgoing.filter(e => {
const target = masterNodes.find(n => n.id === e.target);
return target && target.kind !== 'SCALAR' && target.kind !== 'ENUM';
});
}
const newOutgoing = outgoing.filter(e => !graph.hasNode(e.target) || !graph.hasEdge(e.id));
let toReveal = newOutgoing;
let capped = false;
if (isFinite(capLimit) && newOutgoing.length > capLimit) {
capped = true;
toReveal = rankByRisk(newOutgoing).slice(0, capLimit);
}
if (toReveal.length === 0) return { added: 0, capped: false, total: newOutgoing.length };
const parentPos = graph.hasNode(nodeId)
? { x: graph.getNodeAttribute(nodeId, 'x'), y: graph.getNodeAttribute(nodeId, 'y') }
: null;
const newTargets = toReveal.filter(e => !graph.hasNode(e.target));
const k = newTargets.length;
let unit = 8;
if (parentPos && graph.order > 1) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
graph.forEachNode((id, a) => {
if (a.x < minX) minX = a.x; if (a.x > maxX) maxX = a.x;
if (a.y < minY) minY = a.y; if (a.y > maxY) maxY = a.y;
});
const diag = Math.hypot(maxX - minX, maxY - minY);
unit = diag > 1e-3 ? diag / Math.sqrt(graph.order) : 8;
}
const R = unit * Math.min(1.7 + k * 0.22, 5.5);
let parentHash = 0;
for (let i = 0; i < nodeId.length; i++) parentHash = (parentHash + nodeId.charCodeAt(i)) % 360;
const angOffset = (parentHash / 360) * 2 * Math.PI;
let added = 0;
let ringIdx = 0;
toReveal.forEach(e => {
if (!graph.hasNode(e.target)) {
let pos = parentPos;
if (parentPos && k > 0) {
const ang = (ringIdx / k) * 2 * Math.PI + angOffset;
pos = {
x: parentPos.x + R * Math.cos(ang) + (Math.random() - 0.5) * 0.3,
y: parentPos.y + R * Math.sin(ang) + (Math.random() - 0.5) * 0.3
};
ringIdx++;
}
if (addMasterNodeToGraph(e.target, pos, true)) added++;
}
if (!graph.hasEdge(e.id) && graph.hasNode(e.source) && graph.hasNode(e.target)) {
addMasterEdgeToGraph(e);
added++;
}
});
return { added, capped, total: newOutgoing.length };
}
function expandNode(nodeId) {
pushHistory();
const result = revealFromNode(nodeId, EXPAND_CAP);
if (result.added > 0) {
runForceAtlas({ iterations: Math.min(120, 40 + result.added * 2), preserveExisting: true });
updateExpandableMarkers();
if (renderer) renderer.refresh();
}
if (result.capped) {
showToast(`Showing ${EXPAND_CAP} of ${result.total} — right-click → Expand all`);
}
}
let selectedNodeId = null;
window.addEventListener('click', function() {
document.getElementById('ctx-menu').style.display = 'none';
});
function ctxExpandNode() {
if (selectedNodeId) expandNode(selectedNodeId);
}
function ctxExpandAll() {
if (!selectedNodeId) return;
let totalAdded = 0;
const expandRecursive = (id, depth) => {
if (depth > 3) return;
const r = revealFromNode(id, Infinity);
totalAdded += r.added;
const children = masterEdges.filter(e => e.source === id).map(e => e.target);
children.forEach(c => expandRecursive(c, depth + 1));
};
expandRecursive(selectedNodeId, 0);
if (totalAdded > 0) {
runForceAtlas({ iterations: 100 });
updateExpandableMarkers();
if (renderer) renderer.refresh();
}
}
function ctxTraceRoot() {
if (selectedNodeId) ensurePathVisible(selectedNodeId);
}
function ctxHideNode() {
if (selectedNodeId && graph.hasNode(selectedNodeId)) {
graph.dropNode(selectedNodeId);
updateExpandableMarkers();
if (renderer) renderer.refresh();
}
}
function updateExpandableMarkers() {
graph.forEachNode((id) => {
const hasOutgoingInMaster = masterEdges.some(e => e.source === id);
const allOutgoingShown = masterEdges
.filter(e => e.source === id)
.every(e => graph.hasEdge(e.id));
graph.setNodeAttribute(id, 'hasHidden', hasOutgoingInMaster && !allOutgoingShown);
});
}
let isolateMode = false;
function toggleIsolateMode() {
isolateMode = !isolateMode;
const btn = document.getElementById('isolate-mode-btn');
btn.innerHTML = isolateMode ?
`<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 8a3 3 0 100-6 3 3 0 000 6zm6 5c0-2.21-3.13-4-7-4s-7 1.79-7 4v1h14v-1z"/></svg> Isolate Mode: ON` :
`<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 8a3 3 0 100-6 3 3 0 000 6zm6 5c0-2.21-3.13-4-7-4s-7 1.79-7 4v1h14v-1z"/></svg> Isolate Mode: OFF`;
btn.style.borderColor = isolateMode ? 'var(--accent)' : 'var(--border)';
}
function getFullPathDiscovery(targetId) {
const rootIds = masterNodes.filter(n => n.isRoot).map(n => n.id);
let shortestPath = null;
let queue = rootIds.map(id => [id]);
let visited = new Set(rootIds);
while (queue.length > 0) {
let path = queue.shift();
let last = path[path.length - 1];
if (last === targetId) {
shortestPath = path;
break;
}
masterEdges.filter(e => e.source === last).forEach(e => {
if (!visited.has(e.target)) {
visited.add(e.target);
queue.push([...path, e.target]);
}
});
}
let successors = new Set();
let succEdges = new Set();
const stack = [targetId];
const seen = new Set();
while (stack.length > 0) {
const current = stack.pop();
if (seen.has(current)) continue;
seen.add(current);
successors.add(current);
let outgoing = masterEdges.filter(e => e.source === current);
if (hideScalars) {
outgoing = outgoing.filter(e => {
const tNode = masterNodes.find(n => n.id === e.target);
return tNode && tNode.kind !== 'SCALAR' && tNode.kind !== 'ENUM';
});
}
outgoing.forEach(e => {
succEdges.add(e.id);
stack.push(e.target);
});
}
const nodesSet = new Set(shortestPath || [targetId]);
successors.forEach(s => nodesSet.add(s));
const edgesSet = new Set(succEdges);
if (shortestPath) {
for (let i = 0; i < shortestPath.length - 1; i++) {
masterEdges.filter(e => e.source === shortestPath[i] && e.target === shortestPath[i+1])
.forEach(e => edgesSet.add(e.id));
}
}
return {
nodes: Array.from(nodesSet).map(id => masterNodes.find(n => n.id === id)).filter(Boolean),
edges: Array.from(edgesSet).map(id => masterEdges.find(e => e.id === id)).filter(Boolean)
};
}
function performIsolation(targetNodeId) {
pushHistory();
const discovery = getFullPathDiscovery(targetNodeId);
rebuildVisibleGraph(discovery.nodes, discovery.edges);
}
function ensurePathVisible(targetNodeId) {
if (isolateMode) {
performIsolation(targetNodeId);
return;
}
const rootIds = masterNodes.filter(n => n.isRoot).map(n => n.id);
if (rootIds.includes(targetNodeId)) {
if (!graph.hasNode(targetNodeId)) {
addMasterNodeToGraph(targetNodeId, null);
runForceAtlas({ preserveExisting: true });
updateExpandableMarkers();
if (renderer) renderer.refresh();
}
return;
}
const queue = rootIds.map(r => [r]);
const visited = new Set(rootIds);
let path = null;
while (queue.length > 0) {
const currentPath = queue.shift();
const lastNode = currentPath[currentPath.length - 1];
if (lastNode === targetNodeId) {
path = currentPath;
break;
}
masterEdges
.filter(e => e.source === lastNode)
.forEach(e => {
if (!visited.has(e.target)) {
visited.add(e.target);
queue.push([...currentPath, e.target]);
}
});
}
if (path) {
path.forEach((nodeId, i) => {
if (!graph.hasNode(nodeId)) {
addMasterNodeToGraph(nodeId, null);
}
if (i > 0) {
const prevNodeId = path[i-1];
const edge = masterEdges.find(e => e.source === prevNodeId && e.target === nodeId);
if (edge && !graph.hasEdge(edge.id) && graph.hasNode(edge.source) && graph.hasNode(edge.target)) {
addMasterEdgeToGraph(edge);
}
}
});
updateExpandableMarkers();
runForceAtlas({ preserveExisting: true });
if (renderer) renderer.refresh();
} else {
if (!graph.hasNode(targetNodeId)) {
const node = masterNodes.find(n => n.id === targetNodeId);
if (node) {
addMasterNodeToGraph(targetNodeId, null);
runForceAtlas({ preserveExisting: true });
updateExpandableMarkers();
if (renderer) renderer.refresh();
}
}
}
}
function showSummary() {
document.getElementById('summary-pane').style.display = 'block';
document.getElementById('details-pane').style.display = 'none';
clearHighlight();
}
function processQueryForSeeds(query) {
let result = query;
seedsData.forEach(s => {
const escapedVal = s.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const fieldName = s.field_name;
const regex = new RegExp(`(${fieldName}:\\s+)("?${escapedVal}"?)(\\s+#\\s+\\[Source: [^\\]]+\\])`, 'g');
result = result.replace(regex, (match, p1, p2, p3) => {
return `${p1}<span class="swappable-value" data-field="${fieldName}" onclick="openValueDropdown(this, event)">${p2}</span>${p3}`;
});
});
return result;
}
function showDetails(findingId) {
const finding = findingsData.find(f => f.id === findingId);
if (!finding) return;
document.getElementById('summary-pane').style.display = 'none';
document.getElementById('details-pane').style.display = 'block';
const affectedTypes = [...new Set(finding.affected.map(a => String(a).split('.')[0]))];
const missingTypes = affectedTypes.filter(t => !masterNodes.some(n => n.id === t));
const shownTypes = affectedTypes.filter(t => !missingTypes.includes(t));
let html = `
<div class="card severity-${finding.severity.toLowerCase()}">
<button class="back-btn" onclick="showSummary()">← Back to list</button>
<h3>[${finding.id}] ${finding.title}</h3>
<p style="font-size:0.8rem;">Highlighted in graph: <b>${shownTypes.join(', ') || '—'}</b></p>
${missingTypes.length ? `<div style="color:var(--med)">⚠ Not present in the current graph view: ${missingTypes.join(', ')}</div>` : ''}
<p>${finding.description}</p>
<strong>Remediation:</strong>
<p style="color: var(--low)">${finding.remediation}</p>
</div>
<h4>Affected Fields & Exploits</h4>
`;
finding.affected.forEach((aff, index) => {
const tplObj = finding.templates[index];
let literalStr = typeof tplObj === 'object' && tplObj.literal ? tplObj.literal : String(tplObj);
const varStr = typeof tplObj === 'object' && tplObj.variable ? tplObj.variable : literalStr;
literalStr = processQueryForSeeds(literalStr);
html += `
<div class="card">
<strong onclick="focusAffected('${aff.split('.')[0]}')" style="cursor:pointer;text-decoration:underline dotted;">${aff}</strong>
<div class="toggle-group" id="finding-toggle-${index}">
<button class="toggle-btn active" onclick="toggleFindingQuery(${index}, 'literal')">Literal</button>
<button class="toggle-btn" onclick="toggleFindingQuery(${index}, 'variable')">Variables</button>
</div>
<pre id="tpl-lit-${index}">${literalStr}</pre>
<pre id="tpl-var-${index}" style="display:none;">${varStr}</pre>
<div id="btn-group-lit-${index}" style="margin-top: 10px;">
<button onclick="copyToClipboard('tpl-lit-${index}')">Copy GraphQL</button>
<button onclick="copyAsCurlGeneric('tpl-lit-${index}')">Copy as cURL</button>
<button onclick="copyAsPythonGeneric('tpl-lit-${index}')">Copy as Python</button>
</div>
<div id="btn-group-var-${index}" style="display:none; margin-top: 10px;">
<button onclick="copyToClipboard('tpl-var-${index}')">Copy GraphQL</button>
<button onclick="copyAsCurlGeneric('tpl-var-${index}')">Copy as cURL</button>
<button onclick="copyAsPythonGeneric('tpl-var-${index}')">Copy as Python</button>
</div>
</div>
`;
});
document.getElementById('details-content').innerHTML = html;
clearHighlight();
let firstShown = null;
affectedTypes.forEach(t => {
if (masterNodes.some(n => n.id === t)) {
ensurePathVisible(t);
addHighlight(t);
if (!firstShown) firstShown = t;
}
});
renderer && renderer.refresh();
if (firstShown) centerOnNode(firstShown, 0.4);
}
window.focusAffected = function(typeName) {
if (!masterNodes.some(n => n.id === typeName)) return;
ensurePathVisible(typeName);
clearHighlight();
addHighlight(typeName);
renderer && renderer.refresh();
centerOnNode(typeName, 0.4);
};
function getQueryForEdge(edgeData) {
const sourceId = edgeData.source;
const targetId = edgeData.target;
const fieldName = edgeData.label;
const rootIds = masterNodes.filter(n => n.isRoot).map(n => n.id);
let bestPath = null;
let bestRootId = null;
let minWeight = Infinity;
rootIds.forEach(rootId => {
const queue = [[rootId]];
const visited = new Set([rootId]);
while (queue.length > 0) {
const path = queue.shift();
const lastId = path[path.length - 1];
if (lastId === sourceId) {
const weight = path.length;
if (weight < minWeight) {
minWeight = weight;
bestPath = path;
bestRootId = rootId;
}
break;
}
masterEdges.filter(e => e.source === lastId).forEach(e => {
if (!visited.has(e.target)) {
visited.add(e.target);
queue.push([...path, e.target]);
}
});
}
});
let queryPath = [];
if (bestPath) {
for (let i = 0; i < bestPath.length - 1; i++) {
const s = bestPath[i];
const t = bestPath[i+1];
const edge = masterEdges.find(e => e.source === s && e.target === t);
if (edge) {
queryPath.push({ label: edge.label, args: edge.args || [] });
}
}
}
queryPath.push({ label: fieldName, args: edgeData.args || [] });
let indent = " ";
let literalQuery = "";
let varQueryBody = "";
let varDefs = [];
const bestRootNode = masterNodes.find(n => n.id === bestRootId);
let rootOp = "query";
let opBadgeClass = "tag-info";
if (bestRootNode && bestRootNode.opType) {
rootOp = bestRootNode.opType;
if (rootOp === 'mutation') opBadgeClass = "tag-high";
if (rootOp === 'subscription') opBadgeClass = "tag-med";
}
literalQuery += `${rootOp} {\n`;
queryPath.forEach((seg, i) => {
literalQuery += `${indent}${seg.label}`;
varQueryBody += `${indent}${seg.label}`;
if (seg.args && seg.args.length > 0) {
const litArgs = [];
const varArgs = [];
seg.args.forEach(a => {
const fieldSeeds = seedsData.filter(s => s.field_name === a.name);
let val = a.sampleValue;
let displayVal = val;
let annotation = "";
if (fieldSeeds.length > 0) {
val = fieldSeeds[0].value;
if (a.typeName === 'String' || a.typeName === 'ID') {
val = `"${val}"`;
}
annotation = ` # [Source: ${fieldSeeds[0].source}]`;
displayVal = `<span class="swappable-value" data-field="${a.name}" onclick="openValueDropdown(this, event)">${val}</span>`;
}
litArgs.push(`${a.name}: ${displayVal}${annotation}`);
varArgs.push(`${a.name}: $${a.name}`);
varDefs.push(`$${a.name}: ${a.typeName || 'String'}`);
});
literalQuery += `(${litArgs.join(", ")})`;
varQueryBody += `(${varArgs.join(", ")})`;
}
if (i < queryPath.length - 1) {
literalQuery += " {\n";
varQueryBody += " {\n";
indent += " ";
}
});
const targetNode = masterNodes.find(n => n.id === targetId);
const targetKind = targetNode ? targetNode.kind : 'UNKNOWN';
if (targetKind === 'OBJECT' || targetKind === 'INTERFACE' || targetKind === 'UNION') {
const typeSelect = " {\n" + `${indent} __typename\n` + `${indent}}`;
literalQuery += typeSelect;
varQueryBody += typeSelect;
}
for (let i = 0; i < queryPath.length - 1; i++) {
indent = indent.substring(0, indent.length - 2);
literalQuery += `\n${indent}}`;
varQueryBody += `\n${indent}}`;
}
literalQuery += "\n}";
let varQuery = "";
if (varDefs.length > 0) {
const uniqueDefs = [...new Set(varDefs)];
varQuery = `${rootOp} Explore(${uniqueDefs.join(", ")}) {\n${varQueryBody}\n}`;
} else {
varQuery = `${rootOp} Explore {\n${varQueryBody}\n}`;
}
return {
literalQuery,
varQuery,
rootOp,
opBadgeClass,
queryPathStr: bestRootId ? `${bestRootId} → ${queryPath.map(p => p.label).join(' → ')}` : 'None',
sourceId: sourceId,
targetId: targetId,
targetKind: targetKind,
fieldName: fieldName
};
}
function renderQueryCard(q, index) {
const litId = `path-query-literal-${index}`;
const varId = `path-query-variable-${index}`;
return `
<div class="card" style="margin-top: 15px;">
<div style="margin-bottom: 10px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--border); padding-bottom: 10px;">
<span class="tag ${q.opBadgeClass}" style="font-size: 0.7rem; padding: 2px 6px; border-radius: 4px;">${q.rootOp.toUpperCase()}</span>
<strong style="font-size: 0.9rem;">${q.sourceId}.${q.fieldName}</strong>
</div>
<div class="meta" style="margin-bottom: 10px; font-size: 0.75rem;">
<strong>Path:</strong> ${q.queryPathStr}
</div>
<div class="toggle-group" style="margin-bottom: 10px;">
<button class="toggle-btn active" onclick="toggleQuerySet('${litId}', '${varId}', this)">Literal</button>
<button class="toggle-btn" onclick="toggleQuerySet('${varId}', '${litId}', this)">Variables</button>
</div>
<pre id="${litId}">${q.literalQuery}</pre>
<pre id="${varId}" style="display:none;">${q.varQuery}</pre>
<div style="margin-top: 10px; display: flex; gap: 8px;">
<button class="btn-sm" onclick="copyToClipboard('${litId}')">Copy GraphQL</button>
<button class="btn-sm" onclick="copyAsCurlGeneric('${litId}')">cURL</button>
</div>
</div>
`;
}
window.toggleQuerySet = function(showId, hideId, btn) {
document.getElementById(showId).style.display = 'block';
document.getElementById(hideId).style.display = 'none';
btn.parentElement.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
};
function showPathDetails(edge) {
try {
const q = getQueryForEdge(edge.data());
clearHighlight();
setHighlight([edge.source().id(), edge.target().id()]);
document.getElementById('summary-pane').style.display = 'none';
document.getElementById('details-pane').style.display = 'block';
let html = `
<div class="card">
<button class="back-btn" onclick="showSummary()">← Back to list</button>
<h3>Traversal Details</h3>
<p>Field: <strong>${q.fieldName}</strong> on <strong>${q.sourceId}</strong></p>
<p>Returns: <strong>${q.targetId}</strong> (${q.targetKind})</p>
</div>
${renderQueryCard(q, 0)}
`;
const fieldId = `${q.sourceId}.${q.fieldName}`;
const relevantFindings = findingsData.filter(f => f.affected.some(aff => aff.startsWith(fieldId) || aff === fieldId));
if (relevantFindings.length > 0) {
html += `<h4 style="margin-top: 20px;">Related Security Findings</h4>`;
relevantFindings.forEach(f => {
html += `
<div class="finding-item" onclick="showDetails('${f.id}')">
<span class="tag tag-${f.severity.toLowerCase()}">${f.severity}</span>
<strong>${f.title}</strong>
</div>
`;
});
}
document.getElementById('details-content').innerHTML = html;
} catch (err) {
console.error("Error in showPathDetails:", err);
}
}
function copyToClipboard(id) {
const text = document.getElementById(id).innerText;
navigator.clipboard.writeText(text);
}
function copyAsCurlGeneric(id) {
const queryText = document.getElementById(id).innerText;
const query = queryText.replace(/\n/g, ' ').replace(/"/g, '\\"');
const curl = `curl -X POST -H "Content-Type: application/json" -d '{"query": "${query}"}' {{SOURCE}}`;
navigator.clipboard.writeText(curl);
alert('Copied as cURL!');
}
function copyAsPythonGeneric(id) {
const queryText = document.getElementById(id).innerText;
const python = `import requests\n\nurl = "{{SOURCE}}"\nquery = """${queryText}"""\n\nresponse = requests.post(url, json={"query": query})\nprint(response.json())`;
navigator.clipboard.writeText(python);
alert('Copied as Python snippet!');
}
const searchInput = document.getElementById('node-search');
const searchSuggestions = document.getElementById('search-suggestions');
searchInput.addEventListener('input', function(e) {
const val = e.target.value.toLowerCase();
if (!val) {
searchSuggestions.style.display = 'none';
return;
}
let suggestions = [];
masterNodes.forEach(n => {
if (n.id.toLowerCase().includes(val)) {
suggestions.push({ type: 'node', id: n.id, label: n.id });
}
});
masterEdges.forEach(e => {
const label = `${e.source}.${e.label}`;
if (label.toLowerCase().includes(val)) {
suggestions.push({ type: 'edge', id: e.id, label: label, source: e.source, target: e.target });
}
});
if (suggestions.length > 0) {
searchSuggestions.innerHTML = suggestions.slice(0, 10).map(s => `
<div class="suggestion-item" onclick="selectSearchResult('${s.type}', '${s.id}')">
${s.label}
</div>
`).join('');
searchSuggestions.style.display = 'block';
} else {
searchSuggestions.style.display = 'none';
}
});
window.selectSearchResult = function(type, id) {
searchSuggestions.style.display = 'none';
searchInput.value = '';
if (type === 'node') {
ensurePathVisible(id);
clearHighlight();
addHighlight(id);
centerOnNode(id, 0.3);
handleNodeClick(id);
} else if (type === 'edge') {
const edgeData = masterEdges.find(e => e.id === id);
if (edgeData) {
ensurePathVisible(edgeData.target);
clearHighlight();
setHighlight([edgeData.source, edgeData.target]);
centerOnNode(edgeData.target, 0.3);
showPathDetails(makeEdgeShim(edgeData));
}
}
};
window.toggleFindingQuery = function(index, type) {
const btns = document.querySelectorAll('#finding-toggle-' + index + ' .toggle-btn');
btns.forEach(b => b.classList.remove('active'));
if (type === 'literal') {
btns[0].classList.add('active');
document.getElementById('tpl-lit-' + index).style.display = 'block';
document.getElementById('tpl-var-' + index).style.display = 'none';
document.getElementById('btn-group-lit-' + index).style.display = 'block';
document.getElementById('btn-group-var-' + index).style.display = 'none';
} else {
btns[1].classList.add('active');
document.getElementById('tpl-lit-' + index).style.display = 'none';
document.getElementById('tpl-var-' + index).style.display = 'block';
document.getElementById('btn-group-lit-' + index).style.display = 'none';
document.getElementById('btn-group-var-' + index).style.display = 'block';
}
};
window.toggleQuery = function(type) {
const btns = document.querySelectorAll('#query-toggle .toggle-btn');
btns.forEach(b => b.classList.remove('active'));
if (type === 'literal') {
btns[0].classList.add('active');
document.getElementById('path-query-literal').style.display = 'block';
document.getElementById('path-query-variable').style.display = 'none';
document.getElementById('btn-group-literal').style.display = 'block';
document.getElementById('btn-group-variable').style.display = 'none';
} else {
btns[1].classList.add('active');
document.getElementById('path-query-literal').style.display = 'none';
document.getElementById('path-query-variable').style.display = 'block';
document.getElementById('btn-group-literal').style.display = 'none';
document.getElementById('btn-group-variable').style.display = 'block';
}
};
function switchTab(tabId) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.querySelector(`.tab-btn[onclick="switchTab('${tabId}')"]`).classList.add('active');
document.getElementById(`tab-${tabId}`).classList.add('active');
if (tabId === 'schema' && !document.getElementById('schema-tree').innerHTML) {
buildSchemaTree();
}
}
function buildSchemaTree() {
const container = document.getElementById('schema-tree');
const roots = masterNodes.filter(n => n.isRoot);
roots.forEach(root => {
container.appendChild(createTreeNode(root.id, 'OBJECT', true));
});
}
function createTreeNode(nodeId, kind, isExpanded = false) {
const nodeEl = document.createElement('div');
nodeEl.className = 'tree-node-wrapper';
const item = document.createElement('div');
item.className = 'tree-item';
item.setAttribute('data-id', nodeId);
const children = masterEdges.filter(e => e.source === nodeId);
const hasChildren = children.length > 0;
let icon = '📦';
if (nodeId === 'Query') icon = '🔍';
if (nodeId === 'Mutation') icon = '⚡';
if (nodeId === 'Subscription') icon = '📡';
if (kind === 'SCALAR') icon = '📝';
if (kind === 'ENUM') icon = '🔢';
item.innerHTML = `
<div class="tree-toggle ${hasChildren ? '' : 'hidden'} ${isExpanded ? '' : 'collapsed'}">
${hasChildren ? '▼' : ''}
</div>
<span class="tree-icon">${icon}</span>
<span class="tree-label">${nodeId}</span>
`;
const toggle = item.querySelector('.tree-toggle');
const childrenContainer = document.createElement('div');
childrenContainer.className = 'tree-node';
childrenContainer.style.display = isExpanded ? 'block' : 'none';
const populateChildren = () => {
if (childrenContainer.innerHTML) return;
children.forEach(edge => {
const targetNode = masterNodes.find(n => n.id === edge.target);
const targetKind = targetNode ? targetNode.kind : 'UNKNOWN';
const childNodeWrapper = createTreeNode(edge.target, targetKind, false);
const childItem = childNodeWrapper.querySelector('.tree-item');
const isScalar = targetKind === 'SCALAR' || targetKind === 'ENUM';
const childIcon = isScalar ? '📝' : '🔗';
childItem.querySelector('.tree-icon').innerText = childIcon;
const labelSpan = childItem.querySelector('.tree-label');
labelSpan.innerHTML = `${edge.label} <span class="tree-type" style="margin-left:8px; opacity:0.5; font-size:0.75rem;">${edge.target}</span>`;
childItem.onclick = (ev) => {
ev.stopPropagation();
ensurePathVisible(edge.target);
if (graph.hasEdge(edge.id)) {
clearHighlight();
setHighlight([edge.source, edge.target]);
centerOnNode(edge.target, 0.3);
showPathDetails(makeEdgeShim(edge));
} else {
handleNodeClick(edge.target);
}
document.querySelectorAll('.tree-item').forEach(i => i.classList.remove('selected'));
childItem.classList.add('selected');
};
childrenContainer.appendChild(childNodeWrapper);
});
};
if (isExpanded) populateChildren();
item.onclick = (e) => {
e.stopPropagation();
ensurePathVisible(nodeId);
clearHighlight();
addHighlight(nodeId);
centerOnNode(nodeId, 1.2);
handleNodeClick(nodeId);
document.querySelectorAll('.tree-item').forEach(i => i.classList.remove('selected'));
item.classList.add('selected');
};
if (hasChildren) {
toggle.onclick = (e) => {
e.stopPropagation();
const nowCollapsed = toggle.classList.toggle('collapsed');
childrenContainer.style.display = nowCollapsed ? 'none' : 'block';
if (!nowCollapsed) populateChildren();
};
}
nodeEl.appendChild(item);
nodeEl.appendChild(childrenContainer);
return nodeEl;
}
function renderFindingsList() {
const findingsList = document.getElementById('findings-list');
findingsList.innerHTML = '';
findingsData.forEach(f => {
const item = document.createElement('div');
item.className = 'finding-item';
item.innerHTML = `
<span class="tag tag-${f.severity.toLowerCase()}">${f.severity}</span>
<strong>${f.title}</strong>
<div style="font-size: 0.8rem; color: var(--muted); margin-top: 3px;">${f.id} - ${f.affected.length} affected</div>
`;
item.onclick = () => showDetails(f.id);
findingsList.appendChild(item);
});
}
switchTab('findings');
renderFindingsList();
if (renderer) {
renderer.on('clickStage', function(){
showSummary();
});
}
function showNodeQueries(node) {
const typeName = node.id();
const kind = node.data('kind');
const incomingEdges = masterEdges.filter(e => {
return e.target === typeName && graph.hasEdge(e.id);
});
document.getElementById('summary-pane').style.display = 'none';
document.getElementById('details-pane').style.display = 'block';
let html = `
<div class="card">
<button class="back-btn" onclick="showSummary()">← Back to list</button>
<h3>Type Details: ${typeName}</h3>
<div style="display: flex; gap: 8px; margin-top: 10px;">
<span class="tag tag-info">${kind}</span>
<span class="tag tag-low">${incomingEdges.length} Visible Connections</span>
</div>
</div>
`;
if (incomingEdges.length > 0) {
html += `<h4 style="margin-top: 20px;">Reproduction Queries (Visible Context)</h4>`;
incomingEdges.forEach((edgeData, i) => {
try {
const q = getQueryForEdge(edgeData);
html += renderQueryCard(q, i);
} catch (e) {
console.error("Failed to generate query for edge:", e);
}
});
} else {
html += `<p style="color: var(--muted); margin-top: 20px; font-style: italic;">No active discovery paths lead to this node in the current view.</p>`;
}
const relevantFindings = findingsData.filter(f => f.affected.some(aff => aff.startsWith(typeName)));
if (relevantFindings.length > 0) {
html += `<h4 style="margin-top: 20px;">Related Security Findings</h4>`;
relevantFindings.forEach(f => {
html += `
<div class="finding-item" onclick="showDetails('${f.id}')">
<span class="tag tag-${f.severity.toLowerCase()}">${f.severity}</span>
<strong>${f.title}</strong>
</div>
`;
});
}
document.getElementById('details-content').innerHTML = html;
}
function selectNode(nodeId) {
const nodeObj = masterNodes.find(n => n.id === nodeId);
if (!nodeObj) return;
const neighborhood = [nodeId];
if (graph.hasNode(nodeId)) {
graph.forEachNeighbor(nodeId, (neighbor) => neighborhood.push(neighbor));
}
setHighlight(neighborhood);
const treeItem = document.querySelector(`.tree-item[data-id="${nodeId}"]`);
if (treeItem) {
switchTab('schema');
document.querySelectorAll('.tree-item').forEach(i => i.classList.remove('selected'));
treeItem.classList.add('selected');
treeItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
if (nodeObj.kind === 'SCALAR' || nodeObj.kind === 'ENUM') {
showNodeQueries(makeNodeShim(nodeObj));
} else {
const relevantFindings = findingsData.filter(f => f.affected.some(aff => aff.startsWith(nodeId)));
if (relevantFindings.length > 0) {
showDetails(relevantFindings[0].id);
}
}
}
function handleNodeClick(nodeId) {
const nodeObj = masterNodes.find(n => n.id === nodeId);
if (!nodeObj) return;
if (isolateMode) {
performIsolation(nodeId);
} else {
expandNode(nodeId);
}
selectNode(nodeId);
}
if (renderer) {
renderer.on('clickNode', ({ node }) => {
if (didDrag) { didDrag = false; return; }
selectNode(node);
});
renderer.on('doubleClickNode', (e) => {
if (e.preventSigmaDefault) e.preventSigmaDefault();
if (isolateMode) {
performIsolation(e.node);
} else {
expandNode(e.node);
}
selectNode(e.node);
});
renderer.on('clickEdge', ({ edge }) => {
const edgeData = masterEdges.find(e => e.id === edge);
if (edgeData) showPathDetails(makeEdgeShim(edgeData));
});
renderer.on('enterNode', ({ node }) => {
hoveredNode = node;
const set = new Set([node]);
graph.forEachNeighbor(node, (n) => set.add(n));
highlightedNodes = set;
renderer.refresh();
});
renderer.on('leaveNode', () => {
hoveredNode = null;
highlightedNodes = new Set();
renderer.refresh();
});
renderer.on('rightClickNode', (e) => {
if (e.preventSigmaDefault) e.preventSigmaDefault();
if (e.event && e.event.original) e.event.original.preventDefault();
selectedNodeId = e.node;
const menu = document.getElementById('ctx-menu');
menu.style.display = 'block';
const nativeEvt = e.event && e.event.original;
if (nativeEvt) {
menu.style.left = nativeEvt.clientX + 'px';
menu.style.top = nativeEvt.clientY + 'px';
}
});
renderer.on('downNode', (e) => {
didDrag = false;
draggedNode = e.node;
graph.setNodeAttribute(draggedNode, 'highlighted', true);
});
renderer.getMouseCaptor().on('mousedown', () => {
if (!renderer.getCustomBBox()) renderer.setCustomBBox(renderer.getBBox());
});
renderer.getMouseCaptor().on('mousemovebody', (e) => {
if (!draggedNode) return;
didDrag = true;
const pos = renderer.viewportToGraph(e);
graph.setNodeAttribute(draggedNode, 'x', pos.x);
graph.setNodeAttribute(draggedNode, 'y', pos.y);
if (e.preventSigmaDefault) e.preventSigmaDefault();
if (e.original) {
e.original.preventDefault();
e.original.stopPropagation();
}
});
renderer.getMouseCaptor().on('mouseup', () => {
if (draggedNode) {
graph.removeNodeAttribute(draggedNode, 'highlighted');
}
draggedNode = null;
renderer.setCustomBBox(null);
});
}
window.addEventListener('keydown', (e) => {
const active = document.activeElement;
const isTyping = active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable);
if (isTyping) return;
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z' && !e.shiftKey) {
e.preventDefault();
undoGraph();
} else if ((e.ctrlKey || e.metaKey) && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) {
e.preventDefault();
redoGraph();
}
});
window.openValueDropdown = function(element, event) {
event.stopPropagation();
const field = element.getAttribute('data-field');
const fieldSeeds = seedsData.filter(s => s.field_name === field);
if (fieldSeeds.length <= 1) return;
document.querySelectorAll('.value-dropdown').forEach(d => d.remove());
const dropdown = document.createElement('div');
dropdown.className = 'value-dropdown';
fieldSeeds.forEach(s => {
const item = document.createElement('div');
item.className = 'dropdown-item';
let val = s.value;
if (element.innerText.startsWith('"')) val = `"${val}"`;
item.innerText = val;
item.onclick = () => {
element.innerText = val;
dropdown.remove();
};
dropdown.appendChild(item);
});
document.body.appendChild(dropdown);
const rect = element.getBoundingClientRect();
dropdown.style.top = `${rect.bottom + window.scrollY}px`;
dropdown.style.left = `${rect.left + window.scrollX}px`;
const closer = () => {
dropdown.remove();
document.removeEventListener('click', closer);
};
setTimeout(() => document.addEventListener('click', closer), 10);
};
const seedsList = document.getElementById('seeds-list');
if (seedsData.length > 0) {
seedsList.innerHTML = '';
const groups = {};
seedsData.forEach(s => {
if (!groups[s.field_name]) groups[s.field_name] = [];
groups[s.field_name].push(s);
});
Object.entries(groups).forEach(([field, seeds]) => {
const group = document.createElement('div');
group.className = 'seed-group';
group.innerHTML = `<h4>${field}</h4>`;
seeds.forEach(s => {
const item = document.createElement('div');
item.className = 'seed-item';
item.innerText = s.value;
item.title = `Source: ${s.source}`;
group.appendChild(item);
});
seedsList.appendChild(group);
});
}
resetGraph();
</script>
</body>
</html>