mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-01 17:17:14 +00:00
Compare commits
3 Commits
7b0314c21a
...
32566f15a8
| Author | SHA1 | Date | |
|---|---|---|---|
| 32566f15a8 | |||
| 46277413c3 | |||
| d87b2ea936 |
|
|
@ -28,21 +28,13 @@
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="social-main">
|
<main class="social-main">
|
||||||
<p class="social-intro">{{ t.intro }}</p>
|
<section class="graph-wrap" aria-label="{{ t.header_subtitle }}">
|
||||||
|
|
||||||
<section class="stats" aria-live="polite">
|
<section class="stats" aria-live="polite">
|
||||||
<div class="stat-tile"><span class="stat-n" data-bind="total_trackers">0</span><span class="stat-l">{{ t.stats_total_trackers }}</span></div>
|
<div class="stat-tile"><span class="stat-n" data-bind="total_trackers">0</span><span class="stat-l">{{ t.stats_total_trackers }}</span></div>
|
||||||
<div class="stat-tile"><span class="stat-n" data-bind="total_sites">0</span><span class="stat-l">{{ t.stats_total_sites }}</span></div>
|
<div class="stat-tile"><span class="stat-n" data-bind="total_sites">0</span><span class="stat-l">{{ t.stats_total_sites }}</span></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="graph-wrap" aria-label="{{ t.header_subtitle }}">
|
|
||||||
<p class="graph-hint">{{ t.graph_swipe_hint }}</p>
|
<p class="graph-hint">{{ t.graph_swipe_hint }}</p>
|
||||||
<svg id="social-graph" viewBox="0 0 600 600" role="img"></svg>
|
<svg id="social-graph" role="img" preserveAspectRatio="xMidYMid meet"></svg>
|
||||||
<div id="social-empty" class="social-empty" hidden>
|
|
||||||
<div class="empty-title">{{ t.empty_title }}</div>
|
|
||||||
<div class="empty-body">{{ t.empty_body }}</div>
|
|
||||||
</div>
|
|
||||||
<div id="social-loading" class="social-loading">{{ t.loading }}</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside id="node-detail" class="node-detail" hidden aria-live="polite">
|
<aside id="node-detail" class="node-detail" hidden aria-live="polite">
|
||||||
|
|
@ -64,20 +56,20 @@
|
||||||
</dl>
|
</dl>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<nav class="cards-row">
|
||||||
<details class="card">
|
<details class="card">
|
||||||
<summary>{{ t.card_evidence }}</summary>
|
<summary>{{ t.card_evidence }}</summary>
|
||||||
<p class="card-pending">{{ t.card_evidence_pending }}</p>
|
<p class="card-pending">{{ t.card_evidence_pending }}</p>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="card card-wipe">
|
<details class="card card-wipe">
|
||||||
<summary>{{ t.card_wipe }}</summary>
|
<summary>{{ t.card_wipe }}</summary>
|
||||||
<button type="button" class="wipe-btn" data-action="open-wipe">{{ t.wipe_button }}</button>
|
<button type="button" class="wipe-btn" data-action="open-wipe">{{ t.wipe_button }}</button>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="card">
|
<details class="card">
|
||||||
<summary>{{ t.card_pdf }}</summary>
|
<summary>{{ t.card_pdf }}</summary>
|
||||||
<p class="card-pending">{{ t.card_pdf_pending }}</p>
|
<p class="card-pending">{{ t.card_pdf_pending }}</p>
|
||||||
</details>
|
</details>
|
||||||
|
</nav>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<dialog id="wipe-modal" aria-labelledby="wipe-modal-title">
|
<dialog id="wipe-modal" aria-labelledby="wipe-modal-title">
|
||||||
|
|
|
||||||
|
|
@ -19,25 +19,33 @@
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html, body { margin: 0; padding: 0; background: var(--cosmos-black); color: var(--text-primary); }
|
html, body { margin: 0; padding: 0; background: var(--cosmos-black); color: var(--text-primary); }
|
||||||
|
html, body { height: 100%; }
|
||||||
body {
|
body {
|
||||||
font-family: 'IM Fell English', Georgia, serif;
|
font-family: 'IM Fell English', Georgia, serif;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
min-height: 100vh;
|
/* Full-viewport flex layout : header (sticky), main (flex 1, owns the
|
||||||
|
graph), node-detail (slides over). Cards live inside main as a
|
||||||
|
compact bottom row so the graph stays hero. */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
/* Header — compact, sticky, doesn't steal vertical space from the graph. */
|
||||||
.social-header {
|
.social-header {
|
||||||
display: flex; align-items: baseline; justify-content: space-between;
|
flex: 0 0 auto;
|
||||||
padding: 14px 18px;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 8px 14px;
|
||||||
border-bottom: 1px solid var(--gold-hermetic);
|
border-bottom: 1px solid var(--gold-hermetic);
|
||||||
background: linear-gradient(180deg, rgba(201,168,76,.06), transparent);
|
background: linear-gradient(180deg, rgba(201,168,76,.06), transparent);
|
||||||
}
|
}
|
||||||
.brand-title {
|
.brand-title {
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 22px;
|
font-size: 18px;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: var(--gold-hermetic);
|
color: var(--gold-hermetic);
|
||||||
}
|
}
|
||||||
|
|
@ -52,37 +60,52 @@ body {
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Main */
|
/* Main : flex column, takes all remaining height after the header.
|
||||||
.social-main { padding: 14px 16px 24px; max-width: 720px; margin: 0 auto; }
|
Graph fills the body of main ; stats + cards float over the corners. */
|
||||||
.social-intro { color: var(--text-muted); font-size: 14px; margin: 8px 0 16px; }
|
.social-main {
|
||||||
|
|
||||||
/* Stats tiles */
|
|
||||||
.stats { display: flex; gap: 12px; margin-bottom: 18px; }
|
|
||||||
.stat-tile {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: rgba(0, 212, 255, .04);
|
position: relative;
|
||||||
border: 1px solid rgba(0, 212, 255, .2);
|
display: flex;
|
||||||
border-radius: 4px;
|
flex-direction: column;
|
||||||
padding: 10px 12px;
|
min-height: 0; /* allow children to shrink past content size */
|
||||||
display: flex; flex-direction: column;
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
/* Phase 11.B v2 — intro paragraph hidden in full-viewport mode ; the
|
||||||
|
graph itself + the hover hint communicate the same thing. */
|
||||||
|
.social-intro { display: none; }
|
||||||
|
|
||||||
|
/* Stats tiles — compact horizontal strip floating top-left over the
|
||||||
|
graph. Glassy background so the graph stays visually dominant. */
|
||||||
|
.stats {
|
||||||
|
position: absolute; top: 8px; left: 8px;
|
||||||
|
display: flex; gap: 6px;
|
||||||
|
z-index: 5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.stat-tile {
|
||||||
|
background: rgba(10, 10, 15, .65);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
border: 1px solid rgba(0, 212, 255, .35);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
display: flex; align-items: baseline; gap: 5px;
|
||||||
}
|
}
|
||||||
.stat-n {
|
.stat-n {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 22px;
|
font-size: 16px;
|
||||||
color: var(--cyber-cyan);
|
color: var(--cyber-cyan);
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.stat-l { font-size: 12px; color: var(--text-muted); }
|
.stat-l { font-size: 10px; color: var(--text-muted); }
|
||||||
|
|
||||||
/* Graph */
|
/* Graph fills all remaining vertical space inside .social-main. */
|
||||||
.graph-wrap {
|
.graph-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
background: rgba(110, 64, 201, .03);
|
background: rgba(110, 64, 201, .03);
|
||||||
border: 1px solid rgba(110, 64, 201, .25);
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
aspect-ratio: 1 / 1;
|
|
||||||
max-height: 70vh;
|
|
||||||
}
|
}
|
||||||
.graph-hint {
|
.graph-hint {
|
||||||
position: absolute; top: 8px; right: 12px;
|
position: absolute; top: 8px; right: 12px;
|
||||||
|
|
@ -91,20 +114,6 @@ body {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
#social-graph { display: block; width: 100%; height: 100%; touch-action: none; }
|
#social-graph { display: block; width: 100%; height: 100%; touch-action: none; }
|
||||||
.social-loading {
|
|
||||||
position: absolute; inset: 0;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
color: var(--text-muted); font-family: 'JetBrains Mono', monospace;
|
|
||||||
}
|
|
||||||
.social-empty {
|
|
||||||
position: absolute; inset: 0;
|
|
||||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
||||||
color: var(--matrix-green);
|
|
||||||
text-align: center;
|
|
||||||
font-family: 'Cinzel', serif;
|
|
||||||
}
|
|
||||||
.empty-title { font-size: 22px; letter-spacing: 0.1em; margin-bottom: 8px; }
|
|
||||||
.empty-body { font-family: 'IM Fell English', serif; font-style: italic; color: var(--matrix-green); opacity: .8; }
|
|
||||||
|
|
||||||
/* d3 node + edge styling — colors come from the data class on each element. */
|
/* d3 node + edge styling — colors come from the data class on each element. */
|
||||||
.node circle { stroke: var(--cosmos-black); stroke-width: 1.5; }
|
.node circle { stroke: var(--cosmos-black); stroke-width: 1.5; }
|
||||||
|
|
@ -154,21 +163,49 @@ body {
|
||||||
.node-detail dt { color: var(--text-muted); font-style: italic; }
|
.node-detail dt { color: var(--text-muted); font-style: italic; }
|
||||||
.node-detail dd { margin: 0; color: var(--text-primary); font-family: 'JetBrains Mono', monospace; font-size: 12px; word-break: break-word; }
|
.node-detail dd { margin: 0; color: var(--text-primary); font-family: 'JetBrains Mono', monospace; font-size: 12px; word-break: break-word; }
|
||||||
|
|
||||||
/* Cards */
|
/* Cards row — floats at the bottom of the viewport as a 3-cell
|
||||||
|
horizontal strip ; each <details> opens upward as a popover so the
|
||||||
|
graph stays visible while you interact with a card. */
|
||||||
.card {
|
.card {
|
||||||
background: rgba(201, 168, 76, .04);
|
background: rgba(201, 168, 76, .04);
|
||||||
border: 1px solid rgba(201, 168, 76, .25);
|
border: 1px solid rgba(201, 168, 76, .25);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 10px 14px;
|
}
|
||||||
margin-bottom: 10px;
|
.cards-row {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 8px env(safe-area-inset-bottom);
|
||||||
|
background: rgba(10, 10, 15, .85);
|
||||||
|
border-top: 1px solid var(--gold-hermetic);
|
||||||
|
}
|
||||||
|
.cards-row .card {
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.card summary {
|
.card summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: 'Cinzel', serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 15px;
|
font-size: 12px;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
color: var(--gold-hermetic);
|
color: var(--gold-hermetic);
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
/* Open-card popover : opens upward so cards stay readable above the
|
||||||
|
bottom strip. */
|
||||||
|
.cards-row .card[open] {
|
||||||
|
position: absolute;
|
||||||
|
left: 8px; right: 8px;
|
||||||
|
bottom: calc(50px + env(safe-area-inset-bottom));
|
||||||
|
background: rgba(10, 10, 15, .96);
|
||||||
|
border-color: var(--gold-hermetic);
|
||||||
|
padding: 12px 16px;
|
||||||
|
z-index: 8;
|
||||||
}
|
}
|
||||||
.card summary::-webkit-details-marker { display: none; }
|
.card summary::-webkit-details-marker { display: none; }
|
||||||
.card summary::after { content: ' ▾'; color: var(--text-muted); font-size: 12px; }
|
.card summary::after { content: ' ▾'; color: var(--text-muted); font-size: 12px; }
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,6 @@
|
||||||
// ─── DOM refs ───
|
// ─── DOM refs ───
|
||||||
const svgEl = document.getElementById('social-graph');
|
const svgEl = document.getElementById('social-graph');
|
||||||
const svg = d3.select(svgEl);
|
const svg = d3.select(svgEl);
|
||||||
const loadingEl = document.getElementById('social-loading');
|
|
||||||
const emptyEl = document.getElementById('social-empty');
|
|
||||||
const ndEl = document.getElementById('node-detail');
|
const ndEl = document.getElementById('node-detail');
|
||||||
const wipeModal = document.getElementById('wipe-modal');
|
const wipeModal = document.getElementById('wipe-modal');
|
||||||
|
|
||||||
|
|
@ -52,7 +50,14 @@
|
||||||
|
|
||||||
// ─── graph state ───
|
// ─── graph state ───
|
||||||
let simulation = null;
|
let simulation = null;
|
||||||
const W = 600, H = 600;
|
|
||||||
|
function svgSize() {
|
||||||
|
// Measure actual rendered size so the force center scales with the
|
||||||
|
// viewport. Falls back to a sane default if the layout hasn't
|
||||||
|
// settled yet.
|
||||||
|
const r = svgEl.getBoundingClientRect();
|
||||||
|
return { W: Math.max(r.width, 320), H: Math.max(r.height, 320) };
|
||||||
|
}
|
||||||
|
|
||||||
function clearGraph() {
|
function clearGraph() {
|
||||||
svg.selectAll('*').remove();
|
svg.selectAll('*').remove();
|
||||||
|
|
@ -61,16 +66,15 @@
|
||||||
|
|
||||||
function render(graph) {
|
function render(graph) {
|
||||||
clearGraph();
|
clearGraph();
|
||||||
|
const { W, H } = svgSize();
|
||||||
|
svg.attr('viewBox', `0 0 ${W} ${H}`);
|
||||||
|
|
||||||
bind('total_trackers', graph.stats.total_trackers || 0);
|
bind('total_trackers', graph.stats.total_trackers || 0);
|
||||||
bind('total_sites', graph.stats.total_sites || 0);
|
bind('total_sites', graph.stats.total_sites || 0);
|
||||||
|
|
||||||
loadingEl.hidden = true;
|
// Empty graph → just return ; the stats tiles already show 0/0 and
|
||||||
if (!graph.nodes.length) {
|
// the user knows. No persistent overlay message.
|
||||||
emptyEl.hidden = false;
|
if (!graph.nodes.length) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
emptyEl.hidden = true;
|
|
||||||
|
|
||||||
// Build d3 dataset: sites are union of all node.sites + tracker nodes themselves.
|
// Build d3 dataset: sites are union of all node.sites + tracker nodes themselves.
|
||||||
const siteSet = new Set();
|
const siteSet = new Set();
|
||||||
|
|
@ -121,19 +125,34 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 11.B v4 — when we have many nodes (last test: 86 trackers +
|
||||||
|
// 60 sites = 146 nodes) the default force layout spreads them far
|
||||||
|
// outside the viewport, and the first autoFit caught the simulation
|
||||||
|
// mid-flight so only a single node was visible. Scale the forces
|
||||||
|
// with node count and pre-warm the simulation synchronously before
|
||||||
|
// first render so layout is already settled.
|
||||||
|
const N = nodes.length;
|
||||||
|
const linkDist = N > 80 ? 40 : N > 30 ? 55 : 70;
|
||||||
|
const chargeStr = N > 80 ? -60 : N > 30 ? -120 : -180;
|
||||||
simulation = d3.forceSimulation(nodes)
|
simulation = d3.forceSimulation(nodes)
|
||||||
.force('link', d3.forceLink([...links, ...accentLinks]).id(d => d.id).distance(70))
|
.force('link', d3.forceLink([...links, ...accentLinks]).id(d => d.id).distance(linkDist))
|
||||||
.force('charge', d3.forceManyBody().strength(-180))
|
.force('charge', d3.forceManyBody().strength(chargeStr))
|
||||||
.force('center', d3.forceCenter(W / 2, H / 2))
|
.force('center', d3.forceCenter(W / 2, H / 2))
|
||||||
.force('collide', d3.forceCollide().radius(22));
|
.force('collide', d3.forceCollide().radius(N > 80 ? 14 : 22))
|
||||||
|
.alphaDecay(0.05); // settle faster than the 0.0228 default
|
||||||
|
|
||||||
const linkSel = svg.append('g').attr('class', 'links')
|
// Phase 11.B v3 — content group that owns links + nodes ; the
|
||||||
|
// d3.zoom behavior applies its transform here so pan/pinch don't
|
||||||
|
// move the SVG itself (or its viewBox).
|
||||||
|
const content = svg.append('g').attr('class', 'content');
|
||||||
|
|
||||||
|
const linkSel = content.append('g').attr('class', 'links')
|
||||||
.selectAll('line').data([...links, ...accentLinks]).join('line')
|
.selectAll('line').data([...links, ...accentLinks]).join('line')
|
||||||
.attr('class', d => d.accent ? 'edge accent' : 'edge')
|
.attr('class', d => d.accent ? 'edge accent' : 'edge')
|
||||||
.attr('stroke-width', d => Math.max(1, Math.log(1 + (d.reuse || 0)) * 1.8))
|
.attr('stroke-width', d => Math.max(1, Math.log(1 + (d.reuse || 0)) * 1.8))
|
||||||
.attr('stroke-dasharray', d => d.accent ? '4,3' : null);
|
.attr('stroke-dasharray', d => d.accent ? '4,3' : null);
|
||||||
|
|
||||||
const nodeG = svg.append('g').attr('class', 'nodes')
|
const nodeG = content.append('g').attr('class', 'nodes')
|
||||||
.selectAll('g').data(nodes).join('g')
|
.selectAll('g').data(nodes).join('g')
|
||||||
.attr('class', d => 'node node-' + d.kind)
|
.attr('class', d => 'node node-' + d.kind)
|
||||||
.call(d3.drag()
|
.call(d3.drag()
|
||||||
|
|
@ -150,12 +169,81 @@
|
||||||
.attr('x', 12).attr('y', 4)
|
.attr('x', 12).attr('y', 4)
|
||||||
.text(d => d.label.length > 22 ? d.label.slice(0, 21) + '…' : d.label);
|
.text(d => d.label.length > 22 ? d.label.slice(0, 21) + '…' : d.label);
|
||||||
|
|
||||||
|
// ─── pan + pinch-zoom on the SVG (transform applies to content) ──
|
||||||
|
// Drag on a node calls d3.drag, drag on empty SVG calls d3.zoom's
|
||||||
|
// pan ; pinch and wheel always zoom. Touch-action: none on the
|
||||||
|
// svg (css) keeps the browser from intercepting these gestures.
|
||||||
|
const zoom = d3.zoom()
|
||||||
|
.scaleExtent([0.2, 6])
|
||||||
|
.filter((ev) => {
|
||||||
|
// Allow pan when the gesture didn't start on a node element.
|
||||||
|
// Allow all wheel + touch (multi-finger pinch).
|
||||||
|
if (ev.type === 'wheel' || (ev.touches && ev.touches.length > 1)) return true;
|
||||||
|
return !ev.target.closest('.node');
|
||||||
|
})
|
||||||
|
.on('zoom', (ev) => content.attr('transform', ev.transform));
|
||||||
|
svg.call(zoom).on('dblclick.zoom', () => autoFit(800));
|
||||||
|
|
||||||
|
// Auto-fit using node data positions (not getBBox — which can be
|
||||||
|
// skewed by text labels far outside the actual node cluster).
|
||||||
|
function autoFit(duration = 600) {
|
||||||
|
if (!nodes.length) return;
|
||||||
|
const xs = nodes.map(n => n.x).filter(Number.isFinite);
|
||||||
|
const ys = nodes.map(n => n.y).filter(Number.isFinite);
|
||||||
|
if (!xs.length) return;
|
||||||
|
const x0 = Math.min(...xs), x1 = Math.max(...xs);
|
||||||
|
const y0 = Math.min(...ys), y1 = Math.max(...ys);
|
||||||
|
const bw = Math.max(x1 - x0, 100);
|
||||||
|
const bh = Math.max(y1 - y0, 100);
|
||||||
|
const pad = 60;
|
||||||
|
const scale = Math.min(
|
||||||
|
(W - pad * 2) / bw,
|
||||||
|
(H - pad * 2) / bh,
|
||||||
|
2.5,
|
||||||
|
);
|
||||||
|
const cx = (x0 + x1) / 2;
|
||||||
|
const cy = (y0 + y1) / 2;
|
||||||
|
const tx = W / 2 - cx * scale;
|
||||||
|
const ty = H / 2 - cy * scale;
|
||||||
|
svg.transition().duration(duration).call(
|
||||||
|
zoom.transform,
|
||||||
|
d3.zoomIdentity.translate(tx, ty).scale(scale),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-warm the simulation synchronously so the layout is already
|
||||||
|
// settled before the user sees the first frame. 300 ticks is
|
||||||
|
// enough for ~150 nodes to find their resting positions.
|
||||||
|
for (let i = 0; i < 300; i++) simulation.tick();
|
||||||
|
|
||||||
|
// Now bind the live tick so subsequent micro-drift updates the DOM.
|
||||||
simulation.on('tick', () => {
|
simulation.on('tick', () => {
|
||||||
linkSel
|
linkSel
|
||||||
.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
|
.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
|
||||||
.attr('x2', d => d.target.x).attr('y2', d => d.target.y);
|
.attr('x2', d => d.target.x).attr('y2', d => d.target.y);
|
||||||
nodeG.attr('transform', d => `translate(${d.x},${d.y})`);
|
nodeG.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Render once immediately with the pre-warmed positions.
|
||||||
|
linkSel
|
||||||
|
.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
|
||||||
|
.attr('x2', d => d.target.x).attr('y2', d => d.target.y);
|
||||||
|
nodeG.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||||
|
|
||||||
|
// Allow a gentle re-settle for visual polish (low alpha so it
|
||||||
|
// barely moves) and fit-to-viewport immediately.
|
||||||
|
simulation.alpha(0.05).restart();
|
||||||
|
requestAnimationFrame(() => autoFit(0));
|
||||||
|
// Re-fit on viewport resize.
|
||||||
|
let resizeTimer;
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(() => {
|
||||||
|
const sz = svgSize();
|
||||||
|
svg.attr('viewBox', `0 0 ${sz.W} ${sz.H}`);
|
||||||
|
autoFit(400);
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── focus / detail panel ───
|
// ─── focus / detail panel ───
|
||||||
|
|
@ -204,9 +292,6 @@
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
wipeModal.close();
|
wipeModal.close();
|
||||||
alert(t('wipe_success', { n: j.rows_deleted || 0 }));
|
alert(t('wipe_success', { n: j.rows_deleted || 0 }));
|
||||||
// Refresh
|
|
||||||
loadingEl.hidden = false;
|
|
||||||
svgEl.style.display = '';
|
|
||||||
fetchGraph();
|
fetchGraph();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[social] wipe failed', e);
|
console.error('[social] wipe failed', e);
|
||||||
|
|
@ -235,7 +320,6 @@
|
||||||
render(g);
|
render(g);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[social] fetch failed', e);
|
console.error('[social] fetch failed', e);
|
||||||
loadingEl.textContent = t('error');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchGraph();
|
fetchGraph();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user