İlk commit: Stepstead web (statik panel + db)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jts 2026-07-11 11:30:59 +03:00
commit dea01dd6a8
18 changed files with 3764 additions and 0 deletions

758
assets/map-editor.html Normal file
View file

@ -0,0 +1,758 @@
<!DOCTYPE html>
<html lang="tr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex,nofollow">
<title>Stepstead — Harita Editörü</title>
<style>
:root{--bg:#0f1419;--panel:#1a2230;--line:#2c3a4f;--txt:#e6edf3;--muted:#8b99a8;--gold:#e8b923;--ok:#2ea043;--err:#e5534b}
*{box-sizing:border-box}
body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--txt);line-height:1.5}
.wrap{max-width:1500px;margin:0 auto;padding:16px}
h1{font-size:20px;color:var(--gold);margin:0 0 4px}
.sub{color:var(--muted);font-size:13px;margin-bottom:14px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px;margin-bottom:14px}
input,button,select,textarea{font-size:14px;border-radius:6px}
input,select,textarea{background:#222d3d;color:var(--txt);border:1px solid var(--line);padding:8px 10px}
input[type=email],input[type=password]{width:100%;margin-bottom:10px}
textarea{width:100%;min-height:54px;resize:vertical}
.btn{background:var(--gold);color:#1a1300;border:none;padding:8px 16px;font-weight:600;cursor:pointer}
.btn.sec{background:#2a3648;color:var(--txt)}
.btn.danger{background:var(--err);color:#fff}
.btn:disabled{opacity:.45;cursor:default}
.msg{font-size:13px;margin-top:8px}
.msg.ok{color:var(--ok)} .msg.err{color:var(--err)}
.hide{display:none}
.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px}
.toolbar .grp{display:flex;gap:6px;align-items:center;background:#141c28;border:1px solid var(--line);border-radius:8px;padding:6px 8px}
.modebtn{background:#2a3648;color:var(--txt);border:1px solid var(--line);padding:7px 12px;cursor:pointer;border-radius:6px}
.modebtn.on{background:var(--gold);color:#1a1300;border-color:var(--gold);font-weight:600}
.maparea{position:relative;width:100%;user-select:none;line-height:0;margin-bottom:14px}
#mapview{position:relative;width:100%}
#mapzoom{position:relative;width:100%;transform-origin:0 0}
#mapimg{width:100%;display:block;border-radius:8px;border:1px solid var(--line)}
#svg{position:absolute;inset:0;width:100%;height:100%;cursor:crosshair}
/* tam ekran düzenleme modu */
.maparea.fs{position:fixed;inset:0;z-index:2000;width:100vw;height:100vh;height:100dvh;margin:0;overflow:hidden;background:#0b0f14;display:flex;flex-direction:column}
.maparea.fs #mapview{flex:1;min-height:0;overflow:hidden;touch-action:none}
.maparea.fs #mapimg{border-radius:0;border:none}
.toolbar.fsfloat{flex:0 0 auto;margin:0;background:#141c28;border:none;border-bottom:1px solid var(--line);border-radius:0;padding:6px 8px;max-height:42vh;overflow:auto}
.maparea.fs #fsOpenBtn{display:none}
.zoomctl{display:none}
.maparea.fs .zoomctl{display:flex;position:absolute;right:10px;bottom:10px;z-index:5;flex-direction:column;gap:6px}
.zoomctl button{width:44px;height:44px;font-size:20px;font-weight:700;background:rgba(20,28,40,.92);color:var(--txt);border:1px solid var(--line);border-radius:8px;cursor:pointer;line-height:1;display:flex;align-items:center;justify-content:center}
.zoomctl button:hover{border-color:var(--gold)}
#fsClose{display:none}
.maparea.fs #fsClose{display:inline-flex}
#formCard{max-width:560px;margin-bottom:14px}
.taggroups{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px;align-items:start}
@media(max-width:900px){.taggroups{grid-template-columns:1fr}}
.taggroups .card{margin-bottom:0}
.grouphead{display:flex;align-items:center;gap:8px;font-size:14px;font-weight:600;margin-bottom:6px}
.grouphead .cnt{color:var(--muted);font-size:12px;font-weight:400}
.taglist{max-height:340px;overflow:auto}
.gempty{color:var(--muted);font-size:12px;padding:6px 2px}
.tagrow{display:flex;align-items:center;gap:8px;padding:6px 4px;border-top:1px solid var(--line);font-size:13px;cursor:pointer}
.tagrow:hover{background:#141c28}
.tagrow.sel{background:#243247}
.swatch{width:12px;height:12px;border-radius:3px;flex:0 0 12px}
.knd{color:var(--muted);font-size:11px;margin-left:auto}
.delx{flex:0 0 auto;background:none;border:none;color:var(--muted);cursor:pointer;font-size:15px;padding:0 4px;line-height:1}
.delx:hover{color:var(--err)}
.hint{color:var(--muted);font-size:12px;margin-top:8px}
label{font-size:12px;color:var(--muted);display:block;margin:8px 0 3px}
.row{display:flex;gap:8px}
.row>*{flex:1}
.regchecks{display:flex;flex-wrap:wrap;gap:6px;max-height:140px;overflow:auto;background:#141c28;border:1px solid var(--line);border-radius:6px;padding:8px}
.regchecks label{display:inline-flex;align-items:center;gap:5px;margin:0;font-size:12px;color:var(--txt);background:#222d3d;border:1px solid var(--line);border-radius:14px;padding:3px 9px;cursor:pointer}
.regchecks label:hover{border-color:var(--gold)}
.regchecks input{width:auto;margin:0}
.regchecks label.auto{border-color:var(--ok)}
.regchecks .gempty{color:var(--muted);font-size:12px}
polygon,polyline,circle,text{vector-effect:non-scaling-stroke}
#svg.draw *{pointer-events:none}
.veclabel{fill:#fff;paint-order:stroke;stroke:#000;stroke-width:3px;font-weight:600}
</style>
<script src="/nav.js?v=8" data-theme="dark" defer></script>
</head>
<body>
<div class="wrap">
<h1>🗺 Harita Editörü</h1>
<div class="sub">Haritada kıta/bölge/nokta işaretle, lokasyonlar arası yol çiz → DB'ye kaydet. Kıtalar: Eldoria · Kaldün · Meridia</div>
<div class="card" id="loginCard">
<input type="email" id="email" placeholder="E-posta" autocomplete="username">
<input type="password" id="pass" placeholder="Şifre" autocomplete="current-password">
<button class="btn" id="loginBtn">Giriş</button>
<div class="msg" id="loginMsg"></div>
</div>
<div id="editor" class="hide">
<div class="toolbar">
<div class="grp">
<span style="color:var(--muted);font-size:12px">Mod:</span>
<button class="modebtn on" data-mode="select">👆 Seç</button>
<button class="modebtn" data-mode="region">▱ Bölge çiz</button>
<button class="modebtn" data-mode="poi">📍 Nokta koy</button>
<button class="modebtn" data-mode="road">🛣 Yol çiz</button>
</div>
<div class="grp" id="drawCtl" style="display:none">
<span id="drawInfo" style="font-size:12px;color:var(--muted)">0 köşe</span>
<button class="modebtn" id="undoPt">↩︎ Geri al</button>
<button class="btn" id="finishBtn">✓ Bitir</button>
<button class="modebtn" id="cancelDraw">✕ İptal</button>
</div>
<div class="grp" id="roadCtl" style="display:none">
<span id="roadInfo" style="font-size:12px;color:var(--muted)">Başlangıç lokasyonuna tıkla</span>
<button class="modebtn" id="undoWp">↩︎ Geri al</button>
<button class="modebtn" id="cancelRoad">✕ İptal</button>
</div>
<div class="grp" style="gap:10px">
<span style="color:var(--muted);font-size:12px">Göster:</span>
<label style="margin:0"><input type="checkbox" id="showLabels" checked style="width:auto"> İsimler</label>
<label style="margin:0"><input type="checkbox" id="showRegions" checked style="width:auto"> Bölgeler</label>
<label style="margin:0"><input type="checkbox" id="showRoads" checked style="width:auto"> Yollar</label>
<label style="margin:0"><input type="checkbox" id="showPois" checked style="width:auto"> Noktalar</label>
</div>
<div class="grp">
<button class="btn" id="fsOpenBtn" title="Haritayı tam ekranda düzenle">🖊 Tam ekran düzenle</button>
</div>
</div>
<div class="maparea" id="maparea">
<div id="mapview">
<div id="mapzoom">
<img id="mapimg" src="/assets/world_map@4x.webp?v=2" alt="Dünya haritası">
<svg id="svg" viewBox="0 0 1 1" preserveAspectRatio="none"></svg>
</div>
</div>
<div class="zoomctl">
<button type="button" id="fsClose" title="Tam ekranı kapat" style="background:var(--gold);color:#1a1300"></button>
<button type="button" id="zoomIn" title="Yakınlaştır">+</button>
<button type="button" id="zoomOut" title="Uzaklaştır"></button>
<button type="button" id="zoomReset" title="Sığdır" style="font-size:15px"></button>
</div>
</div>
<div class="card hide" id="formCard">
<b id="formTitle" style="font-size:14px">Yeni etiket</b>
<label>İsim</label>
<input type="text" id="fName" placeholder="örn. Fısıltı Ormanı">
<div class="row">
<div>
<label>Tür</label>
<select id="fKind">
<option value="region">Bölge</option>
<option value="continent">Kıta</option>
<option value="poi">Nokta (POI)</option>
</select>
</div>
<div>
<label>Kıta</label>
<select id="fCont">
<option value=""></option>
<option>Eldoria</option>
<option>Kaldün</option>
<option>Meridia</option>
</select>
</div>
</div>
<div id="catWrap">
<label>Kategori <span style="color:var(--muted)">(tip — ikon/renk otomatik gelir)</span></label>
<select id="fCategory"><option value="">— (belirsiz) —</option></select>
</div>
<div id="regionsWrap" class="hide">
<label>Bölge(ler) — nokta hangi bölgede? <span style="color:var(--muted)">(içinde olduğu bölgeler otomatik işaretlendi)</span></label>
<div id="fRegions" class="regchecks"></div>
</div>
<label>Not (opsiyonel)</label>
<textarea id="fNote" placeholder="lore / açıklama"></textarea>
<div style="display:flex;gap:8px;margin-top:12px">
<button class="btn" id="saveBtn">Kaydet</button>
<button class="btn sec" id="closeForm">Vazgeç</button>
<button class="btn danger hide" id="delBtn" style="margin-left:auto">Sil</button>
</div>
<div class="msg" id="formMsg"></div>
</div>
<div class="card hide" id="roadForm" style="max-width:560px">
<b id="roadTitle" style="font-size:14px">Yeni yol</b>
<div style="font-size:13px;margin:8px 0" id="roadEnds"></div>
<div class="row">
<div>
<label>Adım maliyeti</label>
<input type="number" id="rCost" min="1" max="100000" value="300">
</div>
<div>
<label>Olay şansı (0-1)</label>
<input type="number" id="rEvent" min="0" max="1" step="0.05" value="0.3">
</div>
</div>
<div class="hint" id="rWpInfo">0 ara nokta</div>
<div style="display:flex;gap:8px;margin-top:12px">
<button class="btn" id="saveRoad">Kaydet</button>
<button class="btn sec" id="closeRoad">Vazgeç</button>
<button class="btn danger hide" id="delRoadBtn" style="margin-left:auto">Sil</button>
</div>
<div class="msg" id="roadMsg"></div>
</div>
<div class="taggroups">
<div class="card">
<div class="grouphead">🌍 Kıtalar <span class="cnt" id="cntContinent">0</span></div>
<div class="taglist" id="listContinent"></div>
</div>
<div class="card">
<div class="grouphead">▱ Bölgeler <span class="cnt" id="cntRegion">0</span></div>
<div class="taglist" id="listRegion"></div>
</div>
<div class="card">
<div class="grouphead">📍 Noktalar <span class="cnt" id="cntPoi">0</span></div>
<div class="taglist" id="listPoi"></div>
</div>
<div class="card">
<div class="grouphead">🛣 Yollar <span class="cnt" id="cntRoad">0</span></div>
<div class="taglist" id="listRoad"></div>
</div>
</div>
<div class="hint" id="listHint">Yükleniyor…</div>
</div>
</div>
<script>
const API="https://api.stepstead.com";
const SVGNS="http://www.w3.org/2000/svg";
let TOKEN=localStorage.getItem("ss_upload_token")||"";
let CAN_EDIT=false; // map_edit izni — yoksa salt okunur (map_view)
let mode="select";
let tags=[]; // sunucudan gelen etiketler
let draft=[]; // çizilmekte olan köşeler [[x,y],...]
let editingId=null; // seçili/düzenlenen etiket id (null = yeni)
let draftPoint=null; // poi için tek nokta
let locs=[]; // lokasyonlar (yol uçları) — {id,code,name,type,x,y}
let roads=[]; // yollar — {id,a,b,step_cost,event_chance,waypoints}
let roadA=null,roadB=null;// çizilen yolun uç lokasyon code'ları
let roadWps=[]; // çizilen yolun ara noktaları [[x,y],...]
let roadEditId=null; // düzenlenen yol id (null = yeni)
const $=id=>document.getElementById(id);
const CONT_COLOR={Eldoria:"#3fa34d",["Kaldün"]:"#e0632a",Meridia:"#3a7bd5"};
let CATS={}; // kategori kataloğu {key:{kind,label,icon,color}}
let VENDOR_CATS=[],QUEST_CATS=[];
function cat(k){return k&&CATS[k]?CATS[k]:null;}
function catIcon(k){return cat(k)?cat(k).icon:"";}
function catLabel(k){return cat(k)?cat(k).label:"";}
function colorFor(t){return t.color||(cat(t.category)?cat(t.category).color:null)||CONT_COLOR[t.continent]||"#9aa7b5";}
function catOptions(kind,selected){
let html='<option value="">— (belirsiz) —</option>';
for(const k in CATS)if(CATS[k].kind===kind)
html+=`<option value="${k}"${k===selected?" selected":""}>${CATS[k].icon} ${esc(CATS[k].label)}</option>`;
return html;
}
async function loadCats(){
const r=await api("/map-tags/categories");
if(r.ok){CATS=r.body.categories||{};VENDOR_CATS=r.body.vendor||[];QUEST_CATS=r.body.questgiver||[];}
}
async function api(path,opts={}){
opts.headers=Object.assign({"Content-Type":"application/json"},opts.headers,{Authorization:"Bearer "+TOKEN});
const r=await fetch(API+path,opts);
let body={};try{body=await r.json();}catch(e){}
return {ok:r.ok,status:r.status,body};
}
/* ---- auth ---- */
async function login(){
$("loginMsg").textContent="…";
const r=await fetch(API+"/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({email:$("email").value.trim(),password:$("pass").value})});
const b=await r.json().catch(()=>({}));
if(r.ok&&b.access_token){TOKEN=b.access_token;localStorage.setItem("ss_upload_token",TOKEN);showEditor();}
else{$("loginMsg").className="msg err";$("loginMsg").textContent=b.detail||"Giriş başarısız";}
}
async function showEditor(){
// izin kontrolü: map_edit → tam yetki, map_view → salt okunur, yoksa red
try{
const r=await fetch(API+"/auth/me",{headers:{Authorization:"Bearer "+TOKEN}});
if(!r.ok){
if(r.status===401){TOKEN="";localStorage.removeItem("ss_upload_token");}
$("loginMsg").className="msg err";$("loginMsg").textContent="Oturum geçersiz — tekrar giriş yap";
return;
}
const u=await r.json();
const perms=u.permissions||[];
const has=p=>perms.includes("*")||perms.includes(p);
CAN_EDIT=has("map_edit");
if(!CAN_EDIT&&!has("map_view")){
$("loginMsg").className="msg err";$("loginMsg").textContent="Bu hesabın harita editörü yetkisi yok";
return;
}
}catch(e){$("loginMsg").className="msg err";$("loginMsg").textContent="Bağlantı hatası";return;}
$("loginCard").classList.add("hide");$("editor").classList.remove("hide");
if(!CAN_EDIT){
document.querySelectorAll('.modebtn[data-mode="region"],.modebtn[data-mode="poi"],.modebtn[data-mode="road"]').forEach(b=>b.style.display="none");
document.querySelector(".sub").textContent="Salt okunur görünüm — etiketleri görebilirsin, değiştiremezsin.";
}
await loadCats();
await loadTags();
await loadLocsRoads();
}
/* ---- veri ---- */
async function loadTags(){
const r=await api("/map-tags");
if(!r.ok){$("listHint").textContent="Yüklenemedi";return;}
tags=r.body;render();
}
async function loadLocsRoads(){
const [l,r]=await Promise.all([api("/map-tags/locations"),api("/map-tags/roads")]);
if(l.ok)locs=l.body;
if(r.ok)roads=r.body;
render();
}
function locByCode(c){return locs.find(l=>l.code===c);}
// Yol ismi: "A'dan B'ye" (basit Türkçe ünlü uyumu — ayrılma + yönelme eki)
function _lastVowel(w){const m=(w||"").toLowerCase().match(/[aeıioöuü]/g);return m?m[m.length-1]:"e";}
function _back(v){return "aıou".includes(v);}
function _ablative(w){ // 'dan/'den/'tan/'ten
const b=_back(_lastVowel(w)); const d=/[çfhkpsşt]$/i.test((w||"").trim())?"t":"d";
return w+"'"+d+(b?"an":"en");
}
function _dative(w){ // 'a/'e/'ya/'ye
const b=_back(_lastVowel(w)); const y=/[aeıioöuü]$/i.test((w||"").trim())?"y":"";
return w+"'"+y+(b?"a":"e");
}
function roadLabel(rd){
const a=locByCode(rd.a),b=locByCode(rd.b);
return _ablative(a?a.name:rd.a)+" "+_dative(b?b.name:rd.b);
}
// yol tipine göre renk
const LOC_COLOR={village:"#e8b923",city:"#f0872a",forest:"#3fa34d",mine:"#9aa7b5",lake:"#3a7bd5",meadow:"#8fce5a",hunting:"#c06a3a"};
function locColor(l){return LOC_COLOR[l&&l.type]||"#c9d4e0";}
// verilen normalize noktaya en yakın lokasyonu bul (eşik içinde), yoksa null
function locAt(pt){
let best=null,bd=0.022;
for(const l of locs){const d=Math.hypot(l.x-pt[0],l.y-pt[1]);if(d<bd){bd=d;best=l;}}
return best;
}
/* ---- koordinat: tıklama -> normalize 0-1 ---- */
function evtToNorm(e){
const rect=$("svg").getBoundingClientRect();
let x=(e.clientX-rect.left)/rect.width, y=(e.clientY-rect.top)/rect.height;
return [Math.min(1,Math.max(0,+x.toFixed(4))),Math.min(1,Math.max(0,+y.toFixed(4)))];
}
function centroid(pts){
let x=0,y=0;for(const p of pts){x+=p[0];y+=p[1];}return [x/pts.length,y/pts.length];
}
function pointInPoly(pt,poly){
let inside=false,x=pt[0],y=pt[1];
for(let i=0,j=poly.length-1;i<poly.length;j=i++){
const xi=poly[i][0],yi=poly[i][1],xj=poly[j][0],yj=poly[j][1];
if(((yi>y)!==(yj>y))&&(x<(xj-xi)*(y-yi)/(yj-yi)+xi))inside=!inside;
}
return inside;
}
// verilen noktayı içeren kıta etiketinin kıta adını döndür (yoksa "")
function continentAt(pt){
for(const t of tags){
if(t.kind==="continent"&&t.points.length>=3&&pointInPoly(pt,t.points))
return t.continent||t.name||"";
}
return "";
}
// verilen noktayı içeren bölge (region) id'lerini döndür — üst üste binenlerin hepsi
function regionsAt(pt){
const out=[];
for(const t of tags){
if(t.kind==="region"&&t.points.length>=3&&pointInPoly(pt,t.points))out.push(t.id);
}
return out;
}
// bölge checkbox listesini çiz (checked = seçili, auto = otomatik tespit edilenler)
function renderRegionChecks(selected,autoIds){
const wrap=$("fRegions");wrap.innerHTML="";
const regs=tags.filter(t=>t.kind==="region");
if(!regs.length){wrap.innerHTML='<div class="gempty">Henüz bölge çizilmemiş.</div>';return;}
const sel=new Set(selected||[]),auto=new Set(autoIds||[]);
for(const r of regs){
const lb=document.createElement("label");
if(auto.has(r.id))lb.className="auto";
lb.innerHTML=`<input type="checkbox" value="${r.id}" ${sel.has(r.id)?"checked":""} ${CAN_EDIT?"":"disabled"}>
<span>${esc(r.name)}${r.continent?" · "+esc(r.continent):""}</span>`;
wrap.appendChild(lb);
}
}
function selectedRegions(){
return [...$("fRegions").querySelectorAll("input:checked")].map(i=>+i.value);
}
function toggleRegionsWrap(kind){
$("regionsWrap").classList.toggle("hide",kind!=="poi");
}
/* ---- render ---- */
function render(){
const svg=$("svg");svg.innerHTML="";
const showLbl=$("showLabels").checked;
// kayıtlı yollar (en altta) — lokasyon uçları + ara noktalardan geçen polyline
if($("showRoads").checked)
for(const rd of roads){
const a=locByCode(rd.a),b=locByCode(rd.b);if(!a||!b)continue;
const pts=[[a.x,a.y],...(rd.waypoints||[]),[b.x,b.y]];
const sel=rd.id===roadEditId;
const ln=el("polyline",{points:pts.map(p=>p.join(",")).join(" "),fill:"none",
stroke:sel?"#fff":"#b98a3a","stroke-width":sel?3.2:2});
ln.setAttribute("pointer-events",mode==="select"?"auto":"none");
ln.style.cursor="pointer";ln.onclick=ev=>{ev.stopPropagation();selectRoad(rd);};
svg.appendChild(ln);
}
// kayıtlı etiketler
for(const t of tags){
if(t.kind==="poi" && !$("showPois").checked) continue;
if(t.kind!=="poi" && !$("showRegions").checked) continue;
const c=colorFor(t),sel=t.id===editingId;
if(t.kind==="poi"){
const [x,y]=t.points[0];
const dot=el("circle",{cx:x,cy:y,r:0.008,fill:c,stroke:sel?"#fff":"#000","stroke-width":sel?2:1});
dot.style.cursor="pointer";dot.onclick=ev=>{ev.stopPropagation();selectTag(t);};
svg.appendChild(dot);
if(showLbl)svg.appendChild(text(x+0.012,y+0.004,(catIcon(t.category)?catIcon(t.category)+" ":"")+t.name));
}else{
const poly=el("polygon",{points:t.points.map(p=>p.join(",")).join(" "),
fill:c+"33",stroke:c,"stroke-width":sel?3:1.6});
poly.style.cursor="pointer";poly.onclick=ev=>{ev.stopPropagation();selectTag(t);};
svg.appendChild(poly);
if(showLbl){const [cx,cy]=centroid(t.points);svg.appendChild(text(cx,cy,(catIcon(t.category)?catIcon(t.category)+" ":"")+t.name,"middle"));}
}
}
// çizim taslağı
if(draft.length){
svg.appendChild(el("polyline",{points:draft.map(p=>p.join(",")).join(" "),
fill:"none",stroke:"#e8b923","stroke-width":2,"stroke-dasharray":"4 3"}));
for(const p of draft)svg.appendChild(el("circle",{cx:p[0],cy:p[1],r:0.006,fill:"#e8b923"}));
}
if(draftPoint)svg.appendChild(el("circle",{cx:draftPoint[0],cy:draftPoint[1],r:0.008,fill:"#e8b923",stroke:"#000"}));
// lokasyon pinleri (yol uçları) — yol modunda tıklanabilir, diğerlerinde referans
for(const l of locs){
const isEnd=mode==="road"&&(l.code===roadA||l.code===roadB);
const c=el("circle",{cx:l.x,cy:l.y,r:0.0075,fill:locColor(l),
stroke:isEnd?"#fff":"#0a0f16","stroke-width":isEnd?2.6:1.4});
c.setAttribute("pointer-events",mode==="road"?"auto":"none");
if(mode==="road"){c.style.cursor="pointer";c.onclick=ev=>{ev.stopPropagation();pickRoadEnd(l);};}
svg.appendChild(c);
if(showLbl&&mode==="road")svg.appendChild(text(l.x,l.y-0.016,l.name,"middle"));
}
// çizilen yol taslağı
if(mode==="road"&&roadA){
const a=locByCode(roadA);
const pts=[[a.x,a.y],...roadWps];
if(roadB)pts.push([locByCode(roadB).x,locByCode(roadB).y]);
svg.appendChild(el("polyline",{points:pts.map(p=>p.join(",")).join(" "),fill:"none",
stroke:"#e8b923","stroke-width":2.5,"stroke-dasharray":"4 3"}));
for(const p of roadWps)svg.appendChild(el("circle",{cx:p[0],cy:p[1],r:0.005,fill:"#e8b923"}));
}
// liste — türe göre 3 grup
const lists={continent:$("listContinent"),region:$("listRegion"),poi:$("listPoi")};
const counts={continent:0,region:0,poi:0};
for(const k in lists)lists[k].innerHTML="";
$("listHint").textContent=tags.length?"":"Henüz etiket yok. Bir mod seç, haritaya çiz.";
// zone↔pin çapraz: her bölgenin içindeki pinler + her pin'in bölgesi
const pinsByRegion={},tagById={};
for(const t of tags)tagById[t.id]=t;
for(const t of tags)if(t.kind==="poi")for(const rid of (t.region_ids||[])){(pinsByRegion[rid]=pinsByRegion[rid]||[]).push(t);}
for(const t of tags){
const row=document.createElement("div");
row.className="tagrow"+(t.id===editingId?" sel":"");
let meta=t.category?(catIcon(t.category)+" "+catLabel(t.category)):"";
if(t.kind==="region"){const ps=pinsByRegion[t.id]||[];if(ps.length)meta+=` · içinde ${ps.map(p=>catIcon(p.category)||"📍").join("")} ${ps.length}`;}
if(t.kind==="poi"){const zs=(t.region_ids||[]).map(id=>tagById[id]).filter(Boolean);if(zs.length)meta+=" · ⤷ "+zs.map(z=>esc(z.name)).join(", ");}
if(t.continent)meta+=(meta?" · ":"")+esc(t.continent);
row.innerHTML=`<span class="swatch" style="background:${colorFor(t)}"></span>
<span>${catIcon(t.category)?catIcon(t.category)+" ":""}${esc(t.name)}</span><span class="knd">${meta}</span>
${CAN_EDIT?'<button class="delx" title="Sil"></button>':""}`;
const dx=row.querySelector(".delx");
if(dx)dx.onclick=ev=>{ev.stopPropagation();delTag(t.id,t.name);};
row.onclick=()=>selectTag(t);
const k=lists[t.kind]?t.kind:"region";
lists[k].appendChild(row);counts[k]++;
}
$("cntContinent").textContent=counts.continent;
$("cntRegion").textContent=counts.region;
$("cntPoi").textContent=counts.poi;
for(const k in lists)
if(!counts[k])lists[k].innerHTML='<div class="gempty">Boş</div>';
// Yollar listesi — "A'dan B'ye"
const lr=$("listRoad");lr.innerHTML="";
for(const rd of roads){
const wpn=(rd.waypoints||[]).length;
const row=document.createElement("div");
row.className="tagrow"+(rd.id===roadEditId?" sel":"");
row.innerHTML=`<span class="swatch" style="background:#b98a3a"></span>
<span>${esc(roadLabel(rd))}</span>
<span class="knd">${rd.step_cost}👣${wpn?" · "+wpn+" nokta":""}</span>
${CAN_EDIT?'<button class="delx" title="Sil"></button>':""}`;
const dx=row.querySelector(".delx");
if(dx)dx.onclick=ev=>{ev.stopPropagation();delRoad(rd.id);};
row.onclick=()=>selectRoad(rd);
lr.appendChild(row);
}
$("cntRoad").textContent=roads.length;
if(!roads.length)lr.innerHTML='<div class="gempty">Boş — 🛣 Yol çiz moduyla ekle</div>';
$("drawInfo").textContent=draft.length+" köşe";
$("finishBtn").disabled=draft.length<3;
}
function el(tag,attrs){const n=document.createElementNS(SVGNS,tag);for(const k in attrs)n.setAttribute(k,attrs[k]);return n;}
function text(x,y,s,anchor){const t=el("text",{x,y,class:"veclabel","text-anchor":anchor||"start"});t.setAttribute("font-size","0.02");t.textContent=s;return t;}
function esc(s){return (s||"").replace(/[&<>]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));}
/* ---- mod ---- */
function setMode(m){
mode=m;draft=[];draftPoint=null;
if(m!=="road"){roadA=roadB=roadEditId=null;roadWps=[];$("roadForm").classList.add("hide");}
document.querySelectorAll(".modebtn[data-mode]").forEach(b=>b.classList.toggle("on",b.dataset.mode===m));
$("drawCtl").style.display=(m==="region")?"flex":"none";
$("roadCtl").style.display=(m==="road")?"flex":"none";
// 'draw' sınıfı çocukların pointer-event'lerini kapatır — yol modunda pinler tıklanabilir kalmalı
$("svg").classList.toggle("draw",m==="region"||m==="poi");
$("svg").style.cursor=(m==="select")?"pointer":"crosshair";
if(m==="road")updateRoadInfo();
render();
}
function updateRoadInfo(){
let s;
if(!roadA)s="Başlangıç lokasyonuna tıkla";
else if(!roadB)s=`${nm(roadA)} → … (ara nokta koy, sonra varış lokasyonuna tıkla)`;
else s=`${nm(roadA)} → ${nm(roadB)}`;
$("roadInfo").textContent=s;
$("rWpInfo").textContent=roadWps.length+" ara nokta";
}
function nm(code){const l=locByCode(code);return l?l.name:code;}
// yol modunda lokasyon pinine tıklama: 1.=başlangıç, 2.=varış → form aç
function pickRoadEnd(l){
if(!roadA){roadA=l.code;roadWps=[];roadB=null;updateRoadInfo();render();return;}
if(l.code===roadA){return;} // aynı pine tekrar → yoksay
roadB=l.code;
openRoadForm(null);
updateRoadInfo();render();
}
/* ---- çizim ---- */
$("svg").addEventListener("click",e=>{
if(mode==="region"){draft.push(evtToNorm(e));render();}
else if(mode==="poi"){draftPoint=evtToNorm(e);openForm(null,"poi");render();}
else if(mode==="road"){
const pt=evtToNorm(e);
const near=locAt(pt);
if(near){pickRoadEnd(near);return;}
if(roadA&&!roadB){roadWps.push(pt);updateRoadInfo();render();}
}
});
$("undoPt").onclick=()=>{draft.pop();render();};
$("cancelDraw").onclick=()=>{draft=[];render();};
$("finishBtn").onclick=()=>{if(draft.length>=3)openForm(null,"region");};
/* ---- form ---- */
function openForm(tag,kind){
$("formCard").classList.remove("hide");$("formMsg").textContent="";
// salt okunurda kaydet/sil gizli, alanlar kilitli
$("saveBtn").style.display=CAN_EDIT?"":"none";
for(const id of ["fName","fKind","fCategory","fCont","fNote"])$(id).disabled=!CAN_EDIT;
if(tag){ // düzenleme
editingId=tag.id;$("formTitle").textContent="Düzenle: "+tag.name;
$("fName").value=tag.name;$("fKind").value=tag.kind;$("fCont").value=tag.continent||"";
$("fCategory").innerHTML=catOptions(tag.kind,tag.category||"");
$("fNote").value=tag.note||"";$("delBtn").classList.remove("hide");
// POI ise: mevcut bölge seçimini göster; noktanın altındaki bölgeleri "auto" vurgula
const autoR=tag.kind==="poi"&&tag.points[0]?regionsAt(tag.points[0]):[];
renderRegionChecks(tag.region_ids||[],autoR);
toggleRegionsWrap(tag.kind);
}else{ // yeni — çizilen alanın merkezi hangi kıtadaysa onu otomatik seç
editingId=null;$("formTitle").textContent="Yeni etiket";
const pt=(kind==="poi")?draftPoint:(draft.length?centroid(draft):null);
const cont=pt?continentAt(pt):"";
$("fName").value="";$("fKind").value=kind;$("fCont").value=cont;$("fNote").value="";
$("fCategory").innerHTML=catOptions(kind,"");
$("delBtn").classList.add("hide");
// POI: içinde olduğu bölgeleri otomatik seç
const autoR=(kind==="poi"&&pt)?regionsAt(pt):[];
renderRegionChecks(autoR,autoR);
toggleRegionsWrap(kind);
}
$("catWrap").classList.toggle("hide",$("fKind").value==="continent");
if(!CAN_EDIT)$("delBtn").classList.add("hide");
render();
$("formCard").scrollIntoView({behavior:"smooth",block:"nearest"});
$("fName").focus();
}
function selectTag(t){setMode("select");openForm(t,t.kind);}
$("closeForm").onclick=()=>{editingId=null;draft=[];draftPoint=null;$("formCard").classList.add("hide");render();};
$("saveBtn").onclick=async()=>{
const name=$("fName").value.trim();
if(!name){$("formMsg").className="msg err";$("formMsg").textContent="İsim gerekli";return;}
const kind=$("fKind").value;
let points;
if(editingId){ // mevcut geometriyi koru
const cur=tags.find(t=>t.id===editingId);points=cur.points;
}else if(kind==="poi"){
if(!draftPoint){$("formMsg").textContent="Haritada nokta seç";return;}points=[draftPoint];
}else{
if(draft.length<3){$("formMsg").textContent="En az 3 köşe";return;}points=draft.slice();
}
const category=$("fCategory").value||null;
const payload={name,kind,category,continent:$("fCont").value||null,points,
color:cat(category)?cat(category).color:null,
note:$("fNote").value.trim()||null,
region_ids:kind==="poi"?selectedRegions():[]};
$("formMsg").className="msg";$("formMsg").textContent="Kaydediliyor…";
const r=editingId?await api("/map-tags/"+editingId,{method:"POST",body:JSON.stringify(payload)})
:await api("/map-tags",{method:"POST",body:JSON.stringify(payload)});
if(!r.ok){$("formMsg").className="msg err";$("formMsg").textContent=r.body.detail||("Hata "+r.status);return;}
draft=[];draftPoint=null;editingId=null;$("formCard").classList.add("hide");
await loadTags();
};
async function delTag(id,name){
if(!confirm("Silinsin mi? — "+name))return;
const r=await api("/map-tags/"+id+"/delete",{method:"POST"});
if(!r.ok){alert("Silinemedi ("+r.status+")");return;}
if(editingId===id){editingId=null;$("formCard").classList.add("hide");}
await loadTags();
}
$("delBtn").onclick=()=>{if(editingId)delTag(editingId,$("fName").value);};
/* ---- yol formu ---- */
function openRoadForm(rd){
$("roadForm").classList.remove("hide");$("roadMsg").textContent="";
if(rd){ // mevcut yolu düzenle
roadEditId=rd.id;roadA=rd.a;roadB=rd.b;roadWps=(rd.waypoints||[]).map(p=>p.slice());
$("roadTitle").textContent="Yolu düzenle";
$("rCost").value=rd.step_cost;$("rEvent").value=rd.event_chance.toFixed(2);
$("delRoadBtn").classList.remove("hide");
}else{ // yeni yol (roadA/roadB zaten seçili)
roadEditId=null;$("roadTitle").textContent="Yeni yol";
$("delRoadBtn").classList.add("hide");
}
$("roadEnds").innerHTML=`🛣 <b>${esc(roadLabel({a:roadA,b:roadB}))}</b>`;
$("rWpInfo").textContent=roadWps.length+" ara nokta";
$("roadForm").scrollIntoView({behavior:"smooth",block:"nearest"});
}
function selectRoad(rd){setMode("road");openRoadForm(rd);render();}
$("saveRoad").onclick=async()=>{
if(!roadA||!roadB){$("roadMsg").className="msg err";$("roadMsg").textContent="İki uç lokasyon gerekli";return;}
const payload={a:roadA,b:roadB,step_cost:parseInt($("rCost").value)||0,
event_chance:parseFloat($("rEvent").value)||0,waypoints:roadWps};
$("roadMsg").className="msg";$("roadMsg").textContent="Kaydediliyor…";
const r=await api("/map-tags/roads",{method:"POST",body:JSON.stringify(payload)});
if(!r.ok){$("roadMsg").className="msg err";$("roadMsg").textContent=(r.body&&r.body.detail)||("Hata "+r.status);return;}
roadA=roadB=roadEditId=null;roadWps=[];$("roadForm").classList.add("hide");
await loadLocsRoads();updateRoadInfo();
};
async function delRoad(id){
if(!confirm("Bu yol silinsin mi?"))return;
const r=await api("/map-tags/roads/"+id+"/delete",{method:"POST"});
if(!r.ok){alert("Silinemedi ("+r.status+")");return;}
roadA=roadB=roadEditId=null;roadWps=[];$("roadForm").classList.add("hide");
await loadLocsRoads();updateRoadInfo();
}
$("delRoadBtn").onclick=()=>{if(roadEditId)delRoad(roadEditId);};
$("closeRoad").onclick=()=>{roadA=roadB=roadEditId=null;roadWps=[];$("roadForm").classList.add("hide");updateRoadInfo();render();};
$("undoWp").onclick=()=>{roadWps.pop();updateRoadInfo();render();};
$("cancelRoad").onclick=()=>{roadA=roadB=roadEditId=null;roadWps=[];$("roadForm").classList.add("hide");updateRoadInfo();render();};
/* ---- bağla ---- */
$("loginBtn").onclick=login;
$("pass").addEventListener("keydown",e=>{if(e.key==="Enter")login();});
document.querySelectorAll(".modebtn[data-mode]").forEach(b=>b.onclick=()=>setMode(b.dataset.mode));
$("fKind").addEventListener("change",()=>{
const k=$("fKind").value;
toggleRegionsWrap(k);
$("catWrap").classList.toggle("hide",k==="continent");
$("fCategory").innerHTML=catOptions(k,"");
});
// kategori seçilince isim boşsa otomatik öner: "Orman 3"
$("fCategory").addEventListener("change",()=>{
const c=$("fCategory").value;
if(!c||$("fName").value.trim())return;
const n=tags.filter(t=>t.category===c).length+1;
$("fName").value=catLabel(c)+" "+n;
});
$("showLabels").onchange=render;
$("showRegions").onchange=render;
$("showRoads").onchange=render;
$("showPois").onchange=render;
/* ---- tam ekran düzenleme + pan/zoom (yalnız fullscreen'de aktif) ---- */
(function(){
const area=$("maparea"), view=$("mapview"), zoom=$("mapzoom"), toolbar=document.querySelector(".toolbar");
let fsActive=false, scale=1, tx=0, ty=0, MIN=1; const MAX=8;
function apply(){ zoom.style.transform=`translate(${tx}px,${ty}px) scale(${scale})`; }
function clamp(){
const vw=view.clientWidth, vh=view.clientHeight;
const cw=vw*scale, ch=zoom.offsetHeight*scale;
tx = cw<=vw ? (vw-cw)/2 : Math.max(vw-cw, Math.min(0, tx)); // sığıyorsa ortala
ty = ch<=vh ? (vh-ch)/2 : Math.max(vh-ch, Math.min(0, ty));
}
// tüm haritayı görünüme sığdır (contain), ortala — taban zoom
function fit(){
const baseH=zoom.offsetHeight;
if(!baseH || !view.clientHeight) return;
MIN=Math.min(1, view.clientHeight/baseH); // her şeyi görecek kadar uzaklaş
scale=MIN; tx=0; ty=0; clamp(); apply();
}
function zoomAt(cx,cy,ns){
ns=Math.max(MIN,Math.min(MAX,ns));
const r=view.getBoundingClientRect(), px=cx-r.left, py=cy-r.top;
tx=px-(px-tx)*(ns/scale); ty=py-(py-ty)*(ns/scale);
scale=ns; clamp(); apply();
}
function centerZoom(f){ const r=view.getBoundingClientRect(); zoomAt(r.left+view.clientWidth/2, r.top+view.clientHeight/2, scale*f); }
function fsOpen(){
if(fsActive) return; fsActive=true;
area.classList.add("fs");
toolbar.classList.add("fsfloat"); area.prepend(toolbar); // araçları overlay içine taşı
document.body.style.overflow="hidden";
requestAnimationFrame(fit);
}
function fsClose(){
if(!fsActive) return; fsActive=false;
toolbar.classList.remove("fsfloat"); area.parentNode.insertBefore(toolbar, area); // geri koy
area.classList.remove("fs");
document.body.style.overflow="";
scale=1; tx=0; ty=0; zoom.style.transform=""; // normal görünüme dön
}
$("fsOpenBtn").onclick=fsOpen;
$("fsClose").onclick=fsClose;
$("zoomIn").onclick=()=>centerZoom(1.4);
$("zoomOut").onclick=()=>centerZoom(1/1.4);
$("zoomReset").onclick=fit;
document.addEventListener("keydown",e=>{ if(e.key==="Escape"&&fsActive) fsClose(); });
view.addEventListener("wheel",e=>{ if(!fsActive) return; e.preventDefault(); zoomAt(e.clientX,e.clientY,scale*(e.deltaY<0?1.15:1/1.15)); },{passive:false});
let pts=new Map(), startDist=0, startScale=1, lastMid=null;
view.addEventListener("touchstart",e=>{
if(!fsActive) return;
for(const t of e.changedTouches) pts.set(t.identifier,{x:t.clientX,y:t.clientY});
if(pts.size===2){ const [a,b]=[...pts.values()]; startDist=Math.hypot(a.x-b.x,a.y-b.y); startScale=scale; lastMid={x:(a.x+b.x)/2,y:(a.y+b.y)/2}; }
},{passive:false});
view.addEventListener("touchmove",e=>{
if(!fsActive) return;
for(const t of e.changedTouches) if(pts.has(t.identifier)) pts.set(t.identifier,{x:t.clientX,y:t.clientY});
if(pts.size>=2){
e.preventDefault();
const [a,b]=[...pts.values()];
const dist=Math.hypot(a.x-b.x,a.y-b.y), mid={x:(a.x+b.x)/2,y:(a.y+b.y)/2};
if(lastMid){ tx+=mid.x-lastMid.x; ty+=mid.y-lastMid.y; } lastMid=mid;
if(startDist>0) zoomAt(mid.x,mid.y,startScale*(dist/startDist)); else { clamp(); apply(); }
}
},{passive:false});
function end(e){ for(const t of e.changedTouches) pts.delete(t.identifier); if(pts.size<2){ startDist=0; lastMid=null; } }
view.addEventListener("touchend",end); view.addEventListener("touchcancel",end);
window.addEventListener("resize",()=>{ if(fsActive) fit(); });
})();
if(TOKEN)showEditor();
</script>
</body>
</html>