<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>🏬 다층 백화점 길안내 (층간 이동 지원)</title>
<style>
body { margin:0; overflow:hidden; font-family:sans-serif; }
canvas { border:1px solid #ccc; display:block; cursor:crosshair; }
#controls {
position: fixed; top:10px; left:10px;
background: rgba(255,255,255,0.95); padding:10px; border-radius:6px; z-index:10;
}
select, button, input { margin:2px; }
#infoBox {
position: fixed; bottom:10px; left:10px;
background: rgba(0,0,0,0.7); color:#fff; padding:5px 10px; border-radius:6px; font-weight:bold;
display:none;
}
#nodeDialog {
display:none; position:fixed; top:50%; left:50%;
transform:translate(-50%,-50%);
background:#fff; border:1px solid #ccc; padding:15px;
border-radius:8px; z-index:1000; box-shadow:0 4px 12px rgba(0,0,0,0.2);
}
#nodeDialog h3 { margin:0 0 8px 0; font-size:16px; }
#nodeDialog .typeBtn { margin:2px; padding:3px 6px; cursor:pointer; }
</style>
</head>
<body>
<div id="controls">
<div>
Floor plan 업로드: <input type="file" id="fileInput" accept="image/*">
</div>
<div>
출발지: <select id="start"></select>
목적지: <select id="end"></select>
<button onclick="findPath()">길찾기</button>
<button onclick="resetAll()">초기화</button>
</div>
<div>
위치 검색: <input type="text" id="searchInput">
<button onclick="highlightNode()">검색</button>
</div>
</div>
<div id="infoBox"></div>
<canvas id="map"></canvas>
<div id="nodeDialog">
<h3>새 노드 추가</h3>
<div>
<label><input type="radio" name="floorType" value="지상" checked> 지상</label>
<label><input type="radio" name="floorType" value="지하"> 지하</label>
<input type="number" id="floorNumber" min="1" value="1" style="width:60px"> 층
</div>
<div style="margin-top:8px;">
<label>노드 이름: <input type="text" id="nodeName" placeholder="예: 1번 계단 / 4번 에스컬레이터"></label>
</div>
<div style="margin-top:8px;">
<span>노드 타입:</span><br>
<button type="button" class="typeBtn" data-type="normal">normal</button>
<button type="button" class="typeBtn" data-type="elevator">elevator</button>
<button type="button" class="typeBtn" data-type="stairs">stairs</button>
<button type="button" class="typeBtn" data-type="escalator">escalator</button>
</div>
<div style="margin-top:8px;">
<label>참고사항: <input type="text" id="nodeNote"></label>
</div>
<div style="margin-top:10px; text-align:right;">
<button onclick="closeNodeDialog()">취소</button>
<button onclick="saveNode()">저장</button>
</div>
</div>
<script>
const canvas = document.getElementById('map');
const ctx = canvas.getContext('2d');
const fileInput = document.getElementById('fileInput');
const searchInput = document.getElementById('searchInput');
const infoBox = document.getElementById('infoBox');
let floorImg = null;
let nodes = {};
let edges = {};
let nodeNames = [];
let blinkNode = null;
let blinkInterval = null;
let pathNodes = [];
function resizeCanvas(){
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
draw();
}
window.addEventListener('resize', resizeCanvas);
fileInput.addEventListener('change',(e)=>{
const file = e.target.files[0];
if(!file) return;
const reader = new FileReader();
reader.onload = function(evt){
const img = new Image();
img.onload = ()=>{
floorImg = img;
resetAll(true); // 이미지 업로드 시 초기화
resizeCanvas();
};
img.src = evt.target.result;
};
reader.readAsDataURL(file);
});
// ----------------- 노드 클릭 / 삭제 / 이름 변경 -----------------
canvas.addEventListener('click',(e)=>{
if(!floorImg) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const clickedNode = findNodeAt(x,y);
if(clickedNode){
if(e.shiftKey){
const newName = prompt("새 노드 이름을 입력하세요:", clickedNode);
if(!newName || nodes[newName]) { alert("이름이 비어있거나 이미 존재합니다."); return; }
const note = prompt("노드 참고사항을 입력하세요:", nodes[clickedNode].note||"");
nodes[newName] = {...nodes[clickedNode], note};
const idx = nodeNames.indexOf(clickedNode);
if(idx!==-1) nodeNames[idx]=newName;
edges[newName]=edges[clickedNode]||[];
delete edges[clickedNode];
for(let key in edges) edges[key]=edges[key].map(n=>n===clickedNode?newName:n);
delete nodes[clickedNode];
updateSelectOptions();
draw();
return;
} else {
const n = nodes[clickedNode];
infoBox.style.display='block';
infoBox.textContent = `${clickedNode} (${n.type}, ${n.floor}) ${n.note||''}`;
setTimeout(()=>{ infoBox.style.display='none'; }, 2000);
if(confirm(`'${clickedNode}' 노드를 삭제하시겠습니까?`)){
delete nodes[clickedNode];
nodeNames = nodeNames.filter(n=>n!==clickedNode);
delete edges[clickedNode];
for(let key in edges) edges[key]=edges[key].filter(n=>n!==clickedNode);
updateSelectOptions();
pathNodes=[];
draw();
}
return;
}
}
openNodeDialog(x,y);
});
function findNodeAt(x,y){
for(let name of nodeNames){
const node = nodes[name];
if(Math.hypot(node.x-x,node.y-y)<=8) return name;
}
return null;
}
// ----------------- 선택 박스 업데이트 -----------------
function updateSelectOptions(){
const startSel=document.getElementById('start');
const endSel=document.getElementById('end');
startSel.innerHTML=''; endSel.innerHTML='';
nodeNames.forEach(n=>{
const opt1=document.createElement('option'); opt1.value=n; opt1.textContent=n; startSel.appendChild(opt1);
const opt2=document.createElement('option'); opt2.value=n; opt2.textContent=n; endSel.appendChild(opt2);
});
}
function getLabel(fullName){ return fullName.split(' ').slice(1).join(' ').trim(); }
// ----------------- 거리 계산 -----------------
function distance(n1,n2){ return Math.hypot(n1.x-n2.x,n1.y-n2.y); }
// ----------------- Dijkstra (노드 범위 제한 가능) -----------------
function dijkstra(start,end,allowedNodes=null){
const visited=new Set(), distances={}, prev={};
const targetNodes = allowedNodes||nodeNames;
targetNodes.forEach(n=>distances[n]=Infinity);
distances[start]=0;
while(visited.size<targetNodes.length){
let minNode=null;
for(let n of targetNodes){
if(!visited.has(n)&&(minNode===null||distances[n]<distances[minNode])) minNode=n;
}
if(minNode===null) break;
if(minNode===end) break;
visited.add(minNode);
for(let nb of edges[minNode]||[]){
if(!targetNodes.includes(nb)) continue;
let alt = distances[minNode]+distance(nodes[minNode],nodes[nb]);
if(alt<distances[nb]) { distances[nb]=alt; prev[nb]=minNode; }
}
}
const path=[]; let curr=end;
while(curr){ path.unshift(curr); curr=prev[curr]; }
return path;
}
// ----------------- 층 파싱 및 RULES -----------------
function parseFloor(f){
const s=f.trim();
const isBasement = s.includes('지하');
const numMatch = s.match(/(\d+)/);
const num = numMatch?parseInt(numMatch[1],10):0;
let zone='';
const zMatch = s.match(/[AB]\b/i);
if(zMatch) zone=zMatch[0].toUpperCase();
return {level:isBasement?-num:num, zone};
}
function sameArea(f1,f2){
const a=parseFloor(f1), b=parseFloor(f2);
if(a.level!==b.level) return false;
if(a.level===-2 || a.level===-3) return a.zone===b.zone && a.zone!=='';
return true;
}
const RULES={
'1:':[{to:'-1:', filters:[{type:'stairs',labelIncludes:'1번'},{type:'stairs',labelIncludes:'4번'},{type:'stairs',labelIncludes:'5번'}]}],
'-1:':[ {to:'-2:', filters:[{type:'escalator',labelIncludes:'1번'}]} ],
'-2:':[ {to:'-3:', filters:[{type:'escalator',labelIncludes:'1번'}]} ]
};
function stateKeyFromFloorStr(floorStr){ const {level,zone}=parseFloor(floorStr); return `${level}:${zone||''}`; }
function prettyState(key){ const [lv,zone]=key.split(':'); const level=parseInt(lv,10); return level<0?`지하${-level}층${zone?` ${zone}`:''}`:`지상${level}층`; }
function neighbors(state){ return (RULES[state]||[]).map(r=>r.to); }
function bfsStatePath(startState,endState){
if(startState===endState) return [startState];
const q=[startState], prev={}, seen=new Set([startState]);
while(q.length){
const cur=q.shift();
for(const nb of neighbors(cur)){
if(seen.has(nb)) continue;
seen.add(nb); prev[nb]=cur;
if(nb===endState){ let path=[nb]; let p=cur; while(p){ path.unshift(p); p=prev[p]; } return path; }
q.push(nb);
}
}
return null;
}
// ----------------- 커넥터 선택 -----------------
function pickConnectorOnState(currNodeName,stateKey,filters){
const {level,zone}=parseFloor(stateKey.replace(':','층'));
const candidates=nodeNames.filter(n=>{
const f=parseFloor(nodes[n].floor); if(f.level!==level) return false;
if(level===-2||level===-3) if((zone||'')!==(f.zone||'')) return false;
const label=getLabel(n);
return filters.some(fi=>nodes[n].type===fi.type && label.includes(fi.labelIncludes));
});
if(candidates.length===0) return null;
const curr=nodes[currNodeName];
return candidates.reduce((best,now)=>distance(curr,nodes[now])<distance(curr,nodes[best])?now:best);
}
function matchConnectorOnNext(currName,nextStateKey){
const {level,zone}=parseFloor(nextStateKey.replace(':','층'));
const t=nodes[currName].type;
const label=getLabel(currName);
const candidates=nodeNames.filter(n=>{
const f=parseFloor(nodes[n].floor); if(f.level!==level) return false;
if(level===-2||level===-3) if((zone||'')!==(f.zone||'')) return false;
return nodes[n].type===t && getLabel(n)===label;
});
if(candidates.length===0) return null;
const curr=nodes[currName];
return candidates.reduce((best,now)=>distance(curr,nodes[now])<distance(curr,nodes[best])?now:best);
}
// ----------------- 최종 경로 -----------------
function multiFloorPath(startName,endName){
const startFloorStr=nodes[startName].floor;
const endFloorStr=nodes[endName].floor;
if(sameArea(startFloorStr,endFloorStr)){
const allowed=nodeNames.filter(n=>sameArea(nodes[n].floor,startFloorStr));
return dijkstra(startName,endName,allowed);
}
const startState=stateKeyFromFloorStr(startFloorStr);
const endState=stateKeyFromFloorStr(endFloorStr);
const statePath=bfsStatePath(startState,endState);
if(!statePath){ alert(`층간 경로가 없습니다.\n${prettyState(startState)} → ${prettyState(endState)}`); return null; }
let currNode=startName, full=[];
for(let i=0;i<statePath.length-1;i++){
const currState=statePath[i], nextState=statePath[i+1];
const rule=(RULES[currState]||[]).find(r=>r.to===nextState);
if(!rule){ alert(`규칙 오류: ${prettyState(currState)} → ${prettyState(nextState)}`); return null; }
const closestCurr=pickConnectorOnState(currNode,currState,rule.filters);
if(!closestCurr){ alert(`${prettyState(currState)}에 사용할 커넥터 없음`); return null; }
const closestNext=matchConnectorOnNext(closestCurr,nextState);
if(!closestNext){ alert(`${prettyState(nextState)}에 대응 커넥터 없음`); return null; }
const allowedCurr=nodeNames.filter(n=>sameArea(nodes[n].floor,nodes[closestCurr].floor));
const seg=dijkstra(currNode,closestCurr,allowedCurr);
if(seg.length===0){ alert(`${currNode} → ${closestCurr} 경로 없음`); return null; }
full.push(...(full.length?seg.slice(1):seg));
if(full[full.length-1]!==closestNext) full.push(closestNext);
currNode=closestNext;
}
const allowedEnd=nodeNames.filter(n=>sameArea(nodes[n].floor,endFloorStr));
const endSeg=dijkstra(currNode,endName,allowedEnd);
if(endSeg.length===0){ alert(`${currNode} → ${endName} 경로 없음`); return null; }
full.push(...endSeg.slice(1));
return full;
}
// ----------------- 길찾기, 검색, 초기화 -----------------
function findPath(){ pathNodes=multiFloorPath(document.getElementById('start').value,document.getElementById('end').value)||[]; draw(); }
function highlightNode(){
const input=searchInput.value.trim();
if(!input){ alert("검색어 입력"); return; }
const found=nodeNames.find(n=>getLabel(n)===input);
if(!found){ alert("노드 없음"); return; }
if(blinkInterval) clearInterval(blinkInterval);
blinkNode=found;
let visible=true;
blinkInterval=setInterval(()=>{ visible=!visible; draw(); },400);
setTimeout(()=>{ clearInterval(blinkInterval); blinkInterval=null; blinkNode=null; draw(); },3000);
}
function resetAll(skipConfirm=false){
if(!skipConfirm && !confirm("모든 노드와 경로를 초기화하시겠습니까?")) return;
nodes={}; edges={}; nodeNames=[]; pathNodes=[]; updateSelectOptions();
if(blinkInterval){ clearInterval(blinkInterval); blinkInterval=null; }
blinkNode=null; draw();
}
// ----------------- 노드 다이얼로그 -----------------
let tempX=0,tempY=0,selectedType='normal';
function openNodeDialog(x,y){ tempX=x; tempY=y; selectedType='normal'; document.getElementById('nodeDialog').style.display='block'; }
function closeNodeDialog(){ document.getElementById('nodeDialog').style.display='none'; }
document.querySelectorAll('#nodeDialog .typeBtn').forEach(btn=>btn.addEventListener('click',()=>{ selectedType=btn.dataset.type; }));
function saveNode(){
const name=document.getElementById('nodeName').value.trim();
const floor=document.querySelector('input[name="floorType"]:checked').value+document.getElementById('floorNumber').value+'층';
const note=document.getElementById('nodeNote').value;
if(!name){ alert("노드 이름 입력"); return; }
const fullName=`${floor} ${name}`;
if(nodes[fullName]){ alert("이미 존재"); return; }
nodes[fullName]={x:tempX,y:tempY,floor,type:selectedType,note};
nodeNames.push(fullName);
// 같은 층 일반 연결
nodeNames.forEach(n=>{
if(n!==fullName && nodes[n].floor===floor){
edges[n]=edges[n]||[]; edges[n].push(fullName);
edges[fullName]=edges[fullName]||[]; edges[fullName].push(n);
}
});
// 층간 이동 커넥터 연결
if(selectedType==='escalator'||selectedType==='stairs'||selectedType==='elevator'){
for(let n of nodeNames){
if(n===fullName) continue;
if(nodes[n].type===selectedType && getLabel(n)===getLabel(fullName)){
edges[n]=edges[n]||[]; edges[n].push(fullName);
edges[fullName]=edges[fullName]||[]; edges[fullName].push(n);
}
}
}
updateSelectOptions(); closeNodeDialog(); draw();
}
// ----------------- 그림 -----------------
function draw(){
ctx.clearRect(0,0,canvas.width,canvas.height);
if(floorImg) ctx.drawImage(floorImg,0,0,canvas.width,canvas.height);
// 경로 그리기
if(pathNodes.length>1){
ctx.strokeStyle='red'; ctx.lineWidth=4;
ctx.beginPath();
pathNodes.forEach((n,i)=>{
const node=nodes[n];
if(i===0) ctx.moveTo(node.x,node.y);
else ctx.lineTo(node.x,node.y);
});
ctx.stroke();
}
// 노드 그리기
nodeNames.forEach(n=>{
const node=nodes[n];
ctx.beginPath();
ctx.arc(node.x,node.y,8,0,Math.PI*2);
if(n===blinkNode) ctx.fillStyle='yellow';
else if(pathNodes.includes(n)) ctx.fillStyle='red';
else ctx.fillStyle=node.type==='normal'?'blue':(node.type==='stairs'?'orange':node.type==='elevator'?'green':'purple');
ctx.fill();
ctx.strokeStyle='#000'; ctx.stroke();
});
}
resizeCanvas();
</script>
</body>
</html>