mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-01 07:26:08 +00:00
Compare commits
No commits in common. "32566f15a81dabfbb14b035b695bd7675b94c0b5" and "7b0314c21a81a94abd050a3f9f6249287ffffdd8" have entirely different histories.
32566f15a8
...
7b0314c21a
|
|
@ -28,13 +28,21 @@
|
|||
</header>
|
||||
|
||||
<main class="social-main">
|
||||
<p class="social-intro">{{ t.intro }}</p>
|
||||
|
||||
<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_sites">0</span><span class="stat-l">{{ t.stats_total_sites }}</span></div>
|
||||
</section>
|
||||
|
||||
<section class="graph-wrap" aria-label="{{ t.header_subtitle }}">
|
||||
<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_sites">0</span><span class="stat-l">{{ t.stats_total_sites }}</span></div>
|
||||
</section>
|
||||
<p class="graph-hint">{{ t.graph_swipe_hint }}</p>
|
||||
<svg id="social-graph" role="img" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<svg id="social-graph" viewBox="0 0 600 600" role="img"></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>
|
||||
|
||||
<aside id="node-detail" class="node-detail" hidden aria-live="polite">
|
||||
|
|
@ -56,20 +64,20 @@
|
|||
</dl>
|
||||
</aside>
|
||||
|
||||
<nav class="cards-row">
|
||||
<details class="card">
|
||||
<summary>{{ t.card_evidence }}</summary>
|
||||
<p class="card-pending">{{ t.card_evidence_pending }}</p>
|
||||
</details>
|
||||
<details class="card card-wipe">
|
||||
<summary>{{ t.card_wipe }}</summary>
|
||||
<button type="button" class="wipe-btn" data-action="open-wipe">{{ t.wipe_button }}</button>
|
||||
</details>
|
||||
<details class="card">
|
||||
<summary>{{ t.card_pdf }}</summary>
|
||||
<p class="card-pending">{{ t.card_pdf_pending }}</p>
|
||||
</details>
|
||||
</nav>
|
||||
<details class="card">
|
||||
<summary>{{ t.card_evidence }}</summary>
|
||||
<p class="card-pending">{{ t.card_evidence_pending }}</p>
|
||||
</details>
|
||||
|
||||
<details class="card card-wipe">
|
||||
<summary>{{ t.card_wipe }}</summary>
|
||||
<button type="button" class="wipe-btn" data-action="open-wipe">{{ t.wipe_button }}</button>
|
||||
</details>
|
||||
|
||||
<details class="card">
|
||||
<summary>{{ t.card_pdf }}</summary>
|
||||
<p class="card-pending">{{ t.card_pdf_pending }}</p>
|
||||
</details>
|
||||
</main>
|
||||
|
||||
<dialog id="wipe-modal" aria-labelledby="wipe-modal-title">
|
||||
|
|
|
|||
|
|
@ -19,33 +19,25 @@
|
|||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; background: var(--cosmos-black); color: var(--text-primary); }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: 'IM Fell English', Georgia, serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
/* 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;
|
||||
min-height: 100vh;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* Header — compact, sticky, doesn't steal vertical space from the graph. */
|
||||
/* Header */
|
||||
.social-header {
|
||||
flex: 0 0 auto;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 14px;
|
||||
display: flex; align-items: baseline; justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--gold-hermetic);
|
||||
background: linear-gradient(180deg, rgba(201,168,76,.06), transparent);
|
||||
}
|
||||
.brand-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--gold-hermetic);
|
||||
}
|
||||
|
|
@ -60,52 +52,37 @@ body {
|
|||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
/* Main : flex column, takes all remaining height after the header.
|
||||
Graph fills the body of main ; stats + cards float over the corners. */
|
||||
.social-main {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0; /* allow children to shrink past content size */
|
||||
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; }
|
||||
/* Main */
|
||||
.social-main { padding: 14px 16px 24px; max-width: 720px; margin: 0 auto; }
|
||||
.social-intro { color: var(--text-muted); font-size: 14px; margin: 8px 0 16px; }
|
||||
|
||||
/* 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;
|
||||
}
|
||||
/* Stats tiles */
|
||||
.stats { display: flex; gap: 12px; margin-bottom: 18px; }
|
||||
.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;
|
||||
flex: 1;
|
||||
background: rgba(0, 212, 255, .04);
|
||||
border: 1px solid rgba(0, 212, 255, .2);
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.stat-n {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 16px;
|
||||
font-size: 22px;
|
||||
color: var(--cyber-cyan);
|
||||
line-height: 1;
|
||||
}
|
||||
.stat-l { font-size: 10px; color: var(--text-muted); }
|
||||
.stat-l { font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
/* Graph fills all remaining vertical space inside .social-main. */
|
||||
/* Graph */
|
||||
.graph-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: rgba(110, 64, 201, .03);
|
||||
border: 1px solid rgba(110, 64, 201, .25);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 1 / 1;
|
||||
max-height: 70vh;
|
||||
}
|
||||
.graph-hint {
|
||||
position: absolute; top: 8px; right: 12px;
|
||||
|
|
@ -114,6 +91,20 @@ body {
|
|||
pointer-events: 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. */
|
||||
.node circle { stroke: var(--cosmos-black); stroke-width: 1.5; }
|
||||
|
|
@ -163,49 +154,21 @@ body {
|
|||
.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; }
|
||||
|
||||
/* 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. */
|
||||
/* Cards */
|
||||
.card {
|
||||
background: rgba(201, 168, 76, .04);
|
||||
border: 1px solid rgba(201, 168, 76, .25);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.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;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.card summary {
|
||||
cursor: pointer;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 12px;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--gold-hermetic);
|
||||
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::after { content: ' ▾'; color: var(--text-muted); font-size: 12px; }
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@
|
|||
// ─── DOM refs ───
|
||||
const svgEl = document.getElementById('social-graph');
|
||||
const svg = d3.select(svgEl);
|
||||
const loadingEl = document.getElementById('social-loading');
|
||||
const emptyEl = document.getElementById('social-empty');
|
||||
const ndEl = document.getElementById('node-detail');
|
||||
const wipeModal = document.getElementById('wipe-modal');
|
||||
|
||||
|
|
@ -50,14 +52,7 @@
|
|||
|
||||
// ─── graph state ───
|
||||
let simulation = null;
|
||||
|
||||
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) };
|
||||
}
|
||||
const W = 600, H = 600;
|
||||
|
||||
function clearGraph() {
|
||||
svg.selectAll('*').remove();
|
||||
|
|
@ -66,15 +61,16 @@
|
|||
|
||||
function render(graph) {
|
||||
clearGraph();
|
||||
const { W, H } = svgSize();
|
||||
svg.attr('viewBox', `0 0 ${W} ${H}`);
|
||||
|
||||
bind('total_trackers', graph.stats.total_trackers || 0);
|
||||
bind('total_sites', graph.stats.total_sites || 0);
|
||||
|
||||
// Empty graph → just return ; the stats tiles already show 0/0 and
|
||||
// the user knows. No persistent overlay message.
|
||||
if (!graph.nodes.length) return;
|
||||
loadingEl.hidden = true;
|
||||
if (!graph.nodes.length) {
|
||||
emptyEl.hidden = false;
|
||||
return;
|
||||
}
|
||||
emptyEl.hidden = true;
|
||||
|
||||
// Build d3 dataset: sites are union of all node.sites + tracker nodes themselves.
|
||||
const siteSet = new Set();
|
||||
|
|
@ -125,34 +121,19 @@
|
|||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
.force('link', d3.forceLink([...links, ...accentLinks]).id(d => d.id).distance(linkDist))
|
||||
.force('charge', d3.forceManyBody().strength(chargeStr))
|
||||
.force('link', d3.forceLink([...links, ...accentLinks]).id(d => d.id).distance(70))
|
||||
.force('charge', d3.forceManyBody().strength(-180))
|
||||
.force('center', d3.forceCenter(W / 2, H / 2))
|
||||
.force('collide', d3.forceCollide().radius(N > 80 ? 14 : 22))
|
||||
.alphaDecay(0.05); // settle faster than the 0.0228 default
|
||||
.force('collide', d3.forceCollide().radius(22));
|
||||
|
||||
// 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')
|
||||
const linkSel = svg.append('g').attr('class', 'links')
|
||||
.selectAll('line').data([...links, ...accentLinks]).join('line')
|
||||
.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-dasharray', d => d.accent ? '4,3' : null);
|
||||
|
||||
const nodeG = content.append('g').attr('class', 'nodes')
|
||||
const nodeG = svg.append('g').attr('class', 'nodes')
|
||||
.selectAll('g').data(nodes).join('g')
|
||||
.attr('class', d => 'node node-' + d.kind)
|
||||
.call(d3.drag()
|
||||
|
|
@ -169,81 +150,12 @@
|
|||
.attr('x', 12).attr('y', 4)
|
||||
.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', () => {
|
||||
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})`);
|
||||
});
|
||||
|
||||
// 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 ───
|
||||
|
|
@ -292,6 +204,9 @@
|
|||
const j = await r.json();
|
||||
wipeModal.close();
|
||||
alert(t('wipe_success', { n: j.rows_deleted || 0 }));
|
||||
// Refresh
|
||||
loadingEl.hidden = false;
|
||||
svgEl.style.display = '';
|
||||
fetchGraph();
|
||||
} catch (e) {
|
||||
console.error('[social] wipe failed', e);
|
||||
|
|
@ -320,6 +235,7 @@
|
|||
render(g);
|
||||
} catch (e) {
|
||||
console.error('[social] fetch failed', e);
|
||||
loadingEl.textContent = t('error');
|
||||
}
|
||||
}
|
||||
fetchGraph();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user