// Main App function App(){ const initial = { title: '', subtitle: '', showTitleOnCanvas: false, bgPattern: 'bg-dots', bgColor: '#FFF7EC', nodes: [], edges: [], groups: [], writings: [], }; const hist = useHistory(initial); const { state, set } = hist; const [selection, setSelection] = React.useState(null); const [view, setView] = React.useState({ zoom: 1, tx: 200, ty: 120 }); const [editingEdgeId, setEditingEdgeId] = React.useState(null); const [toast, setToast] = React.useState(null); const [shotMode, setShotMode] = React.useState(false); // Group compose mode: when non-null, clicking a node toggles its membership in that group. const [composingGroupId, setComposingGroupId] = React.useState(null); const shotPrevViewRef = React.useRef(null); const canvasRef = React.useRef(null); // ---- toast helper const flash = (msg)=>{ setToast(msg); setTimeout(()=>setToast(null), 1800); }; // ---- mutations const nextId = (prefix) => prefix + '_' + Math.random().toString(36).slice(2,8); const addNode = (partial={}) => { // Place near center of current view const stage = canvasRef.current; const rect = stage?.getBoundingClientRect(); const cx = rect ? (rect.width/2 - view.tx)/view.zoom : 400; const cy = rect ? (rect.height/2 - view.ty)/view.zoom : 300; const color = NODE_COLORS[state.nodes.length % NODE_COLORS.length]; const id = nextId('n'); set(s => ({ ...s, nodes:[...s.nodes, { id, x: cx + (Math.random()-0.5)*80, y: cy + (Math.random()-0.5)*80, name:'新キャラ', color, emoji: null, image: null, shape:'circle', ...partial, }] })); setSelection({type:'node', id}); }; const updateNode = (id, patch, opts) => { set(s => ({ ...s, nodes: s.nodes.map(n => n.id===id ? { ...n, ...patch } : n), }), opts); }; const deleteNode = (id) => { set(s => { // Remove node from any groups; auto-drop groups that become empty. const groups = (s.groups || []) .map(g => ({ ...g, nodeIds: (g.nodeIds || []).filter(nid => nid !== id) })) .filter(g => (g.nodeIds || []).length > 0); // Drop any writing-link that points to this node — orphaned edges to it // also disappear below, so writing-links to those edges drop too. const removedEdgeIds = new Set(s.edges.filter(e=>e.from===id||e.to===id).map(e=>e.id)); const writings = (s.writings || []).map(w => { const links = (w.links || []).filter(l => !(l.type === 'node' && l.id === id) && !(l.type === 'edge' && removedEdgeIds.has(l.id)) ); return links.length === (w.links || []).length ? w : { ...w, links }; }); return { ...s, nodes: s.nodes.filter(n=>n.id!==id), edges: s.edges.filter(e=>e.from!==id && e.to!==id), groups, writings, }; }); setSelection(null); }; const addEdge = (fromId, toId, partial={}) => { const id = nextId('e'); set(s => ({ ...s, edges:[...s.edges, { id, from:fromId, to:toId, relation:'like', style:'oneway', color: RELATIONS[0].color, label: null, stamp: '♡', ...partial, }] })); setSelection({type:'edge', id}); }; const updateEdge = (id, patch, opts) => { set(s => ({ ...s, edges: s.edges.map(e => e.id===id ? { ...e, ...patch } : e), }), opts); }; const deleteEdge = (id) => { set(s => ({ ...s, edges: s.edges.filter(e=>e.id!==id), writings: (s.writings || []).map(w => { const links = (w.links || []).filter(l => !(l.type === 'edge' && l.id === id)); return links.length === (w.links || []).length ? w : { ...w, links }; }), })); setSelection(null); }; // -------- Writings (free-floating annotation text on top of everything) -------- const WRITING_DEFAULT_COLOR = '#F56A92'; const addWriting = (partial = {}) => { const stage = canvasRef.current; const rect = stage?.getBoundingClientRect(); const cx = rect ? (rect.width/2 - view.tx)/view.zoom : 400; const cy = rect ? (rect.height/2 - view.ty)/view.zoom : 300; const id = nextId('w'); set(s => ({ ...s, writings: [...(s.writings || []), { id, x: cx + (Math.random()-0.5)*60, y: cy + (Math.random()-0.5)*60, text: 'こんにちは', color: WRITING_DEFAULT_COLOR, fontSize: 28, rotation: 0, // links: each entry { type:'node'|'edge', id, lineCurve } — a writing // can have any number of links. Empty array = free-floating. links: [], ...partial, }], })); setSelection({ type: 'writing', id }); }; const updateWriting = (id, patch, opts) => { set(s => ({ ...s, writings: (s.writings || []).map(w => w.id === id ? { ...w, ...patch } : w), }), opts); }; const deleteWriting = (id) => { set(s => ({ ...s, writings: (s.writings || []).filter(w => w.id !== id), })); setSelection(null); }; // -------- Groups (cluster of nodes with a blob visual) -------- const GROUP_COLORS = ['#FFB3C7','#FFD3A8','#FFE8A3','#C7EFC0','#B5D9F2','#C9BEF5','#E8BCE8']; const createGroupFromNode = (nodeId) => { const id = nextId('g'); const usedColors = (state.groups || []).map(g=>g.color); const color = GROUP_COLORS.find(c => !usedColors.includes(c)) || GROUP_COLORS[0]; set(s => ({ ...s, groups: [...(s.groups || []), { id, name:'', color, showName:true, nodeIds:[nodeId] }], })); return id; }; const updateGroup = (id, patch, opts) => { set(s => ({ ...s, groups: (s.groups || []).map(g => g.id === id ? { ...g, ...patch } : g), }), opts); }; const deleteGroup = (id) => { set(s => ({ ...s, groups: (s.groups || []).filter(g => g.id !== id), })); setSelection(null); if (composingGroupId === id) setComposingGroupId(null); }; // Toggle a node's membership in a group during compose mode. // Multi-membership is allowed — a node can belong to any number of groups. const toggleGroupMember = (groupId, nodeId) => { set(s => { const groups = (s.groups || []).map(g => { if (g.id !== groupId) return g; const has = (g.nodeIds || []).includes(nodeId); const nodeIds = has ? g.nodeIds.filter(x => x !== nodeId) : [...(g.nodeIds || []), nodeId]; return { ...g, nodeIds }; }) // drop any group that became empty (except the one we're composing) .filter(g => g.id === groupId || (g.nodeIds || []).length > 0); return { ...s, groups }; }); }; // Swap two edges' positions in state.edges — later edges render on top, // so this effectively swaps their z-order. Triggered by Shift+click. const swapEdgeZ = (id1, id2) => { set(s => { const i1 = s.edges.findIndex(e => e.id === id1); const i2 = s.edges.findIndex(e => e.id === id2); if (i1 < 0 || i2 < 0 || i1 === i2) return s; const edges = [...s.edges]; [edges[i1], edges[i2]] = [edges[i2], edges[i1]]; return {...s, edges}; }); }; const clearAll = () => { if (!confirm('すべての人物と関係を削除します。よろしい?')) return; set(s => ({...s, nodes:[], edges:[], groups:[], writings:[], title:'', subtitle:'', showTitleOnCanvas:false, bgPattern:'bg-dots', bgColor:'#FFF7EC'})); setSelection(null); setComposingGroupId(null); try { localStorage.removeItem('sankaku_saved_data'); } catch(e) {} }; // ---- demo data const loadDemo = () => { const demo = buildDemoData(); set(s => ({ ...s, nodes: demo.nodes, edges: demo.edges, groups: demo.groups || [], title: 'ひだまり学園 相関図', subtitle: '第1期 メインキャスト', showTitleOnCanvas: true, })); setView({ zoom: 0.85, tx: 100, ty: 40 }); setSelection(null); flash('サンプルを読み込んだよ ✿'); }; // ---- setters bound to state via set() const setTitle = (t)=> set(s=>({...s,title:t})); const setSubtitle = (t)=> set(s=>({...s,subtitle:t})); const setShowTitleOnCanvas = (v)=> set(s=>({...s,showTitleOnCanvas:v})); const setBgPattern = (v)=> set(s=>({...s,bgPattern:v})); const setBgColor = (v)=> set(s=>({...s,bgColor:v})); // ---- project file save / load const saveProject = () => { const data = JSON.stringify(state, null, 2); const blob = new Blob([data], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = (state.title || '三角関係') + '.sankaku.json'; a.click(); URL.revokeObjectURL(url); flash('作業ファイルを保存したよ 💾'); }; const loadProjectFileRef = React.useRef(null); const loadProject = () => { loadProjectFileRef.current?.click(); }; const onLoadProjectFile = (e) => { const f = e.target.files?.[0]; if (!f) return; const reader = new FileReader(); reader.onload = () => { try { const data = JSON.parse(reader.result); if (!confirm('現在の作業内容は上書きされます。よろしいですか?')) return; set(s => ({ ...s, title: data.title ?? '', subtitle: data.subtitle ?? '', showTitleOnCanvas: data.showTitleOnCanvas ?? false, bgPattern: data.bgPattern ?? 'bg-dots', bgColor: data.bgColor ?? '#FFF7EC', nodes: data.nodes ?? [], edges: data.edges ?? [], groups: data.groups ?? [], // Migrate old single-link writings (attachedTo + lineCurve) to the // new links[] shape so existing project files keep working. writings: (data.writings ?? []).map(w => { if (Array.isArray(w.links)) return w; if (w.attachedTo) { return { ...w, links: [{ type: w.attachedTo.type, id: w.attachedTo.id, lineCurve: w.lineCurve || 0 }] }; } return { ...w, links: [] }; }), })); setSelection(null); setComposingGroupId(null); setView({ zoom: 1, tx: 200, ty: 120 }); flash('作業ファイルを読み込んだよ 📂'); } catch(err) { flash('ファイルの読み込みに失敗しました'); console.error(err); } }; reader.readAsText(f); e.target.value = ''; }; // ---- screenshot mode const enterShotMode = () => { if (state.nodes.length === 0) { flash('キャラを追加してね'); return; } setSelection(null); const pad = 80; let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity; state.nodes.forEach(n=>{ minX = Math.min(minX, n.x-70); minY = Math.min(minY, n.y-70); maxX = Math.max(maxX, n.x+70); maxY = Math.max(maxY, n.y+70); }); if (state.showTitleOnCanvas) minY = Math.min(minY, 0); minX-=pad; minY-=pad; maxX+=pad; maxY+=pad; const bw = maxX-minX, bh = maxY-minY; const vw = window.innerWidth, vh = window.innerHeight; const zoom = Math.min(1.5, Math.max(0.3, Math.min(vw/bw, vh/bh))); const tx = (vw - bw*zoom)/2 - minX*zoom; const ty = (vh - bh*zoom)/2 - minY*zoom; shotPrevViewRef.current = view; setView({ zoom, tx, ty }); setShotMode(true); document.body.classList.add('is-shot-mode'); }; const exitShotMode = () => { setShotMode(false); document.body.classList.remove('is-shot-mode'); if (shotPrevViewRef.current) { setView(shotPrevViewRef.current); shotPrevViewRef.current = null; } }; // ---- keyboard React.useEffect(()=>{ const onKey = (e) => { if (shotMode && e.key==='Escape') { exitShotMode(); return; } if (e.key==='Escape' && composingGroupId) { setComposingGroupId(null); return; } if (e.target.matches('input,textarea')) return; if ((e.metaKey||e.ctrlKey) && e.key==='z' && !e.shiftKey){ e.preventDefault(); hist.undo(); } else if ((e.metaKey||e.ctrlKey) && (e.key==='y' || (e.shiftKey && e.key==='z'))){ e.preventDefault(); hist.redo(); } else if (e.key==='Delete' || e.key==='Backspace'){ if (selection?.type==='node') deleteNode(selection.id); if (selection?.type==='edge') deleteEdge(selection.id); if (selection?.type==='group') deleteGroup(selection.id); if (selection?.type==='writing') deleteWriting(selection.id); } }; window.addEventListener('keydown', onKey); return ()=> window.removeEventListener('keydown', onKey); }, [selection, hist, shotMode, composingGroupId]); // ---- edge label inline editor const editingEdge = state.edges.find(e=>e.id===editingEdgeId); return (