Skip to content

Instantly share code, notes, and snippets.

@ejfox
Last active December 21, 2025 02:30
Show Gist options
  • Select an option

  • Save ejfox/0df331fa3f0cd4a7417ce1f44b542914 to your computer and use it in GitHub Desktop.

Select an option

Save ejfox/0df331fa3f0cd4a7417ce1f44b542914 to your computer and use it in GitHub Desktop.
SCRAP_ZONE - Mobile-first scrapbook with D3 entity graphs + MapLibre maps
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#0a0a0a">
<title>SCRAP</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.js"></script>
<link href="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.css" rel="stylesheet" />
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
:root {
--text: #c8c8c8;
--dim: #666;
--faint: #444;
--bg: #0a0a0a;
}
html, body {
height: 100%;
overflow: hidden;
background: var(--bg);
color: var(--text);
font: 11px/1.5 'IBM Plex Sans', sans-serif;
}
#feed {
height: 100%;
overflow-y: scroll;
scroll-snap-type: y proximity;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
touch-action: pan-y;
}
.card {
min-height: 100dvh;
scroll-snap-align: start;
padding: 12px;
padding-top: max(36px, env(safe-area-inset-top));
padding-bottom: max(12px, env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
border-bottom: 1px solid var(--faint);
}
/* Typography */
.src { color: var(--dim); font-size: 9px; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
.src a { color: inherit; text-decoration: none; }
.src a:hover { text-decoration: underline; }
.title {
font: 500 15px/1.35 'IBM Plex Sans', sans-serif;
color: #e8e8e8;
margin-bottom: 8px;
}
.summary {
font-size: 12px;
line-height: 1.65;
color: var(--text);
margin-bottom: 10px;
white-space: pre-wrap;
}
.summary strong { color: #fff; font-weight: 500; }
/* Inline metadata row */
.meta {
font-size: 10px;
color: var(--dim);
margin-bottom: 12px;
line-height: 1.7;
}
.meta span { margin-right: 12px; }
.meta .val { color: var(--text); }
/* Data sections - minimal */
.data { font-size: 10px; line-height: 1.6; margin-bottom: 10px; }
.data-label { color: var(--dim); text-transform: uppercase; font-size: 9px; letter-spacing: 0.3px; }
/* Entities - compact inline */
.ents { color: var(--dim); }
.ent { color: var(--text); }
.ent-rel { color: var(--faint); font-size: 9px; }
/* Graph - smaller, tighter */
.graph { width: 100%; height: 180px; margin: 8px 0; touch-action: pan-y; }
.graph svg { width: 100%; height: 100%; pointer-events: none; }
.graph svg text { pointer-events: auto; }
.g-link { stroke: var(--faint); stroke-width: 0.5px; }
.g-node { font-size: 8px; fill: var(--dim); cursor: grab; }
.g-label { font-size: 6px; fill: var(--faint); text-anchor: middle; }
/* Map - compact */
.map { width: 100%; height: 120px; margin: 6px 0; touch-action: pan-y; }
.map-marker { width: 6px; height: 6px; background: var(--text); border-radius: 50%; }
/* Finance - inline numbers */
.fin { display: flex; flex-wrap: wrap; gap: 12px; align-items: baseline; }
.fin-sent { font: 500 16px 'IBM Plex Mono', monospace; color: var(--text); }
.fin-assets { font-size: 10px; color: var(--dim); }
.fin-asset { margin-right: 10px; }
.fin-asset .tk { color: var(--text); font-family: 'IBM Plex Mono', monospace; }
.fin-asset .sc { color: var(--dim); }
.fin-reason { font-size: 10px; color: var(--dim); margin-top: 6px; line-height: 1.5; }
/* Concepts - tight inline */
.concepts { font-size: 10px; color: var(--dim); }
.concept { color: var(--text); margin-right: 8px; }
/* Footer */
.foot {
margin-top: auto;
padding-top: 12px;
font-size: 9px;
color: var(--dim);
display: flex;
justify-content: space-between;
}
.foot a { color: var(--text); text-decoration: none; }
/* Position indicator */
#pos {
position: fixed;
top: max(8px, env(safe-area-inset-top));
right: 10px;
font: 9px 'IBM Plex Mono', monospace;
color: var(--faint);
z-index: 100;
}
#loading {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
z-index: 200;
font-size: 9px;
color: var(--dim);
}
#loading.hidden { display: none; }
/* Network overlay */
#network {
position: fixed;
inset: 0;
z-index: 300;
background: var(--bg);
display: none;
flex-direction: column;
}
#network.show { display: flex; }
#net-head {
padding: 10px 14px;
padding-top: max(10px, env(safe-area-inset-top));
font-size: 9px;
color: var(--dim);
display: flex;
justify-content: space-between;
border-bottom: 1px solid var(--faint);
}
#net-close { color: var(--text); background: none; border: none; font: inherit; cursor: pointer; }
#net-graph { flex: 1; overflow: hidden; }
#net-graph svg { width: 100%; height: 100%; }
.net-link { stroke: #222; stroke-width: 0.4px; }
.net-node { font-size: 6px; fill: var(--dim); cursor: grab; }
.net-node.hub { font-size: 8px; fill: var(--text); }
</style>
</head>
<body>
<div id="loading">...</div>
<div id="pos"></div>
<div id="feed"></div>
<div id="network">
<div id="net-head">
<span>NETWORK <span id="net-stats"></span></span>
<button id="net-close" onclick="hideNetwork()">close</button>
</div>
<div id="net-graph"></div>
</div>
<script>
const API = 'https://xmdylmbdeulxcqdbkfno.supabase.co';
const KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhtZHlsbWJkZXVseGNxZGJrZm5vIiwicm9sZSI6ImFub24iLCJpYXQiOjE2ODk5NTM0NjAsImV4cCI6MjAwNTUyOTQ2MH0.jspo2sHRd4RSN8jL8DYIfTdfZVoGZRcbiZL0MpHo8yI';
let scraps = [], idx = 0, offset = 0, busy = false, more = true;
const LIMIT = 30;
const esc = s => !s ? '' : String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fmt = s => !s ? '' : esc(s).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\n{3,}/g, '\n\n');
const ago = d => {
const days = Math.floor((Date.now() - new Date(d)) / 864e5);
if (days === 0) return 'today';
if (days === 1) return '1d';
if (days < 30) return days + 'd';
if (days < 365) return Math.floor(days / 30) + 'mo';
return Math.floor(days / 365) + 'y';
};
const domain = u => { try { return new URL(u).hostname.replace('www.',''); } catch { return ''; } };
const pct = n => (n >= 0 ? '+' : '') + (n * 100).toFixed(0) + '%';
const coord = (lat, lng) => `${lat.toFixed(2)}°, ${lng.toFixed(2)}°`;
const renderEnts = (rels, i) => {
if (!rels?.length) return '';
// Compact entity list + small graph
const pairs = rels.slice(0, 6).map(r =>
`<span class="ent">${esc(r.source)}</span> <span class="ent-rel">${esc(r.relationship)}</span> <span class="ent">${esc(r.target)}</span>`
).join(' · ');
const more = rels.length > 6 ? ` <span class="ent-rel">+${rels.length - 6}</span>` : '';
return `<div class="data">
<span class="data-label">entities</span> ${pairs}${more}
<div class="graph" id="graph-${i}"></div>
</div>`;
};
const initGraph = (i, rels) => {
const box = document.getElementById('graph-' + i);
if (!box || !rels?.length) return;
const w = box.clientWidth, h = box.clientHeight;
const nodeSet = new Set();
rels.forEach(r => { nodeSet.add(r.source); nodeSet.add(r.target); });
const nodes = Array.from(nodeSet).map(id => ({ id, label: id.length > 12 ? id.slice(0,10)+'…' : id }));
const nodeMap = Object.fromEntries(nodes.map((n,j) => [n.id, j]));
const links = rels.map(r => ({ source: nodeMap[r.source], target: nodeMap[r.target], type: r.relationship }));
const svg = d3.select(box).append('svg').attr('viewBox', [0,0,w,h]);
const sim = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).distance(45))
.force('charge', d3.forceManyBody().strength(-50))
.force('center', d3.forceCenter(w/2, h/2))
.force('collision', d3.forceCollide().radius(22));
const link = svg.append('g').selectAll('line').data(links).join('line').attr('class','g-link');
const label = svg.append('g').selectAll('text').data(links).join('text').attr('class','g-label')
.text(d => d.type.length > 8 ? d.type.slice(0,6)+'…' : d.type);
const node = svg.append('g').selectAll('g').data(nodes).join('g')
.call(d3.drag().on('start',(e,d)=>{if(!e.active)sim.alphaTarget(0.3).restart();d.fx=d.x;d.fy=d.y;})
.on('drag',(e,d)=>{d.fx=e.x;d.fy=e.y;}).on('end',(e,d)=>{if(!e.active)sim.alphaTarget(0);d.fx=null;d.fy=null;}));
node.append('text').attr('class','g-node').attr('text-anchor','middle').attr('dy',2).text(d=>d.label);
sim.on('tick', () => {
nodes.forEach(d => { d.x = Math.max(30, Math.min(w-30, d.x)); d.y = Math.max(12, Math.min(h-12, d.y)); });
link.attr('x1',d=>d.source.x).attr('y1',d=>d.source.y).attr('x2',d=>d.target.x).attr('y2',d=>d.target.y);
label.attr('x',d=>(d.source.x+d.target.x)/2).attr('y',d=>(d.source.y+d.target.y)/2);
node.attr('transform',d=>`translate(${d.x},${d.y})`);
});
};
const renderLoc = (s, i) => {
if (!s.latitude || !s.longitude) return '';
return `<div class="data">
<span class="data-label">loc</span> <span class="val">${esc(s.location) || coord(s.latitude, s.longitude)}</span>
<div class="map" id="map-${i}"></div>
</div>`;
};
const initMap = (i, lat, lng) => {
const box = document.getElementById('map-' + i);
if (!box) return;
const map = new maplibregl.Map({
container: box,
style: { version: 8, sources: { 'carto': { type: 'raster', tiles: ['https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png'], tileSize: 256 } }, layers: [{ id: 'carto', type: 'raster', source: 'carto' }] },
center: [lng, lat], zoom: 8, attributionControl: false, interactive: false
});
const el = document.createElement('div'); el.className = 'map-marker';
new maplibregl.Marker({ element: el }).setLngLat([lng, lat]).addTo(map);
};
const renderFin = s => {
const fa = s.financial_analysis;
if (!fa) return '';
const assets = (fa.assets || []).slice(0, 4).map(a =>
`<span class="fin-asset"><span class="tk">${esc(a.ticker)}</span> <span class="sc">${pct(a.sentiment_score)}</span></span>`
).join('');
return `<div class="data">
<div class="fin">
<span class="fin-sent">${pct(fa.overall_market_sentiment || 0)}</span>
<span class="fin-assets">${assets}</span>
</div>
${fa.market_reasoning ? `<div class="fin-reason">${esc(fa.market_reasoning).slice(0, 200)}${fa.market_reasoning.length > 200 ? '…' : ''}</div>` : ''}
</div>`;
};
const renderConcepts = s => {
if (!s.concept_tags?.length) return '';
const tags = s.concept_tags.slice(0, 8).map(t => `<span class="concept">${esc(t.replace(/_/g,' '))}</span>`).join('');
return `<div class="data concepts"><span class="data-label">concepts</span> ${tags}</div>`;
};
const renderCard = (s, i) => {
const tags = s.tags?.slice(0, 4).join(' · ') || '';
return `<div class="card" data-i="${i}">
<div class="src">
<a href="${esc(s.url)}" target="_blank">${domain(s.url)}</a> · ${esc(s.source) || '?'} · ${esc(s.content_type) || 'item'} · ${ago(s.created_at)}
</div>
<h1 class="title">${esc(s.title) || 'untitled'}</h1>
${s.summary ? `<div class="summary">${fmt(s.summary)}</div>` : ''}
${tags ? `<div class="meta"><span class="val">${tags}</span></div>` : ''}
${renderEnts(s.relationships, i)}
${renderLoc(s, i)}
${renderFin(s)}
${renderConcepts(s)}
<div class="foot">
<span>${i + 1}</span>
<a href="${esc(s.url)}" target="_blank">→</a>
</div>
</div>`;
};
const updateUI = () => { document.getElementById('pos').textContent = `${idx+1}/${scraps.length}${more?'+':''}`; };
const load = async (append = false) => {
if (busy) return;
busy = true;
if (!append) document.getElementById('loading').classList.remove('hidden');
try {
const r = await fetch(`${API}/rest/v1/scraps?select=id,title,url,created_at,screenshot_url,source,tags,summary,relationships,concept_tags,content_type,latitude,longitude,location,financial_analysis&order=created_at.desc&limit=${LIMIT}&offset=${offset}`,
{ headers: { 'apikey': KEY, 'Authorization': `Bearer ${KEY}` } });
if (!r.ok) throw new Error(r.status);
const data = await r.json();
more = data.length === LIMIT;
if (append) {
const start = scraps.length;
scraps = [...scraps, ...data];
const feed = document.getElementById('feed');
data.forEach((s, j) => {
const d = document.createElement('div');
d.innerHTML = renderCard(s, start + j);
feed.appendChild(d.firstElementChild);
});
setTimeout(() => {
data.forEach((s, j) => {
if (s.relationships?.length) initGraph(start + j, s.relationships);
if (s.latitude && s.longitude) initMap(start + j, s.latitude, s.longitude);
});
}, 50);
} else {
scraps = data;
document.getElementById('feed').innerHTML = scraps.map((s, j) => renderCard(s, j)).join('');
setTimeout(() => {
scraps.forEach((s, j) => {
if (s.relationships?.length) initGraph(j, s.relationships);
if (s.latitude && s.longitude) initMap(j, s.latitude, s.longitude);
});
}, 50);
}
offset += data.length;
document.getElementById('loading').classList.add('hidden');
updateUI();
} catch (e) {
document.getElementById('loading').textContent = 'err: ' + e.message;
}
busy = false;
};
const checkMore = () => {
if (!more || busy) return;
const feed = document.getElementById('feed');
if (feed.scrollHeight - feed.scrollTop - feed.clientHeight < 400) load(true);
};
document.getElementById('feed').addEventListener('scroll', e => {
const newIdx = Math.round(e.target.scrollTop / window.innerHeight);
if (newIdx !== idx) { idx = newIdx; updateUI(); }
checkMore();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { hideNetwork(); return; }
if (document.getElementById('network').classList.contains('show')) return;
const feed = document.getElementById('feed');
if (e.key === 'j' || e.key === 'ArrowDown') { e.preventDefault(); if (idx < scraps.length-1) { idx++; feed.scrollTo({top: idx*window.innerHeight, behavior:'smooth'}); updateUI(); } }
else if (e.key === 'k' || e.key === 'ArrowUp') { e.preventDefault(); if (idx > 0) { idx--; feed.scrollTo({top: idx*window.innerHeight, behavior:'smooth'}); updateUI(); } }
else if (e.key === 'o' || e.key === 'Enter') { window.open(scraps[idx]?.url, '_blank'); }
else if (e.key === 'n') { showNetwork(); }
});
let netSim = null;
window.showNetwork = () => { document.getElementById('network').classList.add('show'); buildNet(); };
window.hideNetwork = () => { document.getElementById('network').classList.remove('show'); if(netSim){netSim.stop();netSim=null;} document.getElementById('net-graph').innerHTML=''; };
const buildNet = () => {
const box = document.getElementById('net-graph');
box.innerHTML = '';
const w = box.clientWidth, h = box.clientHeight;
const allRels = [], count = {};
scraps.forEach(s => {
(s.relationships || []).forEach(r => {
allRels.push(r);
count[r.source] = (count[r.source]||0)+1;
count[r.target] = (count[r.target]||0)+1;
});
});
if (!allRels.length) { box.innerHTML = '<div style="padding:40px;color:var(--dim)">no entities</div>'; return; }
const nodeSet = new Set();
allRels.forEach(r => { nodeSet.add(r.source); nodeSet.add(r.target); });
const nodes = Array.from(nodeSet).map(id => ({ id, label: id.length > 12 ? id.slice(0,10)+'…' : id, count: count[id]||1 }));
const maxC = Math.max(...nodes.map(n => n.count));
const hubT = Math.max(3, maxC * 0.25);
const nodeMap = Object.fromEntries(nodes.map((n,i) => [n.id, i]));
const seen = new Set();
const links = [];
allRels.forEach(r => {
const k = `${r.source}|${r.target}`;
if (!seen.has(k)) { seen.add(k); links.push({ source: nodeMap[r.source], target: nodeMap[r.target] }); }
});
document.getElementById('net-stats').textContent = `${nodes.length}n ${links.length}e`;
const svg = d3.select(box).append('svg').attr('width',w).attr('height',h);
const g = svg.append('g');
svg.call(d3.zoom().scaleExtent([0.1,4]).on('zoom', e => g.attr('transform', e.transform)));
netSim = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).distance(35).strength(0.2))
.force('charge', d3.forceManyBody().strength(-15))
.force('center', d3.forceCenter(w/2, h/2))
.force('collision', d3.forceCollide().radius(12));
const link = g.append('g').selectAll('line').data(links).join('line').attr('class','net-link');
const node = g.append('g').selectAll('g').data(nodes).join('g')
.call(d3.drag().on('start',(e,d)=>{if(!e.active)netSim.alphaTarget(0.3).restart();d.fx=d.x;d.fy=d.y;})
.on('drag',(e,d)=>{d.fx=e.x;d.fy=e.y;}).on('end',(e,d)=>{if(!e.active)netSim.alphaTarget(0);d.fx=null;d.fy=null;}));
node.append('text').attr('class',d=>d.count>=hubT?'net-node hub':'net-node').attr('text-anchor','middle').attr('dy',2).text(d=>d.label);
netSim.on('tick', () => {
link.attr('x1',d=>d.source.x).attr('y1',d=>d.source.y).attr('x2',d=>d.target.x).attr('y2',d=>d.target.y);
node.attr('transform',d=>`translate(${d.x},${d.y})`);
});
setTimeout(() => svg.call(d3.zoom().transform, d3.zoomIdentity.translate(w*0.15, h*0.15).scale(0.7)), 200);
};
load();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment