自己紹介マンダラ ☰ ツール一覧
自己紹介マンダラ:8人で1枚をつくる入力はこの端末に自動保存されます
グループ未設定

🎉 8人で1枚のマンダラをつくろう

1人が1エリア(3×3の9マス)を担当します。8人ぶんが集まると、81マスのマンダラチャートが完成します。
研修・朝活・キックオフのアイスブレイクにどうぞ。3分で書けます。

Googleのアカウント(Gmail・Google Workspace)だと、資料の共有がスムーズです。

合言葉を入れると、空き状況を確認できます。

すでに書いた人は、同じ合言葉とおなまえを入れると続きから編集できます。

' +'

自己紹介マンダラ'+(orgName?(':'+esc(orgName)):(title?(':'+esc(title)):'')) +(sheetCount()>1?('('+curSheet+'枚目 / 全'+sheetCount()+'枚)'):'')+'

' +''); w.document.close(); }); } function exportPng(){ if(typeof html2canvas==='undefined'){ alert('準備中です'); return; } toast('🖼 画像を作成しています…'); html2canvas($('capWrap'),{backgroundColor:'#ffffff',scale:2}).then(cv=>{ const a=document.createElement('a'); a.download='自己紹介マンダラ_'+ev+(sheetCount()>1?('_'+curSheet+'枚目'):'')+'.png'; a.href=cv.toDataURL('image/png'); a.click(); toast('🖼 保存しました'); }); } async function copyLink(){ let u=location.origin+location.pathname+'?ev='+encodeURIComponent(ev); if(orgName) u+='&org='+encodeURIComponent(orgName); if(qsKey!=='std') u+='&q='+encodeURIComponent(qsKey); try{ await navigator.clipboard.writeText(u); toast('🔗 リンクをコピーしました。仲間に送ってください'); } catch(e){ prompt('このリンクをコピーしてください', u); } } /* ---- スタート画面 ---- */ function renderEmo(){ $('emoPick').innerHTML=EMOJI.map(e=>'').join(''); $('emoPick').querySelectorAll('[data-e]').forEach(b=>b.onclick=()=>{ me.emo=b.dataset.e; renderEmo(); }); } /* 空き状況が取れていないときは参加ボタンを塞ぐ(重複の防止) */ function setJoinEnabled(on){ const b=$('btnJoin'); if(!b) return; b.disabled=!on; b.style.opacity=on?'':'0.5'; b.style.cursor=on?'':'not-allowed'; } /* スタート画面の「どの1枚に入る?」タブ */ function renderSheetPick(){ const el=$('sheetPick'); if(!el) return; const tot=sheetCount(); const canAdd = Object.keys(boards[tot]||{}).length>=8; if(tot<=1 && !canAdd){ el.style.display='none'; return; } el.style.display='flex'; let h=''; for(let n=1;n<=tot;n++){ const cnt=Object.keys(boards[n]||{}).length; h+=''; } if(canAdd) h+=''; el.innerHTML=h; el.querySelectorAll('[data-ps]').forEach(b=>b.onclick=()=>{ useSheet(+b.dataset.ps); picking=''; renderSheetPick(); renderAreaPick(); }); } function myExistingSheetIs(n){ const s=myExistingSpot(); return !!(s && s.sheet===n); } function renderAreaPick(){ const nm=($('inName')?($('inName').value||'').trim():''); if(nm) me.name=nm; $('areaPick').innerHTML=ORDER.map(k=>{ const p=board[k], taken=!!p, mine=isMine(k); const cls=(picking===k?'on ':'')+((taken&&!mine)?'taken':''); const label = taken ? (esc(p.emo)+esc(p.name)+(mine?'(あなた)':' 使用中')) : '空き'; return ''; }).join(''); $('areaPick').querySelectorAll('[data-a]').forEach(b=>b.onclick=()=>{ const k=b.dataset.a; if(board[k] && !isMine(k)){ toast('そのエリアは使用中です。空いているところを選んでください'); return; } picking=k; renderAreaPick(); }); } async function refreshAreas(){ const code=($('inEv').value||'').trim(); if(!code){ $('areaMsg').textContent='合言葉を入れると、空き状況を確認できます。'; return; } ev=code; $('areaMsg').textContent='空き状況を確認しています…'; const ok=await fetchBoard(true); if(!ok && !boardLoaded){ // 一度も読めていない=空き状況が不明。この状態で入らせると重複が起きるので止める $('areaPick').innerHTML=''; $('areaMsg').innerHTML='⚠ 空き状況を確認できませんでした(通信が混み合っています)。' +''; const rb=$('btnRetry'); if(rb) rb.onclick=refreshAreas; setJoinEnabled(false); return; } setJoinEnabled(true); // 自分が既にどこかに入っていればそこへ、いなければ空きのある枚へ const nm=($('inName').value||'').trim(); if(nm) me.name=nm; const spot=myExistingSpot(); if(spot){ useSheet(spot.sheet); picking=spot.area; } else{ useSheet(suggestSheet()); } syncHostBox(); const n=Object.keys(board).length, tot=sheetCount(); $('areaMsg').textContent = n ? ('いま '+curSheet+'枚目に '+n+'/8人が入っています。空いているところを選んでください。' +(tot>1?('(全'+tot+'枚)'):'')) : (curSheet>1 ? (curSheet+'枚目はまだ誰もいません。好きなエリアを選んでください。') : 'まだ誰もいません。好きなエリアを選んでください(A〜Hどこでも)。'); renderSheetPick(); renderAreaPick(); } /* 会社名・質問セットは「最初の人」だけが決める。既に決まっていれば入力欄を隠して従う */ function syncHostBox(){ const decided = Object.keys(boards).some(n=>Object.keys(boards[n]||{}).length); const box=$('hostBox'); if(!box) return; if(decided){ box.style.display='none'; if(orgName) $('inOrg').value=orgName; $('inQset').value=qsKey; }else{ box.style.display=''; } } async function join(){ const code=($('inEv').value||'').trim(), nm=($('inName').value||'').trim(); const mail=($('inMail').value||'').trim(); const real=($('inReal').value||'').trim(), corp=($('inCorp').value||'').trim(); if(!code){ $('startMsg').textContent='グループの合言葉を入れてください。'; return; } if(!real){ $('startMsg').textContent='お名前(本名)を入れてください。'; $('inReal').focus(); return; } if(!nm){ $('startMsg').textContent='ニックネームを入れてください。'; $('inName').focus(); return; } if(!mail){ $('startMsg').textContent='メールアドレスを入れてください(主催者からの資料送付用です)。'; $('inMail').focus(); return; } if(!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(mail)){ $('startMsg').textContent='メールアドレスの形式をご確認ください。'; $('inMail').focus(); return; } if(!picking){ $('startMsg').textContent='入るエリア(A〜H)を選んでください。'; return; } ev=code; const keepEmo=me.emo, wantSheet=curSheet; loadMe(); // 同じ端末で書いた続きがあれば復元 me.name=nm; me.email=mail; me.real=real; me.corp=corp; if(keepEmo) me.emo=keepEmo; // 会社名・質問セットは「最初の人」の入力を採用(2人目以降は入力欄が隠れている) if($('hostBox').style.display!=='none'){ orgName=($('inOrg').value||'').trim().slice(0,60); applyQset($('inQset').value); } // ★押した瞬間に最新の空き状況を取り直す(選んでから押すまでの間に取られていないか) $('startMsg').textContent='エリアを確保しています…'; $('btnJoin').disabled=true; let okFetch=false; try{ okFetch=await fetchBoard(true); }catch(e){ okFetch=false; } $('btnJoin').disabled=false; if(!okFetch){ // 最新の空き状況が取れないまま入ると重複するので、ここで止める $('startMsg').textContent='⚠ 通信が混み合っていて空き状況を確認できませんでした。少し待って、もう一度「はじめる」を押してください。'; return; } useSheet(wantSheet); // すでに自分がどこかに入っていたら、そちらへ案内(1人1エリア) const spot=myExistingSpot(); if(spot && !(spot.sheet===curSheet && spot.area===picking)){ if(confirm('あなたはすでに ' + spot.sheet + '枚目の' + spot.area + 'エリア に入っています。\n' + 'そのまま そちら を編集しますか?\n\n' + '[キャンセル]を押すと ' + curSheet + '枚目の' + picking + 'エリア に新しく入ります(前のぶんは残ります)')){ useSheet(spot.sheet); picking=spot.area; } } // 選んだエリアが他の人に取られていたら中止(上書きしない) if(board[picking] && !isMine(picking)){ const by=board[picking].name; renderAreaPick(); const free=ORDER.filter(k=>!board[k]); $('startMsg').textContent=''; alert('⚠ ' + curSheet + '枚目の' + picking + 'エリアは、いま「' + by + '」さんが入りました。\n\n' + '別のエリアを選んでください。\n' + (free.length ? ('空き:' + free.join('・')) : ('この枚は満席です。' + suggestSheet() + '枚目 をお選びください。'))); picking=''; renderSheetPick(); renderAreaPick(); return; } me.area=picking; me.sheet=curSheet; // すでにサーバーに自分の内容があれば取り込む(別端末から続けられる) const p=board[picking]; if(p && isMine(picking) && me.items.every(x=>!x)){ me.items=p.items.slice(); me.emo=p.emo||me.emo; } saveMe(); // ★すぐに仮押さえ(他の人の画面に「使用中」と出る) const rz=await pushMe({skipCheck:true}); if(!rz.ok){ toast('確保の送信に失敗しました。電波の良いところで「保存」を押してください'); } $('startMsg').textContent=''; try{ localStorage.setItem('jiko_last', JSON.stringify({ev:ev,name:nm})); }catch(e){} $('evBadge').textContent=(orgName?(orgName+'/'):'')+'グループ:'+ev; $('vStart').style.display='none'; $('vMain').style.display='block'; render(); fetchBoard(true).then(render); } /* ---- 起動 ---- */ useSheet(1); $('inQset').innerHTML=Object.keys(QSETS).map(k=>'').join(''); $('inQset').value=qsKey; $('inQset').onchange=e=>{ applyQset(e.target.value); }; renderEmo(); renderSheetPick(); renderAreaPick(); $('btnJoin').onclick=join; $('inEv').addEventListener('change',refreshAreas); $('inEv').addEventListener('blur',refreshAreas); $('inName').addEventListener('blur',()=>{ if(Object.keys(board).length) renderAreaPick(); }); [$('inEv'),$('inReal'),$('inName'),$('inMail'),$('inCorp')].forEach(el=>el.addEventListener('keydown',e=>{ if(e.isComposing||e.keyCode===229) return; if(e.key==='Enter'){ e.preventDefault(); if(el===$('inEv')) refreshAreas(); else if(el===$('inReal')) $('inName').focus(); else if(el===$('inName')) $('inMail').focus(); else if(el===$('inMail')) $('inCorp').focus(); else $('btnJoin').click(); } })); $('inMail').addEventListener('input',e=>{ me.email=e.target.value.trim(); }); $('pClose').onclick=closePerson; $('pModal').addEventListener('click',e=>{ if(e.target.id==='pModal') closePerson(); }); document.addEventListener('keydown',e=>{ if(e.key==='Escape') closePerson(); }); $('btnReload').onclick=async()=>{ toast('↻ 読み込んでいます…'); await fetchBoard(true); renderGrid(); }; let toastTimer=null; function toast(m){ const el=$('toast'); el.textContent=m; el.style.opacity='1'; clearTimeout(toastTimer); toastTimer=setTimeout(()=>el.style.opacity='0',2600); } /* メイン:45秒ごとに自動更新。自分のエリアが奪われていたら知らせる */ /* 【対策4】全員が同じ秒に叩くとサーバーが詰まるので、端末ごとに0〜8秒ずらす。 さらに画面が裏に回っているタブはポーリングしない(27人ぶんの空打ちを止める) */ const JITTER = Math.floor(Math.random()*8000); const visible = ()=> document.visibilityState !== 'hidden'; setInterval(async ()=>{ if(!ev || !visible() || $('vMain').style.display==='none') return; await fetchBoard(true); renderGrid(); const sh=me.sheet||1; if(me.area && (boards[sh]||{})[me.area] && !isMineIn(sh,me.area)){ toast('⚠ ' + sh + '枚目の' + me.area + 'エリアに「' + boards[sh][me.area].name + '」さんが入りました。保存前にご確認ください'); } }, 60000 + JITTER); /* 裏から戻ってきたら、その場で1回だけ最新化する */ document.addEventListener('visibilitychange',()=>{ if(visible() && ev && $('vMain').style.display!=='none'){ fetchBoard(true).then(()=>renderGrid()); } }); /* スタート画面:10秒ごとに空き状況を更新(選んだ枠が取られたら解除して知らせる) */ setInterval(async ()=>{ if(!$('inEv') || !visible() || $('vStart').style.display==='none') return; const code=($('inEv').value||'').trim(); if(!code) return; ev=code; const keep=curSheet; const okPoll=await fetchBoard(true); if(!okPoll) return; // 取れなかった回は画面をいじらない(「全部空き」に見せない) useSheet(keep); syncHostBox(); if(picking && board[picking] && !isMine(picking)){ const by=board[picking].name; picking=''; $('areaMsg').textContent='⚠ 選んでいた枠は「'+by+'」さんが入りました。別のエリアを選んでください。'; } renderSheetPick(); renderAreaPick(); const n=Object.keys(board).length, tot=sheetCount(); if(!(picking==='' && $('areaMsg').textContent.indexOf('⚠')===0)){ $('areaMsg').textContent = n ? ('いま '+curSheet+'枚目に '+n+'/8人が入っています。空いているところを選んでください。' +(tot>1?('(全'+tot+'枚)'):'')) : (curSheet>1 ? (curSheet+'枚目はまだ誰もいません。好きなエリアを選んでください。') : 'まだ誰もいません。好きなエリアを選んでください(A〜Hどこでも)。'); } }, 25000 + JITTER); /* ?ev=合言葉 & ?org=会社名・回次 & ?q=質問セット で、主催者があらかじめ決めたURLを配れる */ (function(){ const q=new URLSearchParams(location.search); const code=(q.get('ev')||'').trim(); const org=(q.get('org')||'').trim(); const qk=(q.get('q')||'').trim(); if(org){ orgName=org.slice(0,60); $('inOrg').value=orgName; } if(qk&&QSETS[qk]){ applyQset(qk); $('inQset').value=qsKey; } const last=(function(){ try{ return JSON.parse(localStorage.getItem('jiko_last')); }catch(e){ return null; } })(); if(code){ $('inEv').value=code; if(last&&last.ev===code&&last.name) $('inName').value=last.name; refreshAreas(); $('inName').focus(); } else if(last&&last.ev){ $('inEv').value=last.ev; if(last.name) $('inName').value=last.name; refreshAreas(); } // 前回このグループで入れたメールがあれば埋めておく(打ち直しの手間を省く) const evc=($('inEv').value||'').trim(); if(evc){ try{ const s=JSON.parse(localStorage.getItem('jiko_'+evc)); if(s){ if(s.email) $('inMail').value=s.email; if(s.real) $('inReal').value=s.real; if(s.corp) $('inCorp').value=s.corp; } }catch(e){} } })();