// Right-side inspector for either the selected node or edge
function Inspector({
selection, setSelection,
nodes, edges, groups, writings,
updateNode, updateEdge, updateWriting,
deleteNode, deleteEdge, deleteWriting,
addNode, addWriting, clearAll,
onLoadDemo,
createGroupFromNode, updateGroup, deleteGroup,
composingGroupId, setComposingGroupId,
}){
groups = groups || [];
writings = writings || [];
if (!selection){
return (
Getting started
・Click + Node to add a character
・Drag the + on the top-right of an avatar onto another character to create a relationship line
・Click a line or a node to edit it
+ Add a node
+ Add a writing
🎀 Load sample diagram
🧹 Clear all
Nothing selected. Click a node or a line on the canvas.
);
}
if (selection.type === 'node'){
const node = nodes.find(n=>n.id===selection.id);
if (!node) return null;
const nodeGroups = groups.filter(g => (g.nodeIds || []).includes(node.id));
return (
);
}
if (selection.type === 'edge'){
const edge = edges.find(e=>e.id===selection.id);
if (!edge) return null;
return (
);
}
if (selection.type === 'writing'){
const writing = writings.find(w => w.id === selection.id);
if (!writing) return null;
return (
);
}
if (selection.type === 'group'){
const group = groups.find(g=>g.id===selection.id);
if (!group) return null;
return (
);
}
return null;
}
function NodeInspector({node, updateNode, deleteNode, nodeGroups, createGroupFromNode, updateGroup, deleteGroup, setComposingGroupId, setSelection}){
nodeGroups = nodeGroups || [];
const fileRef = React.useRef(null);
const onFile = async (e) => {
const f = e.target.files?.[0];
if (!f) return;
try {
const dataUrl = await compressImage(f);
updateNode(node.id, { image: dataUrl });
} catch(err) {
console.error('Image compression failed', err);
}
e.target.value = '';
};
return (
<>
Edit node
Avatar
fileRef.current.click()}>📷 Upload image
{node.image && updateNode(node.id,{image:null})}>Clear image }
Emoji
updateNode(node.id,{emoji:null})}>—
{EMOJI_PRESETS.map(em=>(
updateNode(node.id,{emoji:em, image:null})}>{em}
))}
Avatar color
{NODE_COLORS.map(c=>(
updateNode(node.id,{color:c})}/>
))}
updateNode(node.id,{color:e.target.value},{skipHistory:true})}
onBlur={e=>updateNode(node.id,{color:e.target.value})}
style={{position:'absolute', inset:0, width:'100%', height:'100%', opacity:0, cursor:'pointer', border:'none', padding:0}}/>
Shape
{[['circle','Circle'],['square','Square'],['bubble','Bubble']].map(([v,l])=>(
updateNode(node.id,{shape:v})}>{l}
))}
{/* Name/subtitle position relative to the avatar. Default = bottom.
Useful when nodes are packed close together and labels collide. */}
Label position
{[['top','Top'], ['left','Left'], ['bottom','Bottom'], ['right','Right']].map(([v,l])=>(
updateNode(node.id,{labelPos:v})}>{l}
))}
deleteNode(node.id)}>🗑 Delete node
{/* Group membership — multi-membership is allowed */}
{nodeGroups.length > 0 ? (
🫧 Groups ({nodeGroups.length})
{nodeGroups.map(ng => (
{ng.name || '(Untitled)'}
setSelection && setSelection({type:'group', id:ng.id})}>✎
{
const nextIds = (ng.nodeIds||[]).filter(x => x !== node.id);
if (nextIds.length === 0) { deleteGroup && deleteGroup(ng.id); }
else { updateGroup && updateGroup(ng.id, {nodeIds: nextIds}); }
}}>×
))}
{
const gid = createGroupFromNode && createGroupFromNode(node.id);
if (gid) { setComposingGroupId && setComposingGroupId(gid); }
}}>
+ Add to another group
) : (
{
const gid = createGroupFromNode && createGroupFromNode(node.id);
if (gid) { setComposingGroupId && setComposingGroupId(gid); }
}}>
🫧 Add to a group
)}
>
);
}
function GroupInspector({group, nodes, updateGroup, deleteGroup, setComposingGroupId, setSelection}){
const members = (group.nodeIds || [])
.map(nid => nodes.find(n => n.id === nid))
.filter(Boolean);
const presets = ['#FFB3C7','#FFD3A8','#FFE8A3','#C7EFC0','#B5D9F2','#C9BEF5','#E8BCE8'];
return (
<>
🫧 Edit group
Color
{presets.map(c=>(
updateGroup(group.id,{color:c})}/>
))}
updateGroup(group.id,{color:e.target.value},{skipHistory:true})}
onBlur={e=>updateGroup(group.id,{color:e.target.value})}
style={{position:'absolute', inset:0, width:'100%', height:'100%', opacity:0, cursor:'pointer', border:'none', padding:0}}/>
Members ({members.length})
{members.map(n => (
{n.emoji || (n.name||'?').charAt(0).toUpperCase()}
{n.name || '(Unnamed)'}
{
const next = (group.nodeIds||[]).filter(x => x !== n.id);
if (next.length === 0) { deleteGroup(group.id); return; }
updateGroup(group.id, {nodeIds: next});
}}>×
))}
{ setComposingGroupId && setComposingGroupId(group.id); }}>
+ Add / edit members
deleteGroup(group.id)}>🗑 Delete group
>
);
}
function EdgeInspector({edge, nodes, updateEdge, deleteEdge}){
const from = nodes.find(n=>n.id===edge.from);
const to = nodes.find(n=>n.id===edge.to);
const rel = RELATIONS.find(r=>r.id===edge.relation) || RELATIONS[0];
return (
<>
Edit relationship
from {from?.name||'?'}
→
to {to?.name||'?'}
{' '}
updateEdge(edge.id,{from:edge.to,to:edge.from})}>⇄
Relationship type
{RELATIONS.map(r=>(
updateEdge(edge.id,{relation:r.id, color:r.color, label:r.label})}>
{r.stamp} {r.label}
))}
Label (free text)
updateEdge(edge.id,{label:e.target.value})}/>
Stamp
updateEdge(edge.id,{stamp:''})}>None
{STAMPS.map(s=>(
updateEdge(edge.id,{stamp:s})}>{s}
))}
Arrow style
{ARROW_STYLES.map(s=>(
updateEdge(edge.id,{style:s.id})}>{s.label}
))}
Line color
{(() => {
const presets = ['#F56A92','#F77F6E','#FFB86B','#7DD8B4','#8CCBF0','#B7A0EE','#7a6a7a','#3a2c3a'];
return (
<>
{presets.map(c=>(
updateEdge(edge.id,{color:c})}/>
))}
updateEdge(edge.id,{color:e.target.value},{skipHistory:true})}
onBlur={e=>updateEdge(edge.id,{color:e.target.value})}
style={{position:'absolute', inset:0, width:'100%', height:'100%', opacity:0, cursor:'pointer', border:'none', padding:0}}/>
>
);
})()}
{edge.customOffset !== undefined && edge.customOffset !== 0 && (
updateEdge(edge.id,{customOffset:undefined})}>↺ Reset curve
)}
deleteEdge(edge.id)}>🗑 Delete relationship
💡 Shift+click another arrow to swap their stacking order
>
);
}
function WritingInspector({writing, nodes, edges, updateWriting, deleteWriting}){
const presets = ['#F56A92','#F77F6E','#FFB86B','#FFD97A','#7DD8B4','#8CCBF0','#B7A0EE','#3a2c3a'];
const fontSize = writing.fontSize || 28;
const rotation = writing.rotation || 0;
const links = writing.links || [];
const linkLabel = (link) => {
if (link.type === 'node') {
const n = nodes.find(x => x.id === link.id);
return n ? `Node "${n.name || '?'}"` : '(deleted node)';
}
if (link.type === 'edge') {
const ed = edges.find(x => x.id === link.id);
return ed ? `Arrow "${ed.label || ed.relation || ''}"` : '(deleted arrow)';
}
return '?';
};
const removeLinkAt = (idx) => {
const next = links.filter((_, i) => i !== idx);
updateWriting(writing.id, { links: next });
};
const resetCurveAt = (idx) => {
const next = links.map((l, i) => i === idx ? { ...l, lineCurve: 0 } : l);
updateWriting(writing.id, { links: next });
};
const detachAll = () => updateWriting(writing.id, { links: [] });
return (
<>
📝 Edit writing
Text
Size ({fontSize}px)
updateWriting(writing.id, {fontSize: parseInt(e.target.value, 10)}, {skipHistory:true})}
onMouseUp={e=>updateWriting(writing.id, {fontSize: parseInt(e.target.value, 10)})}
onTouchEnd={e=>updateWriting(writing.id, {fontSize: parseInt(e.target.value, 10)})}/>
{[16, 24, 32, 48, 64].map(v => (
updateWriting(writing.id, {fontSize: v})}>{v}
))}
Rotation ({rotation}°)
updateWriting(writing.id, {rotation: parseInt(e.target.value, 10)}, {skipHistory:true})}
onMouseUp={e=>updateWriting(writing.id, {rotation: parseInt(e.target.value, 10)})}
onTouchEnd={e=>updateWriting(writing.id, {rotation: parseInt(e.target.value, 10)})}/>
{rotation !== 0 && (
updateWriting(writing.id, {rotation: 0})}>↺ Reset rotation
)}
Text color (line color follows)
{presets.map(c => (
updateWriting(writing.id, {color: c})}/>
))}
updateWriting(writing.id, {color: e.target.value}, {skipHistory:true})}
onBlur={e=>updateWriting(writing.id, {color: e.target.value})}
style={{position:'absolute', inset:0, width:'100%', height:'100%', opacity:0, cursor:'pointer', border:'none', padding:0}}/>
Links
{links.length === 0 ? (
No links
) : (
{links.map((link, idx) => {
const bent = (link.lineCurve || 0) !== 0;
return (
🔗 {linkLabel(link)}
{bent && (
resetCurveAt(idx)}>↺
)}
removeLinkAt(idx)}>×
);
})}
)}
{links.length > 0 && (
✕ Detach all lines
)}
deleteWriting(writing.id)}>🗑 Delete writing
>
);
}
window.Inspector = Inspector;