// 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 */}
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 (
);
}
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 */}
{/* 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) => (
))}
)}
{/* 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 (
);
}
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 });