1 parent
a07b8be38a
commit
3af888cfa4
56 files changed
+968
No files matched your search
@@ -0,0 +1,474 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>graphify - graphify-out/graph.html</title>
|
||||
<script src="https://unpkg.com/vis-network@9.1.6/standalone/umd/vis-network.min.js"
|
||||
integrity="sha384-Ux6phic9PEHJ38YtrijhkzyJ8yQlH8i/+buBR8s3mAZOJrP1gwyvAcIYl3GWtpX1"
|
||||
crossorigin="anonymous"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f0f1a; --panel: #1a1a2e; --border: #2a2a4e; --input-border: #3a3a5e;
|
||||
--text: #e0e0e0; --text-2: #aaa; --text-3: #ccc; --muted: #555; --count: #666;
|
||||
--neighbor-border: #333; --node-label: #ffffff;
|
||||
}
|
||||
[data-theme="light"] {
|
||||
--bg: #ffffff; --panel: #f8f9fa; --border: #d9dee5; --input-border: #c3cad4;
|
||||
--text: #222222; --text-2: #666666; --text-3: #333333; --muted: #888888; --count: #777777;
|
||||
--neighbor-border: #ccc; --node-label: #222222;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; display: flex; height: 100vh; overflow: hidden; }
|
||||
#graph { flex: 1; }
|
||||
#sidebar { width: 280px; background: var(--panel); border-left: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
|
||||
#search-wrap { padding: 12px; border-bottom: 1px solid var(--border); }
|
||||
#search { width: 100%; background: var(--bg); border: 1px solid var(--input-border); color: var(--text); padding: 7px 10px; border-radius: 6px; font-size: 13px; outline: none; }
|
||||
#search:focus { border-color: #4E79A7; }
|
||||
#search-results { max-height: 140px; overflow-y: auto; padding: 4px 12px; border-bottom: 1px solid var(--border); display: none; }
|
||||
.search-item { padding: 4px 6px; cursor: pointer; border-radius: 4px; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.search-item:hover { background: var(--border); }
|
||||
#info-panel { padding: 14px; border-bottom: 1px solid var(--border); min-height: 140px; }
|
||||
#info-panel h3 { font-size: 13px; color: var(--text-2); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
#info-content { font-size: 13px; color: var(--text-3); line-height: 1.6; }
|
||||
#info-content .field { margin-bottom: 5px; }
|
||||
#info-content .field b { color: var(--text); }
|
||||
#info-content .empty { color: var(--muted); font-style: italic; }
|
||||
.neighbor-link { display: block; padding: 2px 6px; margin: 2px 0; border-radius: 3px; cursor: pointer; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-left: 3px solid var(--neighbor-border); }
|
||||
.neighbor-link:hover { background: var(--border); }
|
||||
#neighbors-list { max-height: 160px; overflow-y: auto; margin-top: 4px; }
|
||||
#legend-wrap { flex: 1; overflow-y: auto; padding: 12px; }
|
||||
#legend-wrap h3 { font-size: 13px; color: var(--text-2); margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.legend-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; cursor: pointer; border-radius: 4px; font-size: 12px; }
|
||||
.legend-item:hover { background: var(--border); padding-left: 4px; }
|
||||
.legend-item.dimmed { opacity: 0.35; }
|
||||
.legend-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
|
||||
.legend-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.legend-count { color: var(--count); font-size: 11px; }
|
||||
#stats { padding: 10px 14px; border-top: 1px solid var(--border); font-size: 11px; color: var(--muted); }
|
||||
#legend-controls { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; padding: 4px 0; }
|
||||
#legend-controls label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px; color: var(--text-2); user-select: none; }
|
||||
#legend-controls label:hover { color: var(--text); }
|
||||
.legend-cb, #select-all-cb { appearance: none; -webkit-appearance: none; width: 14px; height: 14px; border: 1.5px solid var(--input-border); border-radius: 3px; background: var(--bg); cursor: pointer; position: relative; flex-shrink: 0; }
|
||||
.legend-cb:checked, #select-all-cb:checked { background: #4E79A7; border-color: #4E79A7; }
|
||||
.legend-cb:checked::after, #select-all-cb:checked::after { content: ''; position: absolute; left: 3.5px; top: 1px; width: 4px; height: 7px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); }
|
||||
#select-all-cb:indeterminate { background: #4E79A7; border-color: #4E79A7; }
|
||||
#select-all-cb:indeterminate::after { content: ''; position: absolute; left: 2px; top: 5px; width: 8px; height: 2px; background: #fff; border: none; transform: none; }
|
||||
#theme-wrap { display: flex; gap: 4px; padding: 8px 12px; border-bottom: 1px solid var(--border); }
|
||||
#theme-wrap button { flex: 1; background: var(--bg); color: var(--text-2); border: 1px solid var(--input-border); border-radius: 4px; padding: 3px 0; font-size: 11px; cursor: pointer; }
|
||||
#theme-wrap button:hover { color: var(--text); }
|
||||
#theme-wrap button[aria-pressed="true"] { background: #4E79A7; border-color: #4E79A7; color: #fff; }
|
||||
</style>
|
||||
<script id="graphify-theme-init">
|
||||
// Apply the saved theme before first paint (#3894). No saved choice keeps the
|
||||
// historical dark look; "system" follows prefers-color-scheme.
|
||||
(function () {
|
||||
var pref = null;
|
||||
try { pref = localStorage.getItem('graphify-theme'); } catch (e) {}
|
||||
var t = pref === 'light' ? 'light'
|
||||
: pref === 'system' && window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light'
|
||||
: 'dark';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="graph"></div>
|
||||
<div id="sidebar">
|
||||
<div id="theme-wrap" role="group" aria-label="Theme">
|
||||
<button type="button" data-theme-choice="light">Light</button>
|
||||
<button type="button" data-theme-choice="dark">Dark</button>
|
||||
<button type="button" data-theme-choice="system">System</button>
|
||||
</div>
|
||||
<div id="search-wrap">
|
||||
<input id="search" type="text" placeholder="Search nodes..." autocomplete="off">
|
||||
<div id="search-results"></div>
|
||||
</div>
|
||||
<div id="info-panel">
|
||||
<h3>Node Info</h3>
|
||||
<div id="info-content"><span class="empty">Click a node to inspect it</span></div>
|
||||
</div>
|
||||
<div id="legend-wrap">
|
||||
<h3>Communities</h3>
|
||||
<div id="legend-controls">
|
||||
<label><input type="checkbox" id="select-all-cb" checked onchange="toggleAllCommunities(!this.checked)">Select All</label>
|
||||
</div>
|
||||
<div id="legend"></div>
|
||||
</div>
|
||||
<div id="stats">132 nodes · 189 edges · 17 communities</div>
|
||||
</div>
|
||||
<script>
|
||||
const RAW_NODES = [{"id": "deploy", "label": "deploy.ps1", "color": {"background": "#59A14F", "border": "#59A14F", "highlight": {"background": "#ffffff", "border": "#59A14F"}}, "size": 11.6, "font": {"size": 0, "color": "#ffffff"}, "title": "deploy.ps1", "community": 14, "community_name": "Deploy-Skript", "source_file": "deploy.ps1", "file_type": "code", "degree": 1}, {"id": "deploy_assert_ok", "label": "Assert-Ok()", "color": {"background": "#59A14F", "border": "#59A14F", "highlight": {"background": "#ffffff", "border": "#59A14F"}}, "size": 11.6, "font": {"size": 0, "color": "#ffffff"}, "title": "Assert-Ok()", "community": 14, "community_name": "Deploy-Skript", "source_file": "deploy.ps1", "file_type": "code", "degree": 1}, {"id": "assets_js_site", "label": "site.js", "color": {"background": "#EDC948", "border": "#EDC948", "highlight": {"background": "#ffffff", "border": "#EDC948"}}, "size": 10.0, "font": {"size": 0, "color": "#ffffff"}, "title": "site.js", "community": 15, "community_name": "Spotify Loader (site.js)", "source_file": "assets/js/site.js", "file_type": "code", "degree": 0}, {"id": "epk_build", "label": "build.ps1", "color": {"background": "#B07AA1", "border": "#B07AA1", "highlight": {"background": "#ffffff", "border": "#B07AA1"}}, "size": 10.0, "font": {"size": 0, "color": "#ffffff"}, "title": "build.ps1", "community": 16, "community_name": "EPK PDF Build", "source_file": "epk/build.ps1", "file_type": "code", "degree": 0}, {"id": "gitea_workflows_deploy", "label": "deploy.yml", "color": {"background": "#E15759", "border": "#E15759", "highlight": {"background": "#ffffff", "border": "#E15759"}}, "size": 14.7, "font": {"size": 12, "color": "#ffffff"}, "title": "deploy.yml", "community": 2, "community_name": "Gitea Deploy Workflow", "source_file": ".gitea/workflows/deploy.yml", "file_type": "code", "degree": 3}, {"id": "gitea_workflows_deploy_container_image", "label": "image", "color": {"background": "#E15759", "border": "#E15759", "highlight": {"background": "#ffffff", "border": "#E15759"}}, "size": 11.6, "font": {"size": 0, "color": "#ffffff"}, "title": "image", "community": 2, "community_name": "Gitea Deploy Workflow", "source_file": ".gitea/workflows/deploy.yml", "file_type": "code", "degree": 1}, {"id": "gitea_workflows_deploy_deploy_container", "label": "container", "color": {"background": "#E15759", "border": "#E15759", "highlight": {"background": "#ffffff", "border": "#E15759"}}, "size": 13.2, "font": {"size": 0, "color": "#ffffff"}, "title": "container", "community": 2, "community_name": "Gitea Deploy Workflow", "source_file": ".gitea/workflows/deploy.yml", "file_type": "code", "degree": 2}, {"id": "gitea_workflows_deploy_deploy_env", "label": "env", "color": {"background": "#E15759", "border": "#E15759", "highlight": {"background": "#ffffff", "border": "#E15759"}}, "size": 16.3, "font": {"size": 12, "color": "#ffffff"}, "title": "env", "community": 2, "community_name": "Gitea Deploy Workflow", "source_file": ".gitea/workflows/deploy.yml", "file_type": "code", "degree": 4}, {"id": "gitea_workflows_deploy_deploy_runs_on", "label": "runs-on", "color": {"background": "#E15759", "border": "#E15759", "highlight": {"background": "#ffffff", "border": "#E15759"}}, "size": 11.6, "font": {"size": 0, "color": "#ffffff"}, "title": "runs-on", "community": 2, "community_name": "Gitea Deploy Workflow", "source_file": ".gitea/workflows/deploy.yml", "file_type": "code", "degree": 1}, {"id": "gitea_workflows_deploy_deploy_steps", "label": "steps", "color": {"background": "#E15759", "border": "#E15759", "highlight": {"background": "#ffffff", "border": "#E15759"}}, "size": 16.3, "font": {"size": 12, "color": "#ffffff"}, "title": "steps", "community": 2, "community_name": "Gitea Deploy Workflow", "source_file": ".gitea/workflows/deploy.yml", "file_type": "code", "degree": 4}, {"id": "gitea_workflows_deploy_env_http_proxy", "label": "http_proxy", "color": {"background": "#E15759", "border": "#E15759", "highlight": {"background": "#ffffff", "border": "#E15759"}}, "size": 11.6, "font": {"size": 0, "color": "#ffffff"}, "title": "http_proxy", "community": 2, "community_name": "Gitea Deploy Workflow", "source_file": ".gitea/workflows/deploy.yml", "file_type": "code", "degree": 1}, {"id": "gitea_workflows_deploy_env_https_proxy", "label": "https_proxy", "color": {"background": "#E15759", "border": "#E15759", "highlight": {"background": "#ffffff", "border": "#E15759"}}, "size": 11.6, "font": {"size": 0, "color": "#ffffff"}, "title": "https_proxy", "community": 2, "community_name": "Gitea Deploy Workflow", "source_file": ".gitea/workflows/deploy.yml", "file_type": "code", "degree": 1}, {"id": "gitea_workflows_deploy_env_no_proxy", "label": "no_proxy", "color": {"background": "#E15759", "border": "#E15759", "highlight": {"background": "#ffffff", "border": "#E15759"}}, "size": 11.6, "font": {"size": 0, "color": "#ffffff"}, "title": "no_proxy", "community": 2, "community_name": "Gitea Deploy Workflow", "source_file":Line truncated
|
||||
const RAW_EDGES = [{"from": "deploy", "to": "deploy_assert_ok", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "gitea_workflows_deploy", "to": "gitea_workflows_deploy_name", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "gitea_workflows_deploy", "to": "gitea_workflows_deploy_on", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "gitea_workflows_deploy", "to": "gitea_workflows_deploy_jobs", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "gitea_workflows_deploy_deploy_container", "to": "gitea_workflows_deploy_container_image", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "gitea_workflows_deploy_jobs_deploy", "to": "gitea_workflows_deploy_deploy_container", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "gitea_workflows_deploy_deploy_env", "to": "gitea_workflows_deploy_env_http_proxy", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "gitea_workflows_deploy_deploy_env", "to": "gitea_workflows_deploy_env_https_proxy", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "gitea_workflows_deploy_deploy_env", "to": "gitea_workflows_deploy_env_no_proxy", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "gitea_workflows_deploy_jobs_deploy", "to": "gitea_workflows_deploy_deploy_env", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "gitea_workflows_deploy_jobs_deploy", "to": "gitea_workflows_deploy_deploy_runs_on", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "gitea_workflows_deploy_deploy_steps", "to": "gitea_workflows_deploy_steps_uses", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "gitea_workflows_deploy_deploy_steps", "to": "gitea_workflows_deploy_steps_name", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "gitea_workflows_deploy_deploy_steps", "to": "gitea_workflows_deploy_steps_run", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "gitea_workflows_deploy_jobs_deploy", "to": "gitea_workflows_deploy_deploy_steps", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "gitea_workflows_deploy_jobs", "to": "gitea_workflows_deploy_jobs_deploy", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "gitea_workflows_deploy_on", "to": "gitea_workflows_deploy_on_push", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "gitea_workflows_deploy_on_push", "to": "gitea_workflows_deploy_push_branches", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "assets_css_main_design_tokens", "to": "assets_css_main", "label": "conceptually_related_to", "title": "conceptually_related_to [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "assets_css_main_flyer_wall", "to": "assets_css_main", "label": "conceptually_related_to", "title": "conceptually_related_to [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "layouts__default_baseof", "to": "assets_css_main", "label": "references", "title": "references [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "layouts_404", "to": "layouts__default_baseof", "label": "implements", "title": "implements [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "layouts__default_band", "to": "layouts_partials_img", "label": "calls", "title": "callsLine truncated
|
||||
const LEGEND = [{"cid": 0, "color": "#4E79A7", "label": "Templates & Design", "count": 29}, {"cid": 1, "color": "#F28E2B", "label": "Band, EPK & Regeln", "count": 27}, {"cid": 2, "color": "#E15759", "label": "Gitea Deploy Workflow", "count": 18}, {"cid": 3, "color": "#76B7B2", "label": "Datenschutz & Impressum", "count": 9}, {"cid": 4, "color": "#59A14F", "label": "Show-Schema & Mosel-Gigs", "count": 8}, {"cid": 5, "color": "#EDC948", "label": "Shows \u2194 EPK Sync", "count": 7}, {"cid": 6, "color": "#B07AA1", "label": "Pink Future & Skatehalle", "count": 7}, {"cid": 7, "color": "#FF9DA7", "label": "Trierer Umland-Shows", "count": 7}, {"cid": 8, "color": "#9C755F", "label": "Rock im Gehege", "count": 4}, {"cid": 9, "color": "#BAB0AC", "label": "Tefftival", "count": 4}, {"cid": 10, "color": "#4E79A7", "label": "Album Release Party", "count": 2}, {"cid": 11, "color": "#F28E2B", "label": "Riez Kapellen-Contest", "count": 2}, {"cid": 12, "color": "#E15759", "label": "Altstadtfest Trier", "count": 2}, {"cid": 13, "color": "#76B7B2", "label": "Halloween Rock", "count": 2}, {"cid": 14, "color": "#59A14F", "label": "Deploy-Skript", "count": 2}, {"cid": 15, "color": "#EDC948", "label": "Spotify Loader (site.js)", "count": 1}, {"cid": 16, "color": "#B07AA1", "label": "EPK PDF Build", "count": 1}];
|
||||
|
||||
// HTML-escape helper — prevents XSS when injecting graph data into innerHTML
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
|
||||
// Build vis datasets
|
||||
const nodesDS = new vis.DataSet(RAW_NODES.map((n, i) => ({
|
||||
id: n.id, label: n.label, color: n.color, size: n.size,
|
||||
font: n.font, title: n.title,
|
||||
// Fermat/golden-angle spiral seed positions (#3699): spreading nodes out
|
||||
// before physics runs keeps avoidOverlap from computing near-zero-distance
|
||||
// repulsion that blows the BarnesHut recursion into a stack overflow on large
|
||||
// graphs. `i` is the map index — physics still settles small graphs identically.
|
||||
x: 30 * Math.sqrt(i) * Math.cos(i * 2.4),
|
||||
y: 30 * Math.sqrt(i) * Math.sin(i * 2.4),
|
||||
community: n.community, community_name: n.community_name,
|
||||
source_file: n.source_file, file_type: n.file_type, degree: n.degree,
|
||||
member_count: n.member_count,
|
||||
})));
|
||||
|
||||
const edgesDS = new vis.DataSet(RAW_EDGES.map((e, i) => ({
|
||||
id: i, from: e.from, to: e.to,
|
||||
label: '',
|
||||
title: e.title,
|
||||
dashes: e.dashes,
|
||||
width: e.width,
|
||||
color: e.color,
|
||||
arrows: { to: { enabled: true, scaleFactor: 0.5 } },
|
||||
})));
|
||||
|
||||
const container = document.getElementById('graph');
|
||||
const network = new vis.Network(container, { nodes: nodesDS, edges: edgesDS }, {
|
||||
physics: {
|
||||
enabled: true,
|
||||
solver: 'forceAtlas2Based',
|
||||
forceAtlas2Based: {
|
||||
gravitationalConstant: -60,
|
||||
centralGravity: 0.005,
|
||||
springLength: 120,
|
||||
springConstant: 0.08,
|
||||
damping: 0.4,
|
||||
avoidOverlap: 0.8,
|
||||
},
|
||||
stabilization: { iterations: 200, fit: true },
|
||||
},
|
||||
interaction: {
|
||||
hover: true,
|
||||
tooltipDelay: 100,
|
||||
hideEdgesOnDrag: true,
|
||||
navigationButtons: false,
|
||||
keyboard: false,
|
||||
},
|
||||
nodes: { shape: 'dot', borderWidth: 1.5 },
|
||||
edges: { smooth: { type: 'continuous', roundness: 0.2 }, selectionWidth: 3 },
|
||||
});
|
||||
|
||||
// Theme toggle (#3894): Light / Dark / System, persisted in localStorage.
|
||||
const THEME_KEY = 'graphify-theme';
|
||||
const themeMQ = window.matchMedia ? window.matchMedia('(prefers-color-scheme: light)') : null;
|
||||
let themeLabelColor = '#ffffff'; // matches the per-node font color set in Python
|
||||
function themePref() {
|
||||
let p = null;
|
||||
try { p = localStorage.getItem(THEME_KEY); } catch (e) {}
|
||||
return (p === 'light' || p === 'dark' || p === 'system') ? p : null;
|
||||
}
|
||||
function applyTheme(pref) {
|
||||
const t = pref === 'light' ? 'light' : (pref === 'system' && themeMQ && themeMQ.matches) ? 'light' : 'dark';
|
||||
const root = document.documentElement;
|
||||
if (!root) return;
|
||||
root.setAttribute('data-theme', t);
|
||||
document.querySelectorAll('[data-theme-choice]').forEach(b => {
|
||||
b.setAttribute('aria-pressed', String(b.dataset.themeChoice === (pref || 'dark')));
|
||||
});
|
||||
const labelColor = getComputedStyle(root).getPropertyValue('--node-label').trim();
|
||||
if (labelColor && labelColor !== themeLabelColor) {
|
||||
themeLabelColor = labelColor;
|
||||
nodesDS.update(nodesDS.get().map(n => ({ id: n.id, font: Object.assign({}, n.font, { color: labelColor }) })));
|
||||
}
|
||||
}
|
||||
document.querySelectorAll('[data-theme-choice]').forEach(b => {
|
||||
b.addEventListener('click', () => {
|
||||
const pref = b.dataset.themeChoice;
|
||||
try { localStorage.setItem(THEME_KEY, pref); } catch (e) {}
|
||||
applyTheme(pref);
|
||||
});
|
||||
});
|
||||
if (themeMQ) {
|
||||
const onSchemeChange = () => { if (themePref() === 'system') applyTheme('system'); };
|
||||
if (themeMQ.addEventListener) themeMQ.addEventListener('change', onSchemeChange);
|
||||
else if (themeMQ.addListener) themeMQ.addListener(onSchemeChange);
|
||||
}
|
||||
applyTheme(themePref());
|
||||
|
||||
network.once('stabilizationIterationsDone', () => {
|
||||
network.setOptions({ physics: { enabled: false } });
|
||||
});
|
||||
|
||||
function showInfo(nodeId) {
|
||||
const n = nodesDS.get(nodeId);
|
||||
if (!n) return;
|
||||
const neighborIds = network.getConnectedNodes(nodeId);
|
||||
const neighborItems = neighborIds.map(nid => {
|
||||
const nb = nodesDS.get(nid);
|
||||
const color = nb ? nb.color.background : '#555';
|
||||
return `<span class="neighbor-link" style="border-left-color:${esc(color)}" data-nid="${esc(nid)}">${esc(nb ? nb.label : nid)}</span>`;
|
||||
}).join('');
|
||||
const isMetaNode = !n.file_type && !n.source_file;
|
||||
let html = `<div class="field"><b>${esc(n.label)}</b></div>`;
|
||||
if (!isMetaNode) {
|
||||
html += `<div class="field">Type: ${esc(n.file_type || 'unknown')}</div>`;
|
||||
}
|
||||
html += `<div class="field">Community: ${esc(n.community_name || '')}</div>`;
|
||||
if (!isMetaNode) {
|
||||
html += `<div class="field">Source: ${esc(n.source_file || '-')}</div>`;
|
||||
} else if (n.member_count !== undefined && n.member_count !== null) {
|
||||
html += `<div class="field">Members: ${esc(n.member_count)}</div>`;
|
||||
}
|
||||
html += `<div class="field">Degree: ${n.degree}</div>`;
|
||||
if (neighborIds.length) {
|
||||
html += `<div class="field" style="margin-top:8px;color:var(--text-2);font-size:11px">Neighbors (${neighborIds.length})</div><div id="neighbors-list">${neighborItems}</div>`;
|
||||
}
|
||||
document.getElementById('info-content').innerHTML = html;
|
||||
}
|
||||
|
||||
function focusNode(nodeId) {
|
||||
network.focus(nodeId, { scale: 1.4, animation: true });
|
||||
network.selectNodes([nodeId]);
|
||||
showInfo(nodeId);
|
||||
}
|
||||
|
||||
// Neighbor links use a data attribute + one delegated listener rather than an
|
||||
// inline onclick. A node id/label sourced from a document or a scraped URL
|
||||
// (graphify add) can contain a double-quote; dropping the stringified id
|
||||
// unescaped into a quoted onclick both broke every link and allowed a hostile
|
||||
// source to inject an event handler into the local report (stored XSS, #1838).
|
||||
// esc() on data-nid keeps the value inside the attribute; the listener reads it
|
||||
// back verbatim. Bound to document so it survives the innerHTML rebuild that
|
||||
// recreates #neighbors-list on each showInfo().
|
||||
document.addEventListener('click', e => {
|
||||
const el = e.target.closest('.neighbor-link');
|
||||
if (el && el.dataset.nid !== undefined) focusNode(el.dataset.nid);
|
||||
});
|
||||
|
||||
// Sidebar tooltips reuse vis-network's .vis-tooltip style and 100ms tooltipDelay.
|
||||
// Names get one only when CSS ellipsis truncates them (the full text is still in
|
||||
// the DOM); counts always get one, to say what the number is. Text is set with
|
||||
// textContent only (labels are untrusted). Delegated on document so rows rebuilt
|
||||
// later (search results, showInfo()) are covered.
|
||||
const sidebarTip = document.createElement('div');
|
||||
sidebarTip.className = 'vis-tooltip';
|
||||
sidebarTip.style.cssText = 'white-space: normal; max-width: 360px; overflow-wrap: break-word;';
|
||||
document.body.appendChild(sidebarTip);
|
||||
let sidebarTipTimer = null;
|
||||
let sidebarTipEl = null; // element whose text the tooltip shows
|
||||
let sidebarTipZone = null; // element the pointer must leave to hide it
|
||||
function hideSidebarTip() {
|
||||
clearTimeout(sidebarTipTimer);
|
||||
sidebarTip.style.visibility = 'hidden';
|
||||
sidebarTip.textContent = '';
|
||||
sidebarTipEl = sidebarTipZone = null;
|
||||
}
|
||||
document.addEventListener('mouseover', e => {
|
||||
const zone = e.target.closest && e.target.closest('.legend-count, .legend-item, .search-item, .neighbor-link');
|
||||
// Anywhere on a legend row except its count (dot, checkbox, gaps, padding)
|
||||
// stands for the row's name, so moving within the row never re-triggers it.
|
||||
const el = zone && zone.classList.contains('legend-item') ? zone.querySelector('.legend-label') : zone;
|
||||
if (!el || el === sidebarTipEl) return;
|
||||
hideSidebarTip();
|
||||
const isCount = el.classList.contains('legend-count');
|
||||
if (!isCount && el.scrollWidth <= el.clientWidth) return;
|
||||
const text = isCount ? `${el.textContent} ${el.textContent === '1' ? 'node' : 'nodes'}` : el.textContent;
|
||||
sidebarTipEl = el;
|
||||
sidebarTipZone = zone;
|
||||
const x = e.clientX, y = e.clientY;
|
||||
sidebarTipTimer = setTimeout(() => {
|
||||
sidebarTip.textContent = text;
|
||||
sidebarTip.style.left = sidebarTip.style.top = '0px'; // measure at full max-width
|
||||
sidebarTip.style.visibility = 'visible';
|
||||
const w = sidebarTip.offsetWidth, h = sidebarTip.offsetHeight;
|
||||
sidebarTip.style.left = Math.max(4, Math.min(x + 12, window.innerWidth - w - 4)) + 'px';
|
||||
sidebarTip.style.top = (y + 16 + h > window.innerHeight ? Math.max(4, y - h - 8) : y + 16) + 'px';
|
||||
}, 100);
|
||||
});
|
||||
document.addEventListener('mouseout', e => {
|
||||
if (sidebarTipZone && !sidebarTipZone.contains(e.relatedTarget)) hideSidebarTip();
|
||||
});
|
||||
// A click rebuilds or hides the row (showInfo(), search pick) without a mouseout.
|
||||
document.addEventListener('mousedown', hideSidebarTip);
|
||||
|
||||
// Track hovered node — hover detection is more reliable than click params
|
||||
let hoveredNodeId = null;
|
||||
network.on('hoverNode', params => {
|
||||
hoveredNodeId = params.node;
|
||||
container.style.cursor = 'pointer';
|
||||
});
|
||||
network.on('blurNode', () => {
|
||||
hoveredNodeId = null;
|
||||
container.style.cursor = 'default';
|
||||
});
|
||||
container.addEventListener('click', () => {
|
||||
if (hoveredNodeId !== null) {
|
||||
showInfo(hoveredNodeId);
|
||||
network.selectNodes([hoveredNodeId]);
|
||||
}
|
||||
});
|
||||
network.on('click', params => {
|
||||
if (params.nodes.length > 0) {
|
||||
showInfo(params.nodes[0]);
|
||||
} else if (hoveredNodeId === null) {
|
||||
document.getElementById('info-content').innerHTML = '<span class="empty">Click a node to inspect it</span>';
|
||||
}
|
||||
});
|
||||
|
||||
const searchInput = document.getElementById('search');
|
||||
const searchResults = document.getElementById('search-results');
|
||||
searchInput.addEventListener('input', () => {
|
||||
const q = searchInput.value.toLowerCase().trim();
|
||||
searchResults.innerHTML = '';
|
||||
if (!q) { searchResults.style.display = 'none'; return; }
|
||||
const matches = RAW_NODES.filter(n => n.label.toLowerCase().includes(q)).slice(0, 20);
|
||||
if (!matches.length) { searchResults.style.display = 'none'; return; }
|
||||
searchResults.style.display = 'block';
|
||||
matches.forEach(n => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'search-item';
|
||||
el.textContent = n.label;
|
||||
el.style.borderLeft = `3px solid ${n.color.background}`;
|
||||
el.style.paddingLeft = '8px';
|
||||
el.onclick = () => {
|
||||
network.focus(n.id, { scale: 1.5, animation: true });
|
||||
network.selectNodes([n.id]);
|
||||
showInfo(n.id);
|
||||
searchResults.style.display = 'none';
|
||||
searchInput.value = '';
|
||||
};
|
||||
searchResults.appendChild(el);
|
||||
});
|
||||
});
|
||||
document.addEventListener('click', e => {
|
||||
if (!searchResults.contains(e.target) && e.target !== searchInput)
|
||||
searchResults.style.display = 'none';
|
||||
});
|
||||
|
||||
const hiddenCommunities = new Set();
|
||||
|
||||
const selectAllCb = document.getElementById('select-all-cb');
|
||||
|
||||
function updateSelectAllState() {
|
||||
const total = LEGEND.length;
|
||||
const hidden = hiddenCommunities.size;
|
||||
selectAllCb.checked = hidden === 0;
|
||||
selectAllCb.indeterminate = hidden > 0 && hidden < total;
|
||||
}
|
||||
|
||||
function toggleAllCommunities(hide) {
|
||||
document.querySelectorAll('.legend-item').forEach(item => {
|
||||
hide ? item.classList.add('dimmed') : item.classList.remove('dimmed');
|
||||
});
|
||||
document.querySelectorAll('.legend-cb').forEach(cb => {
|
||||
cb.checked = !hide;
|
||||
});
|
||||
LEGEND.forEach(c => {
|
||||
if (hide) hiddenCommunities.add(c.cid); else hiddenCommunities.delete(c.cid);
|
||||
});
|
||||
const updates = RAW_NODES.map(n => ({ id: n.id, hidden: hide }));
|
||||
nodesDS.update(updates);
|
||||
updateSelectAllState();
|
||||
}
|
||||
|
||||
const legendEl = document.getElementById('legend');
|
||||
LEGEND.forEach(c => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'legend-item';
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.className = 'legend-cb';
|
||||
cb.checked = true;
|
||||
cb.addEventListener('change', (e) => {
|
||||
e.stopPropagation();
|
||||
if (cb.checked) {
|
||||
hiddenCommunities.delete(c.cid);
|
||||
item.classList.remove('dimmed');
|
||||
} else {
|
||||
hiddenCommunities.add(c.cid);
|
||||
item.classList.add('dimmed');
|
||||
}
|
||||
const updates = RAW_NODES
|
||||
.filter(n => n.community === c.cid)
|
||||
.map(n => ({ id: n.id, hidden: !cb.checked }));
|
||||
nodesDS.update(updates);
|
||||
updateSelectAllState();
|
||||
});
|
||||
item.innerHTML = `<div class="legend-dot" style="background:${c.color}"></div>
|
||||
<span class="legend-label">${c.label}</span>
|
||||
<span class="legend-count">${c.count}</span>`;
|
||||
item.prepend(cb);
|
||||
item.onclick = (e) => {
|
||||
if (e.target === cb) return;
|
||||
cb.checked = !cb.checked;
|
||||
cb.dispatchEvent(new Event('change'));
|
||||
};
|
||||
legendEl.appendChild(item);
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Render hyperedges as shaded regions
|
||||
const hyperedges = [{"id": "epk_four_page_layout", "label": "EPK four A4 pages", "nodes": ["epk_epk_cover_page", "epk_epk_bio_page", "epk_epk_band_music_page", "epk_epk_live_contact_page"], "relation": "form", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "epk/epk.html"}, {"id": "release_data_pipeline", "label": "Release cover filename parsing feeding music page and album schema", "nodes": ["layouts_partials_releases", "layouts_partials_releases_filename_convention", "layouts_music_list", "layouts_partials_schema_musicgroup"], "relation": "form", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "layouts/partials/releases.html"}, {"id": "privacy_by_design_choices", "label": "Privacy-conscious site choices (local fonts, click-to-load Spotify, DSGVO)", "nodes": ["content_datenschutz_local_fonts", "content_datenschutz_spotify_click_to_load", "content_datenschutz_dsgvo_rights", "content_datenschutz_hosting_1blu"], "relation": "participate_in", "confidence": "INFERRED", "confidence_score": 0.75, "source_file": "content/Datenschutz.md"}, {"id": "seo_layer", "label": "SEO layer (meta tags, JSON-LD, robots, show redirects)", "nodes": ["layouts__default_baseof_seo_meta", "layouts_partials_schema", "layouts_robots", "layouts_shows_single"], "relation": "form", "confidence": "INFERRED", "confidence_score": 0.75, "source_file": "layouts/_default/baseof.html"}, {"id": "trier_city_shows", "label": "Shows in the city of Trier", "nodes": ["content_shows_2022_jukufe_light_show", "content_shows_2023_pink_future_show", "content_shows_2024_pink_future_show", "content_shows_2025_album_release_party_show", "content_shows_2026_altstadtfest_trier_show", "content_shows_2026_luke_show"], "relation": "participate_in", "confidence": "INFERRED", "confidence_score": 0.75, "source_file": "content/shows/_index.md"}, {"id": "upcoming_shows_2026", "label": "Upcoming shows after 2026-10-30 (EPK 'N\u00e4chste Shows')", "nodes": ["content_shows_2026_halloween_show", "content_shows_2026_tefftival_show", "content_shows__index_shows"], "relation": "participate_in", "confidence": "INFERRED", "confidence_score": 0.75, "source_file": "content/shows/_index.md"}, {"id": "epk_website_manual_sync_flow", "label": "Website content manually mirrored into EPK", "nodes": ["claude_epk_manual_sync", "content__index_band_bio", "content_about_members", "content_music__index_music_page", "epk_epk_document"], "relation": "participate_in", "confidence": "INFERRED", "confidence_score": 0.85, "source_file": "CLAUDE.md"}, {"id": "show_rendering_flow", "label": "Show listing rendering (home, shows page, card, JSON-LD events)", "nodes": ["layouts_index", "layouts_shows_list", "layouts_partials_show_card", "layouts_partials_schema_musicevent"], "relation": "participate_in", "confidence": "INFERRED", "confidence_score": 0.85, "source_file": "layouts/shows/list.html"}];
|
||||
// afterDrawing passes ctx already transformed to network coordinate space.
|
||||
// Draw node positions raw — no manual pan/zoom/DPR math needed.
|
||||
|
||||
// Andrew's monotone chain. Returns the hull in counter-clockwise order, which
|
||||
// is what the perimeter must be traced in. Collinear and duplicate points
|
||||
// collapse to the extremes, so degenerate member sets render as a segment
|
||||
// rather than a zero-area crossed path.
|
||||
function convexHull(pts) {
|
||||
const p = pts.slice().sort((a, b) => (a.x - b.x) || (a.y - b.y));
|
||||
if (p.length < 3) return p;
|
||||
const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
|
||||
const build = seq => {
|
||||
const out = [];
|
||||
for (const q of seq) {
|
||||
while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], q) <= 0) out.pop();
|
||||
out.push(q);
|
||||
}
|
||||
out.pop();
|
||||
return out;
|
||||
};
|
||||
const hull = build(p).concat(build(p.slice().reverse()));
|
||||
return hull.length >= 3 ? hull : p;
|
||||
}
|
||||
network.on('afterDrawing', function(ctx) {
|
||||
hyperedges.forEach(h => {
|
||||
const positions = h.nodes
|
||||
.map(nid => network.getPositions([nid])[nid])
|
||||
.filter(p => p !== undefined);
|
||||
if (positions.length < 2) return;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.12;
|
||||
ctx.fillStyle = '#6366f1';
|
||||
ctx.strokeStyle = '#6366f1';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
// Centroid and expanded hull in network coordinates.
|
||||
// The perimeter must follow hull order, not h.nodes order: tracing the
|
||||
// raw member order self-intersects whenever the layout does not happen
|
||||
// to place members in angular order, filling as crossed wedges.
|
||||
const cx = positions.reduce((s, p) => s + p.x, 0) / positions.length;
|
||||
const cy = positions.reduce((s, p) => s + p.y, 0) / positions.length;
|
||||
const hull = convexHull(positions);
|
||||
const expanded = hull.map(p => ({
|
||||
x: cx + (p.x - cx) * 1.15,
|
||||
y: cy + (p.y - cy) * 1.15
|
||||
}));
|
||||
ctx.moveTo(expanded[0].x, expanded[0].y);
|
||||
expanded.slice(1).forEach(p => ctx.lineTo(p.x, p.y));
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.stroke();
|
||||
// Label
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.fillStyle = '#4f46e5';
|
||||
ctx.font = 'bold 11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(h.label, cx, cy - 5);
|
||||
ctx.restore();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user