@@ -7,6 +7,7 @@ import { iconSvg, STRUCTURE_ICONS } from '@shared/mapIcons'
77import { usageSamples , API_KEY_HEADER , USAGE_NOTES } from '@shared/apiUsage'
88import { API_PREFIX } from '@shared/apiSurface'
99import { MAP_CSS , MAP_HTML , MAP_JS } from '@shared/mapUi'
10+ import { AREA_COLOURS } from '@shared/chunkAreas'
1011export function getPanelHtml ( ) : string {
1112 return `<!doctype html><html lang="en"><head>
1213<meta charset="utf-8"/>
@@ -345,6 +346,50 @@ h2{margin:8px 0;font-weight:800;letter-spacing:-.4px}
345346 <div id="panelTimeline" class="hidden"><div class="card tight" id="dEvents"></div></div>
346347 <div id="panelMap" class="hidden">
347348 <div class="card">${ MAP_HTML } </div>
349+ <!-- Named chunk areas (#144). Hidden without the settings scope, like the
350+ performance card: an area is stored on the server and shown to
351+ everyone, so writing one is not a per-session view preference. -->
352+ <div class="card hidden" id="mpAreas">
353+ <div class="row"><button class="btn sm" onclick="toggleAreaCard()"><span id="mpAreaCaret">▸</span></button>
354+ <b>Chunk areas</b><div class="spacer"></div>
355+ <button class="btn sm" onclick="areaNew()">New area</button></div>
356+ <div id="mpAreaBody" style="display:none;margin-top:10px">
357+ <div class="dim" style="font-size:12px;margin-bottom:10px">
358+ A named, coloured region measured in chunks. Everyone looking at the map sees it and can
359+ read the note — on the public site too, unless you mark it operator-only.
360+ </div>
361+ <div id="mpAreaList" class="mp-list" style="margin-bottom:10px"></div>
362+ <div id="mpAreaForm" class="hidden">
363+ <div class="row" style="margin-bottom:6px">
364+ <input id="mpAreaName" placeholder="Area name, e.g. spawn town" style="flex:1;min-width:160px"/>
365+ <span id="mpAreaSwatches"></span>
366+ </div>
367+ <input id="mpAreaNote" placeholder="Note — shown under the name, e.g. owner: CaYatur"
368+ style="width:100%;margin-bottom:6px"/>
369+ <div class="row" style="margin-bottom:6px">
370+ <button class="btn sm" id="mpAreaPickBtn" onclick="areaTogglePick()">Pick chunks on the map</button>
371+ <span class="dim" id="mpAreaCount" style="font-size:12px"></span>
372+ <button class="btn sm" onclick="areaClearPick()">Clear selection</button>
373+ <div class="spacer"></div>
374+ <label class="row" style="gap:6px;font-size:12px">
375+ <input type="checkbox" id="mpAreaHidden"/> Operator only
376+ </label>
377+ </div>
378+ <div class="row" style="margin-bottom:6px">
379+ <input id="mpAreaTyped" placeholder="Or type: 10,20 or 30,40 - 32,42"
380+ style="flex:1;min-width:200px;font-family:ui-monospace,monospace;font-size:12px"/>
381+ <button class="btn sm" onclick="areaAddTyped()">Add</button>
382+ </div>
383+ <div class="dim" id="mpAreaMsg" style="font-size:12px;margin-bottom:6px"></div>
384+ <div class="row">
385+ <button class="btn primary sm" onclick="areaSave()">Save</button>
386+ <button class="btn sm" onclick="areaCancel()">Cancel</button>
387+ <div class="spacer"></div>
388+ <button class="btn sm danger hidden" id="mpAreaDel" onclick="areaDelete()">Delete</button>
389+ </div>
390+ </div>
391+ </div>
392+ </div>
348393 <!-- The same per-server dials the desktop map has (#133). Hidden without
349394 the settings scope: these persist and apply to every surface, so they
350395 are not a per-session view preference. -->
@@ -507,7 +552,7 @@ function showTab(tab){activeTab=tab;
507552 /* Only poll while the tab is visible: the feed is every two seconds, and a
508553 background tab quietly hammering it is the kind of cost nobody attributes
509554 to the page they left open. */
510- if(tab==='map'){mapStart();loadPerf()}else mapStop();
555+ if(tab==='map'){mapStart();loadPerf();loadAreas() }else mapStop();
511556 if(tab==='manage')loadManage();
512557 if(tab==='stats')loadStats();
513558 if(tab==='timeline')loadEvents();
@@ -836,6 +881,158 @@ function mapFeedUrl(dim,cell){
836881 return '/api/servers/'+mapServerId()+'/map?dim='+encodeURIComponent(dim)+'&cell='+encodeURIComponent(cell)}
837882/* The map engine does not know how this page wraps a response, and must not:
838883 the two pages disagree, and it used to assume this one (#115). */
884+ /* ---- the chunk-area editor (#144) ----
885+ Two ways in on purpose: clicking chunks is how you draw a town you can see,
886+ typing coordinates is how you enter the four hundred somebody sent you. They
887+ edit the same selection, so switching between them mid-edit loses nothing.
888+ The colours are the shared palette, pasted in as data. */
889+ var AREA_COLOURS=${ JSON . stringify ( AREA_COLOURS ) } ;
890+ var AREA_EDIT=null,AREA_PICK=[],AREA_PICKING=false,AREA_COLOUR=AREA_COLOURS[0];
891+ /* The hooks the shared map engine looks for. Optional there — the public site
892+ defines none of them and gets a read-only map. */
893+ function mapAreasChanged(){areaRenderList()}
894+ function mapAreaPicking(){return AREA_PICKING}
895+ function mapAreaPickRects(){return AREA_PICKING?AREA_PICK:[]}
896+ function toggleAreaCard(){var b=document.getElementById('mpAreaBody');
897+ var open=b.style.display!=='none';b.style.display=open?'none':'block';
898+ document.getElementById('mpAreaCaret').textContent=open?'▸':'▾'}
899+ function loadAreas(){
900+ var card=document.getElementById('mpAreas');if(!card||!current)return;
901+ /* Reading areas needs the view scope, which anyone on this map already has;
902+ WRITING them needs settings. Showing the editor to a session that cannot
903+ save is offering a form that ends in a 403. */
904+ var may=current.scopes.indexOf('settings')>=0;
905+ card.classList.toggle('hidden',!may);
906+ mapFetchAreas();
907+ if(may)areaRenderList()}
908+ function areaRenderList(){
909+ var el=document.getElementById('mpAreaList');if(!el)return;
910+ var here=MAP_AREAS.filter(function(a){return mapNormDim(a.dim)===mapNormDim(MAP.dim)});
911+ el.innerHTML=here.length?here.map(function(a){
912+ return '<button class="mp-chip" style="border-color:'+mapEsc(a.colour)+
913+ (a.hidden?';opacity:.55':'')+'" onclick="areaEdit(\\''+a.id+'\\')">'+
914+ '<span style="display:inline-block;width:9px;height:9px;border-radius:3px;margin-right:5px;background:'+
915+ mapEsc(a.colour)+'"></span>'+mapEsc(a.name)+(a.hidden?' ·':'')+'</button>'}).join('')
916+ :'<span class="dim" style="font-size:12px">No areas in '+mapEsc(MAP.dim)+' yet.</span>'}
917+ function areaSwatches(){
918+ document.getElementById('mpAreaSwatches').innerHTML=AREA_COLOURS.map(function(c){
919+ return '<button title="'+c+'" onclick="areaSetColour(\\''+c+'\\')" style="width:22px;height:22px;'+
920+ 'border-radius:6px;cursor:pointer;margin-left:4px;background:'+c+';border:'+
921+ (c===AREA_COLOUR?'2px solid #fff':'1px solid rgba(0,0,0,.35)')+'"></button>'}).join('')}
922+ function areaSetColour(c){AREA_COLOUR=c;areaSwatches()}
923+ function areaNew(){
924+ AREA_EDIT=null;AREA_PICK=[];AREA_COLOUR=AREA_COLOURS[0];
925+ document.getElementById('mpAreaBody').style.display='block';
926+ document.getElementById('mpAreaCaret').textContent='▾';
927+ document.getElementById('mpAreaForm').classList.remove('hidden');
928+ document.getElementById('mpAreaName').value='';
929+ document.getElementById('mpAreaNote').value='';
930+ document.getElementById('mpAreaHidden').checked=false;
931+ document.getElementById('mpAreaDel').classList.add('hidden');
932+ document.getElementById('mpAreaMsg').textContent='';
933+ areaSwatches();areaSetPicking(true);areaCountText()}
934+ function areaEdit(id){
935+ var a=null;for(var i=0;i<MAP_AREAS.length;i++)if(MAP_AREAS[i].id===id)a=MAP_AREAS[i];
936+ if(!a)return;
937+ AREA_EDIT=a;AREA_PICK=(a.rects||[]).slice();AREA_COLOUR=a.colour;
938+ document.getElementById('mpAreaForm').classList.remove('hidden');
939+ document.getElementById('mpAreaName').value=a.name;
940+ document.getElementById('mpAreaNote').value=a.note||'';
941+ document.getElementById('mpAreaHidden').checked=!!a.hidden;
942+ document.getElementById('mpAreaDel').classList.remove('hidden');
943+ document.getElementById('mpAreaMsg').textContent='';
944+ areaSwatches();areaSetPicking(false);areaCountText();
945+ /* Centre on it. An area listed but off-screen is a name with nowhere to look. */
946+ if(MAP.view&&AREA_PICK.length){var r=AREA_PICK[0];
947+ MAP.view={cx:(r.x1+r.x2+1)*8,cz:(r.z1+r.z2+1)*8,scale:MAP.view.scale}}
948+ mapDraw()}
949+ function areaCancel(){
950+ AREA_EDIT=null;AREA_PICK=[];areaSetPicking(false);
951+ document.getElementById('mpAreaForm').classList.add('hidden');mapDraw()}
952+ function areaSetPicking(on){
953+ AREA_PICKING=on;
954+ var b=document.getElementById('mpAreaPickBtn');
955+ if(b){b.textContent=on?'Picking — click chunks to add or remove':'Pick chunks on the map';
956+ b.className='btn sm'+(on?' primary':'')}
957+ mapDraw()}
958+ function areaTogglePick(){areaSetPicking(!AREA_PICKING)}
959+ function areaClearPick(){AREA_PICK=[];areaCountText();mapDraw()}
960+ function areaCountText(){
961+ var n=0;for(var i=0;i<AREA_PICK.length;i++){var r=AREA_PICK[i];
962+ n+=(r.x2-r.x1+1)*(r.z2-r.z1+1)}
963+ var el=document.getElementById('mpAreaCount');
964+ if(el)el.textContent=n+' chunks in '+AREA_PICK.length+' rectangles'}
965+ /* Tidy the selection the same way the server will: duplicates and contained
966+ rects go, neighbours that share a full edge merge. Done here as well so the
967+ count the operator reads is the count that gets stored. */
968+ function areaTidy(list){
969+ var out=[];
970+ for(var i=0;i<list.length;i++){var r=list[i];
971+ if(!r||!isFinite(r.x1)||!isFinite(r.z1)||!isFinite(r.x2)||!isFinite(r.z2))continue;
972+ out.push({x1:Math.min(r.x1,r.x2),x2:Math.max(r.x1,r.x2),
973+ z1:Math.min(r.z1,r.z2),z2:Math.max(r.z1,r.z2)})}
974+ var merged=true;
975+ while(merged&&out.length>1){merged=false;
976+ for(var a=0;a<out.length&&!merged;a++){
977+ for(var b=a+1;b<out.length;b++){
978+ var p=out[a],q=out[b],j=null;
979+ if(p.z1===q.z1&&p.z2===q.z2&&(p.x2+1===q.x1||q.x2+1===p.x1))
980+ j={x1:Math.min(p.x1,q.x1),x2:Math.max(p.x2,q.x2),z1:p.z1,z2:p.z2};
981+ else if(p.x1===q.x1&&p.x2===q.x2&&(p.z2+1===q.z1||q.z2+1===p.z1))
982+ j={x1:p.x1,x2:p.x2,z1:Math.min(p.z1,q.z1),z2:Math.max(p.z2,q.z2)};
983+ else if(q.x1>=p.x1&&q.x2<=p.x2&&q.z1>=p.z1&&q.z2<=p.z2)j=p;
984+ else if(p.x1>=q.x1&&p.x2<=q.x2&&p.z1>=q.z1&&p.z2<=q.z2)j=q;
985+ if(j){var keep=[];for(var k=0;k<out.length;k++)if(k!==a&&k!==b)keep.push(out[k]);
986+ keep.push(j);out=keep;merged=true;break}}}}
987+ out.sort(function(m,n){return m.x1-n.x1||m.z1-n.z1||m.x2-n.x2||m.z2-n.z2});
988+ return out}
989+ function areaPickChunk(cx,cz){
990+ var has=false;
991+ for(var i=0;i<AREA_PICK.length;i++){var r=AREA_PICK[i];
992+ if(cx>=r.x1&&cx<=r.x2&&cz>=r.z1&&cz<=r.z2)has=true}
993+ if(has){
994+ /* Expand and drop the one chunk. Removing the whole rectangle that happens to
995+ contain it would throw away the forty others it was merged with. */
996+ var flat=[];
997+ for(var j=0;j<AREA_PICK.length;j++){var q=AREA_PICK[j];
998+ for(var x=q.x1;x<=q.x2;x++)for(var z=q.z1;z<=q.z2;z++)
999+ if(!(x===cx&&z===cz))flat.push({x1:x,z1:z,x2:x,z2:z})}
1000+ AREA_PICK=areaTidy(flat)}
1001+ else AREA_PICK=areaTidy(AREA_PICK.concat([{x1:cx,z1:cz,x2:cx,z2:cz}]));
1002+ areaCountText();mapDraw()}
1003+ function areaAddTyped(){
1004+ var el=document.getElementById('mpAreaTyped');var text=el.value||'';
1005+ var lines=text.split(/[\\n;]+/),bad=[],add=[];
1006+ for(var i=0;i<lines.length;i++){var line=lines[i].trim();if(!line)continue;
1007+ var nums=line.match(/-?\\d+/g);
1008+ if(!nums||(nums.length!==2&&nums.length!==4)){bad.push(line);continue}
1009+ var n=nums.map(Number);
1010+ add.push(n.length===2?{x1:n[0],z1:n[1],x2:n[0],z2:n[1]}
1011+ :{x1:n[0],z1:n[1],x2:n[2],z2:n[3]})}
1012+ AREA_PICK=areaTidy(AREA_PICK.concat(add));
1013+ el.value='';
1014+ document.getElementById('mpAreaMsg').textContent=bad.length?('Not understood: '+bad.join(', ')):'';
1015+ areaCountText();mapDraw()}
1016+ function areaSave(){
1017+ if(!current)return;
1018+ var name=document.getElementById('mpAreaName').value.trim();
1019+ var msg=document.getElementById('mpAreaMsg');
1020+ if(!name){msg.textContent='Give the area a name.';return}
1021+ if(!AREA_PICK.length){msg.textContent='Pick at least one chunk, or type some coordinates.';return}
1022+ var body={name:name,note:document.getElementById('mpAreaNote').value.trim(),
1023+ colour:AREA_COLOUR,
1024+ /* The area keeps ITS dimension when edited, so opening the nether and saving
1025+ a rename does not silently move a town. */
1026+ dim:AREA_EDIT?AREA_EDIT.dim:MAP.dim,rects:AREA_PICK,
1027+ hidden:document.getElementById('mpAreaHidden').checked};
1028+ if(AREA_EDIT)body.areaId=AREA_EDIT.id;
1029+ api('/api/servers/'+current.id+'/areas',{method:'POST',body:JSON.stringify(body)}).then(function(r){
1030+ if(!r.ok){msg.textContent=(r.body&&r.body.error)||'Could not save the area.';return}
1031+ areaCancel();mapFetchAreas()})}
1032+ function areaDelete(){
1033+ if(!current||!AREA_EDIT)return;
1034+ api('/api/servers/'+current.id+'/areas?areaId='+encodeURIComponent(AREA_EDIT.id),{method:'DELETE'})
1035+ .then(function(){areaCancel();mapFetchAreas()})}
8391036/* Map performance, per server and persisted (#133). Only for a session that may
8401037 change settings — these apply to every surface, not just this browser. */
8411038function togglePerf(){var b=document.getElementById('mpPerfBody');
0 commit comments