// Radar — the relationship intelligence screen. Novel polar viz. // Center = you. Distance = days since meaningful exchange. Size = volume. Color = type. function RadarScreen({ go }) { const d = LifeData; const [activeId, setActiveId] = React.useState(null); const active = activeId && d.people.find(p => p.id === activeId); const W = 370, H = 330, CX = W/2, CY = H/2 - 4; const rings = [ { label: 'TODAY', r: 38, days: 1 }, { label: 'THIS WEEK', r: 80, days: 7 }, { label: 'THIS MONTH',r: 122, days: 30 }, { label: 'DRIFTED', r: 158, days: 90 }, ]; const maxR = 158; const dayToR = (days) => { if (days < 1) return 22 + days * 16; if (days < 7) return 38 + ((days - 1) / 6) * 42; if (days < 30) return 80 + ((days - 7) / 23) * 42; return Math.min(122 + ((days - 30) / 60) * 36, 162); }; const typeAngles = { family: -45, partner: 60, friend: 180, work: 285 }; const indexInType = {}; const defaultGradient = ['#8E8E93', '#636366']; const positioned = d.people.map((p) => { const pg = Array.isArray(p.gradient) && p.gradient.length >= 2 ? p.gradient : defaultGradient; const base = typeAngles[p.type] ?? 0; indexInType[p.type] = (indexInType[p.type] || 0); const spread = 120; const count = d.people.filter(x => x.type === p.type).length; const offset = count > 1 ? (indexInType[p.type] / (count - 1) - 0.5) * spread : 0; indexInType[p.type] += 1; const angle = (base + offset) * Math.PI / 180; const r = dayToR(p.days_since ?? 999); return { ...p, gradient: pg, x: CX + Math.cos(angle) * r, y: CY + Math.sin(angle) * r, r }; }); const dotSize = (msgs) => Math.max(6, Math.min(18, 5 + Math.sqrt(Number(msgs) || 0) * 0.7)); if (d.people.length === 0) { return (
Relationship Radar
Who am I orbiting?
No relationship data yet
Import your WhatsApp export to see your relationship radar and drift scores.
); } return (
{/* Header */}
Relationship Radar
Who am I orbiting?
{/* Radar */}
{positioned.map(p => ( ))} {rings.map(ring => ( {ring.label} ))} FAMILY PARTNER FRIENDS WORK {positioned.filter(p => p.ghost).map(p => ( ))} {positioned.map(p => { const size = dotSize(p.msgs_30d ?? p.msg_count ?? 0); const isActive = activeId === p.id; return ( setActiveId(p.id)} style={{ cursor: 'pointer' }}> {p.ghost && ( )} {isActive && ( )} {p.name} ); })}
ghosted size = volume {d.people.filter(p => p.days_since > 7).length} drifting
{/* Selected person preview */} {active && (
go('person', active.id)} style={{ background: grad(active.gradient[0], active.gradient[1]), color: '#fff', padding: 16, position: 'relative', overflow: 'hidden', }}>
{active.name} · {active.en !== active.name ? active.en : active.type}
{active.type}
{active.days_since === 0 ? 'TODAY' : `${active.days_since}D AGO`}
Open dossier
)} {/* Waiting on you */} {d.people.filter(p => p.ghost).length > 0 && (
{d.people.filter(p => p.ghost).map((p, i, arr) => (
go('person', p.id)} style={{ display: 'flex', alignItems: 'center', minHeight: 60, padding: '10px 14px', borderBottom: i < arr.length - 1 ? `0.5px solid ${T.sep}` : 'none', cursor: 'pointer', }}>
{p.name}
{p.days_since === 0 ? 'replied recently' : `${p.days_since}d ago`}
10 ? T.red : T.orange, padding: '4px 8px', borderRadius: 999, background: p.days_since > 10 ? 'rgba(255,59,48,0.10)' : 'rgba(255,149,0,0.12)', }}> {p.days_since > 30 ? 'COLD' : p.days_since > 10 ? 'DRIFTING' : 'OWED'}
))}
)}
); } function fmtMin(min) { if (min < 60) return `${min}m`; if (min < 1440) return `${Math.round(min / 60)}h`; return `${Math.round(min / 1440)}d`; } function Metric({ label, value, dark }) { return (
{label}
{value}
); } function Avatar({ p, size = 40 }) { return (
{(p.name || '?')[0]}
); } // ── Person detail (full screen) ───────────────────────────────────────────── function PersonScreen({ id, go }) { const d = LifeData; const p = d.people.find(x => x.id === id) || d.people[0]; if (!p) return null; const det = (d.personDetails && d.personDetails[p.id]) || null; const pg = Array.isArray(p.gradient) && p.gradient.length >= 2 ? p.gradient : ['#8E8E93', '#636366']; return (
{/* Hero */}
go('radar')} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 13, fontWeight: 500, opacity: 0.85, cursor: 'pointer', marginBottom: 10 }}> Radar
{p.name} {p.en !== p.name && · {p.en}}
{p.type}
{/* Last + pattern */} {det && (
Last exchange
{det.last_exchange}
Pattern
{det.pattern}
)} {/* Metrics grid — only show stats backed by real data */}
{det && 0} />} {det && }
{/* Topics — if personDetails available */} {det && det.topics && det.topics.length > 0 && (
{det.topics.map(t => ( {t} ))}
)} {/* Action items */} {det && det.action_items && det.action_items.length > 0 && (
{det.action_items.map((a, i) => (
{a}
))}
)} {/* Action buttons */}
); } function StatTile({ label, value, sub, bad }) { return (
{label}
{value}
{sub}
); } function Sparkline({ data, color }) { const W = 320, H = 60, pad = 4; const max = Math.max(...data), min = Math.min(...data); const range = max - min || 1; const pts = data.map((v, i) => { const x = pad + (i / (data.length - 1)) * (W - 2*pad); const y = pad + (1 - (v - min) / range) * (H - 2*pad); return [x, y]; }); const path = pts.map(([x,y], i) => (i === 0 ? `M${x},${y}` : `L${x},${y}`)).join(' '); const area = path + ` L${pts[pts.length-1][0]},${H} L${pts[0][0]},${H} Z`; return ( {pts.map(([x,y], i) => ( ))} ); } function _btnStyle(kind, accent = T.blue) { if (kind === 'primary') { return { flex: 1, height: 46, borderRadius: 14, border: 'none', cursor: 'pointer', background: accent, color: '#fff', fontSize: 15, fontWeight: 600, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, }; } return { flex: 1, height: 46, borderRadius: 14, border: 'none', cursor: 'pointer', background: T.bg2, color: T.label, fontSize: 15, fontWeight: 600, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, boxShadow: '0 1px 3px rgba(0,0,0,0.04)', }; } Object.assign(window, { RadarScreen, PersonScreen, Avatar, Metric, fmtMin });