Files
digital-psychology/mindmap.html
T

589 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh">
<head>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-LVVXH3TL04"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-LVVXH3TL04');
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no">
<title>愈心谷 · 脑图编辑器</title>
<style>
:root{--pri:#E54D42;--hero-start:#FF9F80;--bg-start:#FFD1C7;--bg-end:#FFC8B5}
*{box-sizing:border-box;margin:0;padding:0}
body{
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
background:linear-gradient(180deg,var(--bg-start),var(--bg-end) 50%,#fff5f0);
height:100vh;display:flex;overflow:hidden;
}
/* ── Sidebar ── */
.sidebar{
width:200px;background:#fff;border-right:1px solid #f0e0d8;display:flex;flex-direction:column;
flex-shrink:0;box-shadow:2px 0 8px rgba(0,0,0,0.03);z-index:10;
}
.sidebar .sh{
padding:16px 14px 10px;font-size:15px;font-weight:600;color:#333;
border-bottom:1px solid #f5f0eb;
}
.sidebar .slist{flex:1;overflow-y:auto;padding:8px 0}
.sidebar .slist .item{
padding:10px 14px;cursor:pointer;font-size:13px;color:#555;
display:flex;align-items:center;justify-content:space-between;
border-left:3px solid transparent;transition:background 0.1s;
}
.sidebar .slist .item:hover{background:#fdf8f5}
.sidebar .slist .item.active{background:#fff0ed;border-left-color:var(--pri);color:var(--pri);font-weight:600}
.sidebar .slist .item .del{font-size:11px;color:#ccc;visibility:hidden;cursor:pointer}
.sidebar .slist .item:hover .del{visibility:visible}
.sidebar .slist .item .del:hover{color:var(--pri)}
.sidebar .sb{display:flex;gap:6px;padding:10px 12px;border-top:1px solid #f5f0eb}
.sidebar .sb button{
flex:1;padding:8px 0;border:1px solid #ddd;border-radius:6px;
background:#fff;font-size:12px;cursor:pointer;color:#555;
}
.sidebar .sb button.primary{background:var(--pri);color:#fff;border-color:var(--pri)}
.sidebar .sb button:active{opacity:0.8}
/* ── Main ── */
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
.toolbar{
display:flex;align-items:center;gap:6px;padding:8px 12px;
background:rgba(255,255,255,0.9);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);
border-bottom:1px solid #f0e0d8;flex-shrink:0;flex-wrap:wrap;z-index:10;
}
.toolbar button{
padding:6px 12px;border:1px solid #ddd;border-radius:6px;
background:#fff;font-size:13px;cursor:pointer;color:#555;
}
.toolbar button:active{background:#f0ebe5}
.toolbar button.primary{background:var(--pri);color:#fff;border-color:var(--pri)}
.toolbar .sep{width:1px;height:20px;background:#ddd;margin:0 2px}
.toolbar .hint{font-size:11px;color:#aaa;margin-left:auto}
canvas{flex:1;display:block;cursor:grab}
canvas:active{cursor:grabbing}
#editor{position:absolute;display:none;z-index:20}
#editor input{
padding:4px 8px;border:2px solid var(--pri);border-radius:4px;
font-size:13px;outline:none;min-width:80px;background:#fff;
}
/* Plus buttons */
#plusChild,#plusSibling{
position:absolute;display:none;z-index:25;
width:20px;height:20px;border-radius:50%;
color:#fff;font-size:14px;font-weight:bold;line-height:20px;
text-align:center;cursor:pointer;user-select:none;
pointer-events:auto;
}
/* Mobile */
@media(max-width:600px){
.sidebar{
position:fixed;left:0;top:0;bottom:0;width:240px;z-index:30;
transform:translateX(-100%);transition:transform 0.25s ease;
box-shadow:4px 0 20px rgba(0,0,0,0.1);
}
.sidebar.open{transform:translateX(0)}
.sidebar .sh{font-size:16px;padding:18px 16px 12px}
.sidebar .slist .item{font-size:14px;padding:12px 16px}
.sidebar .sb button{padding:10px 0;font-size:13px}
/* hamburger */
.hamburger{
display:flex!important;position:fixed;top:8px;left:8px;z-index:40;
width:36px;height:36px;align-items:center;justify-content:center;
background:rgba(255,255,255,0.9);border-radius:8px;
border:1px solid #e0d5cf;font-size:18px;cursor:pointer;
-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);
}
/* overlay backdrop */
.overlay{
display:none;position:fixed;inset:0;z-index:25;
background:rgba(0,0,0,0.25);
}
.overlay.show{display:block}
/* toolbar compact */
.toolbar{gap:3px;padding:6px 6px 6px 50px;min-height:42px}
.toolbar button{padding:6px 10px;font-size:12px;border-radius:8px}
.toolbar .sep{margin:0 1px}
.toolbar .hint{font-size:10px;display:none}
/* canvas */
canvas{flex:1}
/* editor bigger for touch */
#editor input{font-size:16px!important;padding:8px 12px;min-width:100px;border-radius:6px}
/* node hit area larger on mobile */
.mobile .toolbar button{min-height:32px}
}
/* Desktop: hide hamburger */
.hamburger{display:none}
</style>
</head>
<body>
<!-- Mobile hamburger -->
<button class="hamburger" id="hamburger" onclick="toggleSidebar()"></button>
<div class="overlay" id="overlay" onclick="closeSidebar()"></div>
<!-- Sidebar -->
<div class="sidebar">
<div class="sh">📁 我的脑图</div>
<div class="slist" id="slist"></div>
<div class="sb">
<button class="primary" onclick="newMap()"> 新建</button>
<button onclick="goHome()">🏠</button>
</div>
</div>
<!-- Main -->
<div class="main">
<div class="toolbar">
<button onclick="renameMap()">✎ 重命名</button>
<button onclick="addChild()"></button>
<button onclick="addSibling()"> 同级</button>
<button onclick="delNode()">✕ 删除</button>
<span class="sep"></span>
<button onclick="zoomIn()">🔍+</button>
<button onclick="zoomOut()">🔍-</button>
<span class="sep"></span>
<span class="hint" id="saveHint">💾 已自动保存</span>
</div>
<canvas id="c"></canvas>
</div>
<!-- Plus buttons (HTML, positioned over canvas) -->
<div id="plusChild" onclick="addChild()" title="添加子节点">+</div>
<div id="plusSibling" onclick="addSibling()" title="添加同级节点">+</div>
<div id="editor"><input id="ei" onblur="commitEdit()" onkeydown="if(event.key==='Enter')commitEdit();if(event.key==='Escape')cancelEdit()"></div>
<script>
var c=document.getElementById('c'),ctx=c.getContext('2d');
var ei=document.getElementById('ei'),ed=document.getElementById('editor');
var SCALE=1,ox=0,oy=0;
var dragging=false,dragX=0,dragY=0;
var selected=null,editing=null;
var colors=['#8B5CF6','#4A90E2','#5CB85C','#E8985A','#E54D42','#06B6D4','#F59E0B','#EC4899'];
var STORAGE_KEY='yuxingu_maps';
// ---- Multi-map state ----
var maps=[],currentIdx=-1;
function defaultTree(){
return {
id:'root',text:'新建脑图',color:colors[0],children:[]
};
}
function defaultTemplate(){
return {
id:'root',text:'愈心谷小程序',color:colors[0],
children:[
{id:'b1',text:'愈心魔方',color:colors[0],children:[
{id:'b1a',text:'急救魔方'},{id:'b1b',text:'亲子魔方'},{id:'b1c',text:'成长魔方'},
{id:'b1d',text:'解构魔方'},{id:'b1e',text:'艺术魔方'},{id:'b1f',text:'情感魔方'}
]},
{id:'b2',text:'私域活动',color:colors[1],children:[
{id:'b2a',text:'心理研学'},{id:'b2b',text:'心灵旅修'}
]},
{id:'b3',text:'心理咨询',color:colors[2]},
{id:'b4',text:'心理评估',color:colors[3],children:[
{id:'b4a',text:'AI面部识别'},{id:'b4b',text:'脑机接口'}
]},
{id:'b5',text:'健康管理',color:colors[4]}
]
};
}
// ---- Persistence ----
function loadMaps(){
try{
var d=localStorage.getItem(STORAGE_KEY);
return d?JSON.parse(d):[];
}catch(e){return [];}
}
function saveMaps(){
try{localStorage.setItem(STORAGE_KEY,JSON.stringify(maps))}catch(e){}
// sync to server (all maps)
fetch('/save',{method:'POST',body:JSON.stringify(maps)}).catch(function(){});
}
function syncServer(){
fetch('/load').then(function(r){return r.json()}).then(function(data){
if(Array.isArray(data)&&data.length>0){
maps=data;saveMaps();renderSidebar();
if(currentIdx<0||currentIdx>=maps.length)switchMap(0);
}
}).catch(function(){});
}
// ---- Sidebar ----
function renderSidebar(){
var sl=document.getElementById('slist');
sl.innerHTML='';
maps.forEach(function(m,i){
var d=document.createElement('div');
d.className='item'+(i===currentIdx?' active':'');
d.innerHTML='<span>'+escHtml(m.name||'脑图'+(i+1))+'</span><span class="del" data-idx="'+i+'" onclick="event.stopPropagation();deleteMap('+i+')">✕</span>';
d.onclick=function(){switchMap(i)};
sl.appendChild(d);
});
}
function escHtml(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function switchMap(idx){
if(idx<0||idx>=maps.length)return;
currentIdx=idx;
tree=JSON.parse(JSON.stringify(maps[idx].tree));
selected=null;
renderSidebar();
layout();draw();
// close sidebar on mobile after selection
if(window.innerWidth<=600)closeSidebar();
}
function newMap(){
var name=prompt('脑图名称:','脑图'+(maps.length+1));
if(!name)return;
maps.push({name:name,tree:defaultTree()});
saveMaps();
renderSidebar();
switchMap(maps.length-1);
}
function renameMap(){
if(currentIdx<0)return;
var name=prompt('新名称:',maps[currentIdx].name);
if(!name)return;
maps[currentIdx].name=name;
saveMaps();renderSidebar();
}
function deleteMap(idx){
if(!confirm('删除「'+maps[idx].name+'」?不可恢复。'))return;
maps.splice(idx,1);
saveMaps();renderSidebar();
if(maps.length===0){maps.push({name:'愈心谷小程序',tree:defaultTemplate()});saveMaps();renderSidebar();}
if(currentIdx>=maps.length)currentIdx=maps.length-1;
switchMap(currentIdx);
}
var _saveTimer=null;
function autoSave(){
if(currentIdx<0)return;
maps[currentIdx].tree=JSON.parse(JSON.stringify(exportTree(tree)));
saveMaps();
var h=document.getElementById('saveHint');
if(h){h.textContent='💾 已自动保存';h.style.color='#aaa';
clearTimeout(_saveTimer);
_saveTimer=setTimeout(function(){h.textContent='';},2000);}
}
// ---- Tree export ----
function exportTree(node){
var o={id:node.id,text:node.text};
if(node.color)o.color=node.color;
if(node.children)o.children=node.children.map(exportTree);
return o;
}
// ---- Layout & Draw ----
var tree=defaultTemplate();
var NODE_W=120,NODE_H=34,HGAP=60,VGAP=14;
function uid(){return 'n'+Date.now()+Math.random().toString(36).slice(2,6)}
function getColor(node,parentColor){
if(node.color)return node.color;
if(parentColor)return parentColor;
return colors[0];
}
function layoutHeight(node){
if(!node.children||node.children.length===0)return NODE_H;
var h=0;
for(var i=0;i<node.children.length;i++)h+=layoutHeight(node.children[i]);
return h+(node.children.length-1)*VGAP;
}
function layoutNode(node,x,y,parentColor){
node._x=x;node._y=y;node._w=NODE_W;node._h=NODE_H;
node._color=getColor(node,parentColor);
if(node.children&&node.children.length>0){
var totalH=0;
for(var i=0;i<node.children.length;i++)totalH+=layoutHeight(node.children[i]);
totalH+=(node.children.length-1)*VGAP;
var cy=y-(totalH-NODE_H)/2;
for(var i=0;i<node.children.length;i++){
var cnode=node.children[i];
var ch=layoutHeight(cnode);
layoutNode(cnode,x+NODE_W+HGAP,cy+(ch-NODE_H)/2,node._color);
cy+=ch+VGAP;
}
}
}
function layout(){
var totalH=layoutHeight(tree);
layoutNode(tree,40,-totalH/2,tree.color);
}
function hitTest(mx,my){
function hit(node){
if(!node._x)return null;
if(mx>=node._x&&mx<=node._x+node._w&&my>=node._y&&my<=node._y+node._h)return node;
if(node.children)for(var i=0;i<node.children.length;i++){var h=hit(node.children[i]);if(h)return h;}
return null;
}
return hit(tree);
}
function draw(){
var W=c.width=c.offsetWidth||window.innerWidth-200;
var H=c.height=c.offsetHeight||window.innerHeight-42;
ctx.clearRect(0,0,W,H);
ctx.save();
ctx.translate(W/2+ox,H/2+oy);
ctx.scale(SCALE,SCALE);
drawTree(tree);
ctx.restore();
updatePlusButtons();
}
function drawTree(node){
var x=node._x,y=node._y,w=node._w,h=node._h,col=node._color;
var isRoot=node===tree,isSel=node===selected;
if(node.children)for(var i=0;i<node.children.length;i++){
var c=node.children[i];
ctx.beginPath();
ctx.moveTo(x+w,y+h/2);
ctx.bezierCurveTo(
x+w+HGAP/2,y+h/2,
c._x-HGAP/2,c._y+c._h/2,
c._x,c._y+c._h/2
);
ctx.strokeStyle=col;ctx.lineWidth=isRoot?3:2;ctx.stroke();
drawTree(c);
}
var rx=6;
ctx.beginPath();
ctx.moveTo(x+rx,y);ctx.lineTo(x+w-rx,y);ctx.arcTo(x+w,y,x+w,y+rx,rx);
ctx.lineTo(x+w,y+h-rx);ctx.arcTo(x+w,y+h,x+w-rx,y+h,rx);
ctx.lineTo(x+rx,y+h);ctx.arcTo(x,y+h,x,y+h-rx,rx);
ctx.lineTo(x,y+rx);ctx.arcTo(x,y,x+rx,y,rx);ctx.closePath();
if(isRoot){
ctx.fillStyle=col;ctx.fill();
ctx.strokeStyle='rgba(0,0,0,0.15)';ctx.lineWidth=1;ctx.stroke();
ctx.fillStyle='#fff';
}else{
ctx.fillStyle='#fff';ctx.fill();
ctx.strokeStyle=col;ctx.lineWidth=isSel?3:1.5;ctx.stroke();
ctx.fillStyle='#333';
}
ctx.font=(isRoot?'bold ':'')+'13px -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif';
ctx.textAlign='center';ctx.textBaseline='middle';
ctx.fillText(node.text,x+w/2,y+h/2);
}
function updatePlusButtons(){
var pc=document.getElementById('plusChild'), ps=document.getElementById('plusSibling');
if(!selected){
pc.style.display='none'; ps.style.display='none'; return;
}
var rect=c.getBoundingClientRect();
var n=selected, col=n._color||colors[0];
// Child + (right edge)
var cx=rect.left + c.width/2 + ox + (n._x+n._w+8)*SCALE;
var cy=rect.top + c.height/2 + oy + (n._y+n._h/2)*SCALE;
pc.style.display='block'; pc.style.left=(cx-10)+'px'; pc.style.top=(cy-10)+'px';
pc.style.background=col;
// Sibling + (bottom edge) — not for root
if(selected!==tree){
var sx=rect.left + c.width/2 + ox + (n._x+n._w/2)*SCALE;
var sy=rect.top + c.height/2 + oy + (n._y+n._h+8)*SCALE;
ps.style.display='block'; ps.style.left=(sx-10)+'px'; ps.style.top=(sy-10)+'px';
ps.style.background=col;
}else{ps.style.display='none';}
}
// ---- Canvas events ----
c.addEventListener('mousedown',function(e){
var rect=c.getBoundingClientRect();
var sx=(e.clientX-rect.left-c.width/2-ox)/SCALE;
var sy=(e.clientY-rect.top-c.height/2-oy)/SCALE;
var node=hitTest(sx,sy);
if(node){selected=node;draw();}
else{dragging=true;dragX=e.clientX;dragY=e.clientY;}
});
c.addEventListener('mousemove',function(e){
if(dragging){ox+=e.clientX-dragX;oy+=e.clientY-dragY;dragX=e.clientX;dragY=e.clientY;draw();}
});
c.addEventListener('mouseup',function(){dragging=false;});
c.addEventListener('mouseleave',function(){dragging=false;});
c.addEventListener('wheel',function(e){
e.preventDefault();
SCALE=Math.max(0.3,Math.min(3,SCALE*(e.deltaY>0?0.9:1.1)));
draw();
},{passive:false});
c.addEventListener('dblclick',function(e){
var rect=c.getBoundingClientRect();
var sx=(e.clientX-rect.left-c.width/2-ox)/SCALE;
var sy=(e.clientY-rect.top-c.height/2-oy)/SCALE;
var node=hitTest(sx,sy);
if(node){
editing=node;
var cx=rect.left+c.width/2+ox+node._x*SCALE;
var cy=rect.top+c.height/2+oy+node._y*SCALE;
ed.style.display='block';ed.style.left=cx+'px';ed.style.top=cy+'px';
ei.value=node.text;ei.focus();ei.select();
}
});
// Touch
c.addEventListener('touchstart',function(e){
if(e.touches.length===1){
var rect=c.getBoundingClientRect();
var sx=(e.touches[0].clientX-rect.left-c.width/2-ox)/SCALE;
var sy=(e.touches[0].clientY-rect.top-c.height/2-oy)/SCALE;
var node=hitTest(sx,sy);
if(node){selected=node;draw();}
else{dragging=true;dragX=e.touches[0].clientX;dragY=e.touches[0].clientY;}
}
},{passive:false});
c.addEventListener('touchmove',function(e){
if(dragging&&e.touches.length===1){
e.preventDefault();
ox+=e.touches[0].clientX-dragX;oy+=e.touches[0].clientY-dragY;
dragX=e.touches[0].clientX;dragY=e.touches[0].clientY;draw();
}
},{passive:false});
c.addEventListener('touchend',function(){dragging=false;});
// ---- Mobile: sidebar toggle ----
function toggleSidebar(){
var sb=document.querySelector('.sidebar');
var ov=document.getElementById('overlay');
sb.classList.toggle('open');
ov.classList.toggle('show');
}
function closeSidebar(){
document.querySelector('.sidebar').classList.remove('open');
document.getElementById('overlay').classList.remove('show');
}
// ---- Mobile: double-tap to edit ----
var _lastTap=0;
c.addEventListener('touchend',function(e){
var now=Date.now();
if(now-_lastTap<300){
// double tap
e.preventDefault();
var rect=c.getBoundingClientRect();
var sx=(e.changedTouches[0].clientX-rect.left-c.width/2-ox)/SCALE;
var sy=(e.changedTouches[0].clientY-rect.top-c.height/2-oy)/SCALE;
var node=hitTest(sx,sy);
if(node){
editing=node;
var cx=rect.left+c.width/2+ox+node._x*SCALE;
var cy=rect.top+c.height/2+oy+node._y*SCALE;
ed.style.display='block';ed.style.left=cx+'px';ed.style.top=cy+'px';
ei.value=node.text;ei.focus();ei.select();
}
}
_lastTap=now;
// close sidebar when tapping canvas
if(window.innerWidth<=600)closeSidebar();
});
// ---- Mobile: pinch-to-zoom ----
var _pinchDist=0;
c.addEventListener('touchstart',function(e){
if(e.touches.length===2){
_pinchDist=Math.hypot(
e.touches[0].clientX-e.touches[1].clientX,
e.touches[0].clientY-e.touches[1].clientY
);
}
},{passive:false});
c.addEventListener('touchmove',function(e){
if(e.touches.length===2){
e.preventDefault();
var d=Math.hypot(
e.touches[0].clientX-e.touches[1].clientX,
e.touches[0].clientY-e.touches[1].clientY
);
var ratio=d/(_pinchDist||1);
SCALE=Math.max(0.3,Math.min(3,SCALE*ratio));
_pinchDist=d;
draw();
}
},{passive:false});
// ---- Keyboard shortcuts ----
document.addEventListener('keydown',function(e){
if(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA')return; // 编辑中不触发
if(e.key==='Delete'||e.key==='Backspace'){
e.preventDefault();
delNode();
}
});
// ---- Actions ----
function commitEdit(){if(editing)editing.text=ei.value||'新节点';ed.style.display='none';editing=null;draw();autoSave();}
function cancelEdit(){ed.style.display='none';editing=null;}
function addChild(){
if(!selected){alert('请先选中一个节点');return;}
if(!selected.children)selected.children=[];
selected.children.push({id:uid(),text:'新节点'});
layout();draw();selected=selected.children[selected.children.length-1];draw();autoSave();
}
function addSibling(){
if(!selected||selected===tree){alert('请选中一个非根节点');return;}
function fp(node,target){
if(node.children)for(var i=0;i<node.children.length;i++){
if(node.children[i].id===target.id)return node;
var f=fp(node.children[i],target);if(f)return f;
}
return null;
}
var parent=fp(tree,selected);
if(!parent)return;
var idx=parent.children.indexOf(selected);
parent.children.splice(idx+1,0,{id:uid(),text:'新节点'});
layout();draw();selected=parent.children[idx+1];draw();autoSave();
}
function delNode(){
if(!selected||selected===tree){alert('不能删除根节点');return;}
function rm(node,target){
if(node.children)for(var i=0;i<node.children.length;i++){
if(node.children[i].id===target.id){node.children.splice(i,1);return true;}
if(rm(node.children[i],target))return true;
}
return false;
}
rm(tree,selected);selected=null;layout();draw();autoSave();
}
function zoomIn(){SCALE=Math.min(3,SCALE*1.15);draw();}
function zoomOut(){SCALE=Math.max(0.3,SCALE*0.85);draw();}
function goHome(){window.location.href='index.html';}
// ---- Init ----
(function init(){
maps=loadMaps();
if(maps.length===0){maps.push({name:'愈心谷小程序',tree:defaultTemplate()});saveMaps();}
renderSidebar();
switchMap(0);
syncServer();
window.addEventListener('resize',function(){draw();});
})();
</script>
</body>
</html>