Last active
December 21, 2025 02:30
-
-
Save ejfox/0df331fa3f0cd4a7417ce1f44b542914 to your computer and use it in GitHub Desktop.
SCRAP_ZONE - Mobile-first scrapbook with D3 entity graphs + MapLibre maps
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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