Compare commits

..

3 Commits

Author SHA1 Message Date
32566f15a8 fix(toolbox): force layout pre-warm + data-based autoFit (ref #507)
Live test screenshot showed 86 trackers / 60 sites computed correctly,
but only one tracker node was visible — the force layout had spread
the rest off-screen and the on-tick autoFit caught the simulation
mid-flight using SVG getBBox (which is sensitive to label text far
outside the cluster).

  - Force tuning scales with node count (146 nodes → linkDist 40,
    chargeStr -60, collide 14, alphaDecay 0.05 for faster settle).
  - Pre-warm: 300 synchronous simulation.tick()s before first render so
    positions are already converged.
  - autoFit() uses node data positions (min/max x,y over nodes array)
    instead of content.getBBox() — labels no longer skew the fit.
  - First render: paint pre-warmed positions immediately, kick a gentle
    alpha(0.05) restart for polish, autoFit on the next animation frame
    with duration 0 (instant).
2026-06-09 12:51:44 +02:00
46277413c3 feat(toolbox): d3 pan + pinch-zoom + auto-fit initial render (ref #507)
- Wrap graph content (links + nodes) in a content <g> so d3.zoom's
    transform applies there without moving the SVG itself.
  - d3.zoom(): scale 0.2–6×, mouse wheel + touch pinch + drag-pan.
    Filter lets node drags pass through d3.drag (no collision with the
    pan gesture) and always accepts multi-touch + wheel.
  - autoFit(): after the simulation alpha drops below 0.1, compute the
    nodes' bounding box and transition to a zoom transform that fits
    all of them with 28 px padding (capped at 2.5× so a single-node
    graph doesn't blow up).  Same logic re-runs on dblclick (reset)
    and on viewport resize (debounced 150 ms).
  - Viewport-resize observer keeps the viewBox and zoom in sync when
    orientation changes.
2026-06-09 12:48:26 +02:00
d87b2ea936 feat(toolbox): full-viewport graph + drop persistent overlay messages (ref #507)
User feedback : graph too small + 'Chargement…' / 'ZÉRO TRACKER DÉTECTÉ'
overlays kept stacking on top of the graph.

Layout reworked :
  - body flex column 100vh overflow hidden — header sticky, main owns
    all remaining vertical space, graph fills it.
  - stats tiles → small glassy pill floating top-left over the graph.
  - cards collapsed into a compact 3-cell horizontal strip at the
    bottom ; open-card popover slides up over the graph so the graph
    stays visible.
  - graph svg measured at render-time, viewBox set dynamically — d3
    force-center scales to actual viewport instead of the fixed
    600×600 box.

Persistent overlays removed :
  - .social-empty and .social-loading divs gone from template.
  - JS no longer references them.  Empty graph is its own indicator
    (stats tiles show 0/0).  Network errors log to console only.

Net effect : tap 'Ma carto' on splash → full-screen force-directed
graph, stats top-left, cards bottom strip, no overlay clutter.
2026-06-09 12:46:00 +02:00
3 changed files with 201 additions and 88 deletions

View File

@ -28,21 +28,13 @@
</header>
<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">
<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 }}">
<p class="graph-hint">{{ t.graph_swipe_hint }}</p>
<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>
<svg id="social-graph" role="img" preserveAspectRatio="xMidYMid meet"></svg>
</section>
<aside id="node-detail" class="node-detail" hidden aria-live="polite">
@ -64,20 +56,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>
</main>
<dialog id="wipe-modal" aria-labelledby="wipe-modal-title">

View File

@ -19,25 +19,33 @@
* { 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;
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);
}
/* Header */
/* Header — compact, sticky, doesn't steal vertical space from the graph. */
.social-header {
display: flex; align-items: baseline; justify-content: space-between;
padding: 14px 18px;
flex: 0 0 auto;
display: flex; align-items: center; justify-content: space-between;
padding: 8px 14px;
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: 22px;
font-size: 18px;
letter-spacing: 0.05em;
color: var(--gold-hermetic);
}
@ -52,37 +60,52 @@ body {
padding: 2px 6px;
}
/* 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 */
.stats { display: flex; gap: 12px; margin-bottom: 18px; }
.stat-tile {
/* 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;
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;
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; }
/* 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 {
font-family: 'JetBrains Mono', monospace;
font-size: 22px;
font-size: 16px;
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 {
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;
@ -91,20 +114,6 @@ 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; }
@ -154,21 +163,49 @@ 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 */
/* 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 {
background: rgba(201, 168, 76, .04);
border: 1px solid rgba(201, 168, 76, .25);
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 {
cursor: pointer;
font-family: 'Cinzel', serif;
font-size: 15px;
font-size: 12px;
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; }

View File

@ -39,8 +39,6 @@
// ─── 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');
@ -52,7 +50,14 @@
// ─── graph state ───
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() {
svg.selectAll('*').remove();
@ -61,16 +66,15 @@
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);
loadingEl.hidden = true;
if (!graph.nodes.length) {
emptyEl.hidden = false;
return;
}
emptyEl.hidden = true;
// Empty graph → just return ; the stats tiles already show 0/0 and
// the user knows. No persistent overlay message.
if (!graph.nodes.length) return;
// Build d3 dataset: sites are union of all node.sites + tracker nodes themselves.
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)
.force('link', d3.forceLink([...links, ...accentLinks]).id(d => d.id).distance(70))
.force('charge', d3.forceManyBody().strength(-180))
.force('link', d3.forceLink([...links, ...accentLinks]).id(d => d.id).distance(linkDist))
.force('charge', d3.forceManyBody().strength(chargeStr))
.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')
.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 = svg.append('g').attr('class', 'nodes')
const nodeG = content.append('g').attr('class', 'nodes')
.selectAll('g').data(nodes).join('g')
.attr('class', d => 'node node-' + d.kind)
.call(d3.drag()
@ -150,12 +169,81 @@
.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 ───
@ -204,9 +292,6 @@
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);
@ -235,7 +320,6 @@
render(g);
} catch (e) {
console.error('[social] fetch failed', e);
loadingEl.textContent = t('error');
}
}
fetchGraph();