<script>// Standalone Relationship Ecosystem Visualization
// No external dependencies - pure JavaScript with embedded React via CDN
const { useState, useRef, useCallback, createElement: h } = React;
// ─── DIMENSIONS from Self Assessment for Visualization survey ───
const DIMENSIONS = [
{
id: "relational",
label: "Relational Style",
color: "#E07B9A",
desc: "Openness to entanglement, commitment needs, multiple connections (Q24–29, Q38–39)",
questions: "How open to multiple partners, new situations, life intertwining, romantic experiences?",
},
{
id: "erotic",
label: "Erotic & Sensual",
color: "#C97BE0",
desc: "Desire, kink, power dynamics, pain, sensuality, frequency (Q18–23, Q32, Q34–36)",
questions: "Importance of kink, power dynamics, pain, sensual touch, erotic exploration, taboo, play frequency.",
},
{
id: "emotional",
label: "Emotional Nurturance",
color: "#7EB8C9",
desc: "Need for co-regulation, emotional processing, calming experiences (Q27–28, Q48–49)",
questions: "Importance of emotional processing, co-regulation, calming, empathy differences in relationship.",
},
{
id: "neurodivergence",
label: "Neurodivergence",
color: "#A8C47B",
desc: "Sensory, attention, communication, memory, stimming, time perception (Q44–59)",
questions: "Altered states, attention differences, communication style, sensory needs, memory, stimming, plurality.",
},
{
id: "mentalhealth",
label: "Mental Health Load",
color: "#E0C47B",
desc: "Current intensity: anxiety, depression, dissociation, hypervigilance (Q7–17)",
questions: "Intrusive thoughts, hypervigilance, dissociation, depression, anxiety, OCD, psychotic symptoms, substance use.",
},
{
id: "identity",
label: "Identity & Body",
color: "#FF9F7A",
desc: "Gender expansiveness, somatic awareness, identity & religious trauma (Q10–11, Q40–43)",
questions: "Identity-related trauma, gender identity, somatic/body awareness, breathwork, physical disabilities.",
},
{
id: "needs",
label: "Unmet Needs",
color: "#7BE0D0",
desc: "Gaps in community, basic needs, self-regulation, sex ed (Q1–4)",
questions: "Community gaps, basic needs (food/housing), difficulty with self-regulation, sex & relationship education gaps.",
},
{
id: "somatic",
label: "Somatic & Spiritual",
color: "#D4A0FF",
desc: "Body-based awareness, breathwork, spirituality and ritual in play (Q33, Q40–41)",
questions: "Importance of spirituality/sacredness, breath regulation, somatic body awareness in play and relationships.",
},
];
const DEFAULT_PEOPLE = [
{ id: 1, name: "You", x: 420, y: 310, color: "#FFFFFF" },
{ id: 2, name: "Alex", x: 210, y: 155, color: "#E07B9A" },
{ id: 3, name: "River", x: 630, y: 175, color: "#C97BE0" },
{ id: 4, name: "Sam", x: 640, y: 430, color: "#A8C47B" },
];
const DEFAULT_RELATIONSHIPS = [
{
id: 1, from: 1, to: 2,
dims: { relational: 0.8, erotic: 0.85, emotional: 0.7, neurodivergence: 0.5, mentalhealth: 0.4, identity: 0.6, needs: 0.3, somatic: 0.5 }
},
{
id: 2, from: 1, to: 3,
dims: { relational: 0.2, erotic: 0.65, emotional: 0.3, neurodivergence: 0.2, mentalhealth: 0.2, identity: 0.15, needs: 0.1, somatic: 0.4 }
},
{
id: 3, from: 1, to: 4,
dims: { relational: 0.5, erotic: 0.0, emotional: 0.95, neurodivergence: 0.75, mentalhealth: 0.6, identity: 0.8, needs: 0.7, somatic: 0.3 }
},
];
function hexToRgb(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return { r, g, b };
}
function RadarChart({ dims, size = 130 }) {
const cx = size / 2, cy = size / 2;
const r = size * 0.34;
const n = DIMENSIONS.length;
const angles = DIMENSIONS.map((_, i) => (i / n) * Math.PI * 2 - Math.PI / 2);
const getPoint = (angle, val) => ({
x: cx + Math.cos(angle) * r * val,
y: cy + Math.sin(angle) * r * val,
});
const dataPoints = DIMENSIONS.map((d, i) => getPoint(angles[i], dims[d.id] || 0));
const dataPath = dataPoints.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ") + " Z";
return h('svg', { width: size, height: size, style: { overflow: "visible" } }, [
// Grid lines
...([0.25, 0.5, 0.75, 1.0].map((lv, idx) => {
const pts = angles.map(a => getPoint(a, lv));
const path = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ") + " Z";
return h('path', { key: `grid-${idx}`, d: path, fill: "none", stroke: "rgba(255,255,255,0.07)", strokeWidth: "1" });
})),
// Radial lines
...angles.map((a, i) => {
const end = getPoint(a, 1);
return h('line', { key: `radial-${i}`, x1: cx, y1: cy, x2: end.x, y2: end.y, stroke: "rgba(255,255,255,0.08)", strokeWidth: "1" });
}),
// Data area
h('path', { d: dataPath, fill: "rgba(180,140,255,0.15)", stroke: "rgba(180,140,255,0.55)", strokeWidth: "1.5" }),
// Data points
...DIMENSIONS.map((d, i) => {
const val = dims[d.id] || 0;
const pt = getPoint(angles[i], val);
return h('circle', { key: `point-${d.id}`, cx: pt.x, cy: pt.y, r: 3, fill: d.color, opacity: 0.9 });
}),
// Labels
...DIMENSIONS.map((d, i) => {
const labelPt = getPoint(angles[i], 1.38);
return h('text', {
key: `label-${d.id}`,
x: labelPt.x,
y: labelPt.y,
textAnchor: "middle",
dominantBaseline: "middle",
fontSize: "6",
fill: d.color,
fontFamily: "'DM Mono', monospace",
opacity: 0.82
}, d.label.split(" ")[0]);
})
]);
}
function ConnectionLine({ from, to, rel }) {
const mx = (from.x + to.x) / 2;
const my = (from.y + to.y) / 2;
const dx = to.x - from.x;
const dy = to.y - from.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const norm = { x: -dy / dist, y: dx / dist };
const curve = dist * 0.18;
const cp = { x: mx + norm.x * curve, y: my + norm.y * curve };
return h('g', {}, DIMENSIONS.map((dim, i) => {
const val = rel.dims[dim.id] || 0;
if (val < 0.05) return null;
const offset = (i - (DIMENSIONS.length - 1) / 2) * 2.6;
const fx = from.x + norm.x * offset;
const fy = from.y + norm.y * offset;
const tx = to.x + norm.x * offset;
const ty = to.y + norm.y * offset;
const cpx = cp.x + norm.x * offset;
const cpy = cp.y + norm.y * offset;
const rgb = hexToRgb(dim.color);
return h('path', {
key: dim.id,
d: `M${fx},${fy} Q${cpx},${cpy} ${tx},${ty}`,
fill: "none",
stroke: `rgba(${rgb.r},${rgb.g},${rgb.b},${0.12 + val * 0.72})`,
strokeWidth: 0.7 + val * 3.8,
strokeLinecap: "round"
});
}).filter(Boolean));
}
function PersonNode({ person, isSelected, onClick, onDrag }) {
const isDragging = useRef(false);
const startPos = useRef({ x: 0, y: 0, px: 0, py: 0 });
const handleMouseDown = (e) => {
isDragging.current = false;
startPos.current = { x: e.clientX, y: e.clientY, px: person.x, py: person.y };
const onMove = (ev) => {
const moved = Math.abs(ev.clientX - startPos.current.x) + Math.abs(ev.clientY - startPos.current.y);
if (moved > 3) isDragging.current = true;
onDrag(person.id, startPos.current.px + ev.clientX - startPos.current.x, startPos.current.py + ev.clientY - startPos.current.y);
};
const onUp = () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
if (!isDragging.current) onClick(person.id);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
e.preventDefault();
};
return h('g', {
transform: `translate(${person.x},${person.y})`,
style: { cursor: "grab" },
onMouseDown: handleMouseDown
}, [
// Selection ring
isSelected && h('circle', {
key: 'selection',
r: 34,
fill: "none",
stroke: person.color,
strokeWidth: "1.5",
opacity: 0.35,
style: { animation: "pulse 2s ease-in-out infinite" }
}),
// Main circle
h('circle', {
key: 'main',
r: 26,
fill: "rgba(18,14,28,0.9)",
stroke: person.color,
strokeWidth: isSelected ? 2 : 1.2,
opacity: isSelected ? 1 : 0.85
}),
// Inner circle
h('circle', { key: 'inner', r: 10, fill: person.color, opacity: 0.22 }),
// Center dot
h('circle', { key: 'center', r: 4, fill: person.color }),
// Label
h('text', {
key: 'label',
y: 40,
textAnchor: "middle",
fontSize: "11",
fill: person.color,
fontFamily: "'DM Mono', monospace",
fontWeight: "500",
letterSpacing: "0.05em"
}, person.name)
].filter(Boolean));
}
function RelationshipEcosystem() {
const [people, setPeople] = useState(DEFAULT_PEOPLE);
const [relationships, setRelationships] = useState(DEFAULT_RELATIONSHIPS);
const [selectedRel, setSelectedRel] = useState(1);
const [selectedPerson, setSelectedPerson] = useState(null);
const [activeDim, setActiveDim] = useState(null);
const [addingPerson, setAddingPerson] = useState(false);
const [newName, setNewName] = useState("");
const [view, setView] = useState("ecosystem");
const nextId = useRef(5);
const rel = relationships.find(r => r.id === selectedRel);
const fromPerson = rel ? people.find(p => p.id === rel.from) : null;
const toPerson = rel ? people.find(p => p.id === rel.to) : null;
const handleDrag = useCallback((id, x, y) => {
setPeople(ps => ps.map(p => p.id === id
? { ...p, x: Math.max(60, Math.min(780, x)), y: Math.max(60, Math.min(560, y)) }
: p
));
}, []);
const handlePersonClick = (id) => {
setSelectedPerson(prev => prev === id ? null : id);
if (id === 1) return;
const existing = relationships.find(r => (r.from === 1 && r.to === id) || (r.from === id && r.to === 1));
if (existing) setSelectedRel(existing.id);
};
const updateDim = (dimId, val) => {
setRelationships(rs => rs.map(r =>
r.id === selectedRel ? { ...r, dims: { ...r.dims, [dimId]: val } } : r
));
};
const addPerson = () => {
if (!newName.trim()) return;
const id = nextId.current++;
const angle = Math.random() * Math.PI * 2;
const radius = 150 + Math.random() * 70;
const colors = ["#E07B9A","#C97BE0","#7EB8C9","#A8C47B","#E0C47B","#FF9F7A","#7BE0D0","#D4A0FF"];
const color = colors[Math.floor(Math.random() * colors.length)];
const newPerson = { id, name: newName.trim(), x: 420 + Math.cos(angle) * radius, y: 310 + Math.sin(angle) * radius, color };
const newRelId = nextId.current++;
const emptyDims = Object.fromEntries(DIMENSIONS.map(d => [d.id, 0]));
setPeople(ps => [...ps, newPerson]);
setRelationships(rs => [...rs, { id: newRelId, from: 1, to: id, dims: emptyDims }]);
setSelectedRel(newRelId);
setNewName("");
setAddingPerson(false);
};
const removePerson = (id) => {
if (id === 1) return;
setPeople(ps => ps.filter(p => p.id !== id));
setRelationships(rs => rs.filter(r => r.from !== id && r.to !== id));
setSelectedPerson(null);
setSelectedRel(null);
};
const romanticNormDims = Object.fromEntries(DIMENSIONS.map(d => [d.id, 1]));
// Main render
return h('div', {
style: {
minHeight: "100vh",
background: "#0d0b16",
fontFamily: "'DM Mono', monospace",
color: "#e8e4f0",
display: "flex",
flexDirection: "column",
overflow: "hidden"
}
}, [
// Styles
h('style', {}, `
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300&family=Cormorant+Garamond:ital,wght@0,300;0,400;1,300;1,400&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; }
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
@keyframes pulse { 0%,100%{opacity:0.3} 50%{opacity:0.6} }
@keyframes fadeIn { from{opacity:0;transform:translateY(5px)} to{opacity:1;transform:translateY(0)} }
input[type=range] { -webkit-appearance:none; appearance:none; height:3px; border-radius:2px; outline:none; cursor:pointer; display:block; width:100%; }
input[type=range]::-webkit-slider-thumb { -webkit-appearance:none; width:13px; height:13px; border-radius:50%; cursor:pointer; border:2px solid #0d0b16; background:#e8e4f0; }
.tab-btn { background:none; border:none; cursor:pointer; font-family:'DM Mono',monospace; font-size:10px; letter-spacing:0.12em; text-transform:uppercase; padding:6px 14px; border-radius:20px; transition:all 0.2s; }
.node-btn { background:none; border:1px solid rgba(255,255,255,0.12); cursor:pointer; font-family:'DM Mono',monospace; font-size:10px; color:rgba(255,255,255,0.5); padding:4px 10px; border-radius:12px; transition:all 0.2s; }
.node-btn:hover { border-color:rgba(255,255,255,0.3); color:rgba(255,255,255,0.8); }
.dim-toggle { background:none; border:none; cursor:pointer; text-align:left; width:100%; padding:0; font-family:'DM Mono',monospace; }
input[type=text] { background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.15); border-radius:6px; color:#e8e4f0; font-family:'DM Mono',monospace; font-size:11px; padding:5px 10px; outline:none; }
input[type=text]:focus { border-color:rgba(255,255,255,0.35); }
`),
// Header
h('div', {
style: {
padding: "16px 24px 12px",
borderBottom: "1px solid rgba(255,255,255,0.06)",
display: "flex",
alignItems: "center",
justifyContent: "space-between"
}
}, [
h('div', {}, [
h('div', {
style: {
fontSize: 9,
letterSpacing: "0.18em",
color: "rgba(255,255,255,0.22)",
textTransform: "uppercase",
marginBottom: 3
}
}, "relational ecosystem"),
h('div', {
style: {
fontFamily: "'Cormorant Garamond', serif",
fontSize: 20,
fontWeight: 300,
fontStyle: "italic"
}
}, "mapping love in all its shapes")
]),
h('div', {
style: {
display: "flex",
gap: 4,
background: "rgba(255,255,255,0.04)",
borderRadius: 20,
padding: 3
}
}, ["ecosystem", "compare"].map(v =>
h('button', {
key: v,
className: "tab-btn",
onClick: () => setView(v),
style: {
color: view === v ? "#0d0b16" : "rgba(255,255,255,0.4)",
background: view === v ? "rgba(255,255,255,0.85)" : "transparent"
}
}, v)
))
]),
// Main content based on view
view === "ecosystem" ?
// Ecosystem view
h('div', { style: { display: "flex", flex: 1, overflow: "hidden" } }, [
// SVG Canvas
h('div', { style: { flex: 1, position: "relative" } }, [
h('svg', {
width: "100%",
height: "100%",
viewBox: "0 0 840 620",
style: { display: "block" }
}, [
// Background and grid
h('defs', {}, [
h('radialGradient', { id: "bg2", cx: "50%", cy: "50%" }, [
h('stop', { offset: "0%", stopColor: "#1a1628" }),
h('stop', { offset: "100%", stopColor: "#0d0b16" })
])
]),
h('rect', { width: "840", height: "620", fill: "url(#bg2)" }),
// Grid lines
...Array.from({ length: 17 }, (_, i) =>
h('line', { key: `v${i}`, x1: i*52, y1: 0, x2: i*52, y2: 620, stroke: "rgba(255,255,255,0.016)", strokeWidth: "1" })
),
...Array.from({ length: 13 }, (_, i) =>
h('line', { key: `h${i}`, x1: 0, y1: i*52, x2: 840, y2: i*52, stroke: "rgba(255,255,255,0.016)", strokeWidth: "1" })
),
// Relationships
...relationships.map(r => {
const from = people.find(p => p.id === r.from);
const to = people.find(p => p.id === r.to);
if (!from || !to) return null;
return h('g', {
key: r.id,
style: { cursor: "pointer" },
onClick: () => { setSelectedRel(r.id); setSelectedPerson(r.to); }
}, [
h(ConnectionLine, { from, to, rel: r }),
h('line', { x1: from.x, y1: from.y, x2: to.x, y2: to.y, stroke: "transparent", strokeWidth: "20" })
]);
}).filter(Boolean),
// People
...people.map(p =>
h(PersonNode, {
key: p.id,
person: p,
isSelected: selectedPerson === p.id || (rel && (rel.from === p.id || rel.to === p.id)),
onClick: handlePersonClick,
onDrag: handleDrag
})
)
]),
// Legend
h('div', {
style: {
position: "absolute",
bottom: 14,
left: 14,
display: "flex",
flexDirection: "column",
gap: 5
}
}, DIMENSIONS.map(d =>
h('div', {
key: d.id,
style: { display: "flex", alignItems: "center", gap: 8 }
}, [
h('div', {
style: {
width: 18,
height: 2.5,
borderRadius: 2,
background: d.color,
opacity: 0.7
}
}),
h('span', {
style: {
fontSize: 8.5,
color: "rgba(255,255,255,0.3)",
letterSpacing: "0.07em"
}
}, d.label)
])
)),
// Add person controls
h('div', {
style: { position: "absolute", top: 12, right: 12 }
}, addingPerson ?
h('div', { style: { display: "flex", gap: 6 } }, [
h('input', {
type: "text",
value: newName,
onChange: e => setNewName(e.target.value),
onKeyDown: e => {
if (e.key === "Enter") addPerson();
if (e.key === "Escape") setAddingPerson(false);
},
placeholder: "their name",
autoFocus: true,
style: { width: 110 }
}),
h('button', {
className: "node-btn",
onClick: addPerson,
style: {
color: "rgba(180,255,180,0.7)",
borderColor: "rgba(180,255,180,0.2)"
}
}, "add"),
h('button', {
className: "node-btn",
onClick: () => setAddingPerson(false)
}, "×")
]) :
h('button', {
className: "node-btn",
onClick: () => setAddingPerson(true)
}, "+ add person")
)
]),
// Side panel with relationship editing
h('div', {
style: {
width: 300,
borderLeft: "1px solid rgba(255,255,255,0.06)",
padding: "18px 18px",
overflowY: "auto",
display: "flex",
flexDirection: "column",
gap: 0,
background: "rgba(10,9,18,0.65)"
}
}, rel && fromPerson && toPerson ? [
// Relationship editor content would go here
h('div', { style: { color: "rgba(255,255,255,0.6)" } }, `Editing ${fromPerson.name} & ${toPerson.name}`)
] : [
h('div', {
style: {
color: "rgba(255,255,255,0.2)",
fontSize: 11,
fontStyle: "italic",
lineHeight: 1.8,
paddingTop: 8
}
}, "Click a connection to edit")
])
]) :
// Compare view
h('div', {
style: {
flex: 1,
overflow: "auto",
padding: "24px 32px"
}
}, [
h('div', { style: { marginBottom: 22 } }, [
h('div', {
style: {
fontFamily: "'Cormorant Garamond', serif",
fontSize: 18,
fontStyle: "italic",
color: "rgba(255,255,255,0.6)",
marginBottom: 6
}
}, "the myth of the one"),
h('div', {
style: {
fontSize: 10,
color: "rgba(255,255,255,0.25)",
lineHeight: 1.75,
maxWidth: 580
}
}, "romantic normativity demands one person match you at 5/5 across every dimension — relational style, erotic needs, emotional load, neurodivergent wiring, mental health, identity, unmet needs, somatic capacity. an architecture designed to fail.")
]),
h('div', {
style: { display: "flex", flexWrap: "wrap", gap: 20 }
}, [
// Myth card
h('div', {
style: {
background: "rgba(255,255,255,0.02)",
border: "1px solid rgba(255,80,80,0.15)",
borderRadius: 12,
padding: 20,
minWidth: 210
}
}, [
h('div', {
style: {
fontSize: 8.5,
letterSpacing: "0.15em",
color: "rgba(255,80,80,0.5)",
textTransform: "uppercase",
marginBottom: 8
}
}, "the myth"),
h('div', {
style: {
fontFamily: "'Cormorant Garamond', serif",
fontSize: 13,
fontStyle: "italic",
color: "rgba(255,255,255,0.4)",
marginBottom: 14
}
}, '"one person, all 8 dimensions, 5/5"'),
h(RadarChart, { dims: romanticNormDims, size: 140 })
])
])
])
]);
}
// Initialize the app
function initializeApp() {
const container = document.getElementById('relationship-ecosystem');
if (container) {
const root = ReactDOM.createRoot(container);
root.render(React.createElement(RelationshipEcosystem));
}
}
// Auto-initialize if DOM is ready, otherwise wait for it
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeApp);
} else {
initializeApp();
}
// Export for manual initialization
window.RelationshipEcosystem = {
init: initializeApp,
component: RelationshipEcosystem
}</script>