<!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;
nodes = {};
edges = {};
nodeNames = [];
pathNodes = [];
blinkNode = null;
if(blinkInterval){ clearInterval(blinkInterval); blinkInterval=null; }
updateSelectOptions();
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);
return;
}
}
openNodeDialog(x,y);
});
function findNodeAt(x,y){
for(let name of nodeNames){
const node = nodes[name];
const dx=node.x-x;
const dy=node.y-y;
if(Math.sqrt(dx*dx+dy*dy)<=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 distance(n1,n2){
const dx=n1.x-n2.x;
const dy=n1.y-n2.y;
return Math.sqrt(dx*dx+dy*dy);
}
// Dijkstra with allowed nodes
function dijkstra(start,end,allowedNodes=null){
const visited=new Set();
const distances={};
const prev={};
const targetNodes=allowedNodes||nodeNames;
targetNodes.forEach(node=>distances[node]=Infinity);
distances[start]=0;
while(visited.size<targetNodes.length){
let minNode=null;
for(let node of targetNodes){
if(!visited.has(node)&&(minNode===null||distances[node]<distances[minNode])){
minNode=node;
}
}
if(minNode===null) break;
if(minNode===end) break;
visited.add(minNode);
for(let neighbor of edges[minNode]||[]){
if(!targetNodes.includes(neighbor)) continue;
let alt=distances[minNode]+distance(nodes[minNode],nodes[neighbor]);
if(alt<distances[neighbor]){
distances[neighbor]=alt;
prev[neighbor]=minNode;
}
}
}
const path=[];
let curr=end;
while(curr){
path.unshift(curr);
curr=prev[curr];
}
return path;
}
function getLabel(fullName){
return fullName.split(' ').slice(1).join(' ').trim();
}
/* ====================== [UPDATED] 규칙 기반 층간 이동 지원 ======================= */
// 층 문자열을 파싱해서 {level, zone} 반환
// level: 지상 양수, 지하 음수. zone: '' | 'A' | 'B'
function parseFloor(floorStr){
const s = floorStr.trim();
const isBasement = s.includes('지하');
const isGround = s.includes('지상') || (!isBasement && s.includes('층'));
// 숫자
const numMatch = s.match(/(\d+)/);
const num = numMatch ? parseInt(numMatch[1],10) : 0;
const level = isBasement ? -num : num;
// 분기(zone): "지하2층 A", "지하3층 B" 등에서 A/B 인식
let zone = '';
const zoneMatch = s.match(/[AB]\b/i);
if(zoneMatch){
zone = zoneMatch[0].toUpperCase();
}else{
// "지하2층A" 같이 붙어있는 경우
const compactZone = s.replace(/\s+/g,'').match(/지하[23]층([AB])/i);
if(compactZone) zone = compactZone[1].toUpperCase();
}
return { level, zone };
}
// 같은 "구역" 판정: level 동일 && zone 동일 (단, zone이 없으면 동일 층으로 간주)
function sameArea(f1, f2){
const a = parseFloor(f1), b = parseFloor(f2);
if(a.level !== b.level) return false;
// 분기가 있는 층(-2, -3)에서는 zone까지 같아야 같은 층으로 간주
const isSplit = (a.level === -2 || a.level === -3);
if(isSplit) return a.zone === b.zone && a.zone !== '';
return true;
}
// 규칙 테이블: (fromState) -> [ {toState, filters[]} ]
// filter: {type:'stairs'|'escalator'|'elevator', labelIncludes:'1번' 등}
const RULES = {
// 지상1층 -> 지하1층 : 1번/4번/5번 계단
'1:': [ { to:'-1:', filters:[{type:'stairs',labelIncludes:'1번'},{type:'stairs',labelIncludes:'4번'},{type:'stairs',labelIncludes:'5번'}] } ],
// 지하1층 -> 지하2층 A : 1번 에스컬레이터
'-1:': [ { to:'-2:A', filters:[{type:'escalator',labelIncludes:'1번'}] } ],
// 지하2층 A -> 지하3층 A/B : 2번/3번 에스컬레이터
'-2:A': [
{ to:'-3:A', filters:[{type:'escalator',labelIncludes:'2번'}] },
{ to:'-3:B', filters:[{type:'escalator',labelIncludes:'3번'}] }
],
// 지상2층 -> 지하2층 B : 2번 계단
'2:': [ { to:'-2:B', filters:[{type:'stairs',labelIncludes:'2번'}] } ],
// 지상3층 -> 지하2층 B : 3번 계단
'3:': [ { to:'-2:B', filters:[{type:'stairs',labelIncludes:'3번'}] } ],
// 지하2층 B -> 지하3층 A/B : (A: 1번 엘리베이터/4번 에스컬레이터) (B: 2번 엘리베이터/5번 에스컬레이터)
'-2:B': [
{ to:'-3:A', filters:[{type:'elevator',labelIncludes:'1번'},{type:'escalator',labelIncludes:'4번'}] },
{ to:'-3:B', filters:[{type:'elevator',labelIncludes:'2번'},{type:'escalator',labelIncludes:'5번'}] }
]
};
// 상태 키 생성: "level:zone" (zone 없으면 빈 문자열)
function stateKeyFromFloorStr(floorStr){
const {level, zone} = parseFloor(floorStr);
return `${level}:${zone||''}`;
}
// 상태 키에서 사람이 읽는 설명(디버그용)
function prettyState(key){
const [levelStr, zone] = key.split(':');
const level = parseInt(levelStr,10);
const isB = level < 0;
const abs = Math.abs(level);
if(isB){
return `지하${abs}층${zone?` ${zone}`:''}`;
}else{
return `지상${abs}층`;
}
}
// RULES 기반 인접 상태 목록
function neighbors(state){
return (RULES[state]||[]).map(r=>r.to);
}
// BFS로 층 상태 경로 계산
function bfsStatePath(startState, endState){
if(startState===endState) return [startState];
const q=[startState];
const prev={};
const 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){
const 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} = parseStateKey(stateKey);
const candidates = nodeNames.filter(n=>{
const f = parseFloor(nodes[n].floor);
if(f.level!==level) return false;
// 분기층에서는 zone 일치 필요
if((level===-2 || level===-3) && (zone||'') !== (f.zone||'')) return false;
// 타입/라벨 필터 중 하나라도 만족하면 후보
const label = getLabel(n);
return filters.some(fi=>{
return nodes[n].type===fi.type && label.includes(fi.labelIncludes);
});
});
if(candidates.length===0) return null;
// 출발점과 가장 가까운 커넥터 선택
const currNode = nodes[currNodeName];
return candidates.reduce((best,now)=>
distance(currNode, nodes[now]) < distance(currNode, nodes[best]) ? now : best
);
}
// 반대편 층에서 "라벨 동일 + 타입 동일" 노드 찾기
function matchConnectorOnNext(closestCurrName, nextStateKey){
const {level, zone} = parseStateKey(nextStateKey);
const t = nodes[closestCurrName].type;
const label = getLabel(closestCurrName);
const candidates = nodeNames.filter(n=>{
const f = parseFloor(nodes[n].floor);
if(f.level!==level) return false;
if((level===-2 || level===-3) && (zone||'') !== (f.zone||'')) return false;
return nodes[n].type===t && getLabel(n)===label;
});
if(candidates.length===0) return null;
// 현재 커넥터와 가장 가까운 동일 라벨/타입 노드 선택
const curr = nodes[closestCurrName];
return candidates.reduce((best,now)=>
distance(curr, nodes[now]) < distance(curr, nodes[best]) ? now : best
);
}
function parseStateKey(key){
const [lv, zone] = key.split(':');
return { level: parseInt(lv,10), zone: zone||'' };
}
// ---------- 최종 경로 계산 ----------
function multiFloorPath(startName,endName){
const startFloorStr = nodes[startName].floor;
const endFloorStr = nodes[endName].floor;
const startState = stateKeyFromFloorStr(startFloorStr);
const endState = stateKeyFromFloorStr(endFloorStr);
// 같은 구역(같은 층 + 같은 분기)일 때만 같은 층 최단거리 허용
if(sameArea(startFloorStr, endFloorStr)){
const allowed = nodeNames.filter(n=> sameArea(nodes[n].floor, startFloorStr));
return dijkstra(startName, endName, allowed);
}
// 상태 그래프에서 층간 경로 찾기
const statePath = bfsStatePath(startState, endState);
if(!statePath){
alert(`층간 경로가 없습니다. (규칙으로 연결되지 않음)\n${prettyState(startState)} → ${prettyState(endState)}`);
return null;
}
let currNode = startName;
const full = [];
// 각 상태 전이마다: (currState -> nextState)
for(let i=0;i<statePath.length-1;i++){
const currState = statePath[i];
const nextState = statePath[i+1];
// 사용 가능한 필터(커넥터 종류/번호)
const rule = (RULES[currState]||[]).find(r=>r.to===nextState);
if(!rule){
alert(`내부 규칙 오류: ${prettyState(currState)} → ${prettyState(nextState)} 전이를 찾을 수 없습니다.`);
return null;
}
// 1) 현재 상태에서 가장 가까운 "허용 커넥터"
const closestCurr = pickConnectorOnState(currNode, currState, rule.filters);
if(!closestCurr){
alert(`${prettyState(currState)} 에서 사용할 수 있는 커넥터 노드가 없습니다.\n(필요: ${rule.filters.map(f=>`${f.labelIncludes} ${f.type}`).join(' 또는 ')})`);
return null;
}
// 2) 다음 상태에서 같은 라벨/타입의 대응 노드
const closestNext = matchConnectorOnNext(closestCurr, nextState);
if(!closestNext){
alert(`${prettyState(nextState)} 에서 '${getLabel(closestCurr)}' (${nodes[closestCurr].type})에 대응하는 노드를 찾을 수 없습니다.`);
return null;
}
// 3) 현재 상태 내부 최단거리(타입 무시)로 커넥터까지 이동
const allowedCurr = nodeNames.filter(n=> {
const f = nodes[n].floor;
return sameArea(f, 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));
// 4) 층 이동(커넥터 대응 노드로 ‘점프’)
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;
}
/* ====================== [/UPDATED] ======================= */
function findPath(){
const startName=document.getElementById('start').value;
const endName=document.getElementById('end').value;
pathNodes=multiFloorPath(startName,endName)||[];
draw();
}
// 검색 입력 시 label 기준으로 노드 찾기
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(()=>{ draw(visible); visible=!visible; },500);
}
// 길찾기/검색 초기화
function resetAll(){
if(!confirm("길찾기 경로와 검색 상태를 초기화합니다.")) return;
if(blinkInterval){ clearInterval(blinkInterval); blinkInterval=null; }
blinkNode=null;
pathNodes=[];
draw();
}
// 그리기
function draw(blinkVisible=true){
ctx.clearRect(0,0,canvas.width,canvas.height);
if(floorImg){
const scale=Math.min(canvas.width/floorImg.width,canvas.height/floorImg.height);
const imgW=floorImg.width*scale;
const imgH=floorImg.height*scale;
const x=(canvas.width-imgW)/2;
const y=(canvas.height-imgH)/2;
ctx.drawImage(floorImg,x,y,imgW,imgH);
}
nodeNames.forEach(n=>{
ctx.beginPath();
ctx.arc(nodes[n].x,nodes[n].y,6,0,Math.PI*2);
ctx.fillStyle=(n===blinkNode && !blinkVisible)?'white':'gray';
ctx.fill();
ctx.strokeStyle='black';
ctx.stroke();
});
if(pathNodes.length>=2){
ctx.strokeStyle='red';
ctx.lineWidth=4;
ctx.beginPath();
ctx.moveTo(nodes[pathNodes[0]].x,nodes[pathNodes[0]].y);
for(let i=1;i<pathNodes.length;i++) ctx.lineTo(nodes[pathNodes[i]].x,nodes[pathNodes[i]].y);
ctx.stroke();
}
if(pathNodes.length>=1){
[pathNodes[0], pathNodes[pathNodes.length-1]].forEach((node,i)=>{
ctx.beginPath();
ctx.arc(nodes[node].x,nodes[node].y,8,0,Math.PI*2);
ctx.fillStyle=i===0?'green':'blue';
ctx.fill();
ctx.strokeStyle='black';
ctx.stroke();
});
}
}
resizeCanvas();
/* ---------- 노드 추가 다이얼로그 ---------- */
let pendingX=0, pendingY=0;
let selectedType="normal";
document.querySelectorAll(".typeBtn").forEach(btn=>{
btn.addEventListener("click",()=>{
selectedType=btn.dataset.type;
document.querySelectorAll(".typeBtn").forEach(b=>b.style.background="");
btn.style.background="yellow";
});
});
function openNodeDialog(x,y){
pendingX=x; pendingY=y;
selectedType="normal";
document.querySelectorAll(".typeBtn").forEach(b=>b.style.background="");
document.querySelector(".typeBtn[data-type='normal']").style.background="yellow";
document.getElementById("nodeDialog").style.display="block";
}
function closeNodeDialog(){ document.getElementById("nodeDialog").style.display="none"; }
function saveNode(){
const floorType=document.querySelector("input[name=floorType]:checked").value;
const floorNum=document.getElementById("floorNumber").value;
if(!floorNum){ alert("층 번호를 입력하세요"); return; }
let floor=`${floorType}${floorNum}층`;
// 사용자가 직접 A/B 구역을 이름에 넣고 싶다면, 노드 이름에 'A' 또는 'B'를 포함하세요.
// 예: "지하2층 A"로 만들려면 층 입력 뒤, 노드 이름에 A/B를 붙여 저장하거나
// 아래처럼 floor 변수 후처리를 수정해도 됩니다.
const name=document.getElementById("nodeName").value.trim();
if(!name){ alert("노드 이름을 입력하세요"); return; }
const fullName=`${floor} ${name}`;
if(nodes[fullName]){ alert("같은 이름의 노드가 이미 존재합니다."); return; }
const note=document.getElementById("nodeNote").value.trim();
nodes[fullName]={x:pendingX, y:pendingY, floor, type:selectedType, note};
nodeNames.push(fullName);
if(nodeNames.length>1){
const prev=nodeNames[nodeNames.length-2];
if(!edges[prev]) edges[prev]=[];
if(!edges[fullName]) edges[fullName]=[];
edges[prev].push(fullName);
edges[fullName].push(prev);
}
updateSelectOptions();
closeNodeDialog();
draw();
}
</script>
</body>
</html>
댓글 없음:
댓글 쓰기