Skip to content

Commit 0810e01

Browse files
committed
The web panel can create areas too, not just draw them (#144)
The first commit gave the panel a map that draws areas and no way to make one. "Yönetim panellerinden" is plural, and an operator who can only create areas from the desktop app has half a feature. Same two ways in as the desktop editor, for the same reason: clicking chunks is how you draw a town you can see, typing coordinates is how you enter the four hundred somebody sent you in a message. They edit one selection, so switching mid-edit loses nothing. Picking is a MODE, not a held modifier. This panel is used on a phone, where there is no shift key, and the map already pans on drag - a distance check tells a click from the end of a pan. Taking a chunk back out expands the selection and re-derives it without that chunk, rather than dropping the rectangle containing it. Rects are merged on the way in, so the chunk under the pointer is usually inside one covering forty others; dropping it would take all forty. The smoke removes the middle chunk of a merged row of four and checks that three survive. The shared map engine gained three optional host hooks - the picker state, the selection, and a redraw signal. The public site defines none of them and gets a read-only map, which is the point: one engine, and what a surface may do is what it declares, not what it is trusted to avoid calling. Verified: 12/12 gates. Two test defects fixed on the way: the stub canvas had no setLineDash, so a dashed selection failed the run with a TypeError that read like a page bug; and the tidy comparison went through JSON.stringify, which called a difference in key order a difference in the answer.
1 parent 973f90e commit 0810e01

3 files changed

Lines changed: 275 additions & 3 deletions

File tree

src/main/smoke.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4175,6 +4175,13 @@ function runPageScript(html: string, seed: Record<string, unknown> = {}): PageRu
41754175
fill: () => {},
41764176
fillRect: () => {},
41774177
fillText: () => {},
4178+
// Areas draw outlines and a dashed selection (#144). A stub that is
4179+
// missing a method the page calls fails the whole run with a TypeError,
4180+
// which reads like a bug in the page rather than a gap in the stub.
4181+
strokeRect: () => {},
4182+
strokeText: () => {},
4183+
setLineDash: () => {},
4184+
drawImage: () => {},
41784185
set font(_v: string) {},
41794186
set fillStyle(_v: string) {},
41804187
set strokeStyle(_v: string) {},
@@ -6732,6 +6739,50 @@ export async function runWebSmoke(): Promise<void> {
67326739
}
67336740
if (compared < 5000) return fail('the area cross-check barely ran: ' + compared)
67346741
if (overlaps < 20) return fail('the battery never hit a contested chunk: ' + overlaps)
6742+
6743+
// The panel's chunk picker. Clicking builds a selection, clicking the
6744+
// same chunk again takes it back, and the result is tidied the way the
6745+
// server will tidy it — so the count the operator reads is the count
6746+
// that gets stored.
6747+
const pnl = panel.ctx as { AREA_PICK: areasMod.ChunkRect[]; AREA_PICKING: boolean }
6748+
pnl.AREA_PICK = []
6749+
pnl.AREA_PICKING = true
6750+
for (let cx = 0; cx < 4; cx++) pctx['areaPickChunk'](cx, 0)
6751+
if (pnl.AREA_PICK.length !== 1) {
6752+
return fail('the picker did not merge a row: ' + JSON.stringify(pnl.AREA_PICK))
6753+
}
6754+
if (areasMod.areaChunkCount({ rects: pnl.AREA_PICK }) !== 4) return fail('the picker lost a chunk')
6755+
// Taking one out of the MIDDLE is the case that matters: the rect it
6756+
// sits in covers three others, and dropping the rect drops them too.
6757+
pctx['areaPickChunk'](1, 0)
6758+
if (areasMod.areaChunkCount({ rects: pnl.AREA_PICK }) !== 3) {
6759+
return fail('removing one chunk took ' + (4 - areasMod.areaChunkCount({ rects: pnl.AREA_PICK })) + ' with it')
6760+
}
6761+
for (const c of [0, 2, 3]) {
6762+
if (!pnl.AREA_PICK.some((r) => areasMod.rectHas(r, c, 0))) return fail('chunk ' + c + ' was lost')
6763+
}
6764+
if (pnl.AREA_PICK.some((r) => areasMod.rectHas(r, 1, 0))) return fail('the removed chunk came back')
6765+
// The panel tidies with its own copy of the merge, so it has to agree
6766+
// with the shared one — otherwise the operator counts one thing and
6767+
// the server stores another.
6768+
const theirsTidy = pctx['areaTidy']([
6769+
{ x1: 0, z1: 0, x2: 0, z2: 0 }, { x1: 1, z1: 0, x2: 1, z2: 0 },
6770+
{ x1: 5, z1: 5, x2: 9, z2: 9 }, { x1: 6, z1: 6, x2: 7, z2: 7 }
6771+
]) as areasMod.ChunkRect[]
6772+
const mineTidy = areasMod.normalizeRects([
6773+
{ x1: 0, z1: 0, x2: 0, z2: 0 }, { x1: 1, z1: 0, x2: 1, z2: 0 },
6774+
{ x1: 5, z1: 5, x2: 9, z2: 9 }, { x1: 6, z1: 6, x2: 7, z2: 7 }
6775+
])
6776+
// By value, not by JSON: the two build their objects with the fields
6777+
// in different orders, which `JSON.stringify` reports as a difference
6778+
// and no consumer of these rects can even observe.
6779+
const canon = (rs: areasMod.ChunkRect[]): string =>
6780+
rs.map((r) => [r.x1, r.z1, r.x2, r.z2].join(',')).join(' ')
6781+
if (canon(theirsTidy) !== canon(mineTidy)) {
6782+
return fail('the panel tidies differently: ' + canon(theirsTidy) + ' vs ' + canon(mineTidy))
6783+
}
6784+
pnl.AREA_PICKING = false
6785+
pnl.AREA_PICK = []
67356786
}
67366787

67376788
// #104: the same empty state on the PUBLIC page must not talk about

src/main/web/panelHtml.ts

Lines changed: 198 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { iconSvg, STRUCTURE_ICONS } from '@shared/mapIcons'
77
import { usageSamples, API_KEY_HEADER, USAGE_NOTES } from '@shared/apiUsage'
88
import { API_PREFIX } from '@shared/apiSurface'
99
import { MAP_CSS, MAP_HTML, MAP_JS } from '@shared/mapUi'
10+
import { AREA_COLOURS } from '@shared/chunkAreas'
1011
export 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. */
8411038
function togglePerf(){var b=document.getElementById('mpPerfBody');

src/shared/mapUi.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -302,11 +302,17 @@ function mapBindNav(){
302302
and the two are only told apart by how far the pointer moved. */
303303
cv.addEventListener('mousedown',function(e){MAP._down={x:e.clientX,y:e.clientY}});
304304
cv.addEventListener('click',function(e){
305-
if(!MAP.view||!MAP.areasOn)return;
305+
if(!MAP.view)return;
306306
var d0=MAP._down;
307307
if(d0&&(Math.abs(e.clientX-d0.x)>4||Math.abs(e.clientY-d0.y)>4))return;
308308
var pt=mapS2W(mapLocalPoint(e,cv));
309309
var c=mapChunkOf(pt.x,pt.z);
310+
/* Picking, when the host has an editor open, takes the click: adding a chunk
311+
and pinning the area under it at the same time would fight each other. */
312+
if(typeof mapAreaPicking==='function'&&mapAreaPicking()){
313+
if(typeof mapAreaPickChunk==='function')mapAreaPickChunk(c.cx,c.cz);
314+
return}
315+
if(!MAP.areasOn)return;
310316
mapPinArea(mapAreaAt(MAP_AREAS,c.cx,c.cz,MAP.dim))});
311317
cv.addEventListener('wheel',function(e){
312318
if(!MAP.view)return;
@@ -358,7 +364,24 @@ function mapAreasUrl(){
358364
return sid?('/api/servers/'+sid+'/areas'):'/api/public/map/areas?dim='+encodeURIComponent(MAP.dim)}
359365
function mapFetchAreas(){
360366
mapGet(mapAreasUrl()).then(function(d){
361-
MAP_AREAS=(d&&d.areas)||[];mapDraw()})}
367+
MAP_AREAS=(d&&d.areas)||[];
368+
/* Optional host hook, like mapServerId: the panel keeps an editable list
369+
beside the map and has to redraw it when the areas change. The public site
370+
defines nothing and gets nothing. */
371+
if(typeof mapAreasChanged==='function')mapAreasChanged();
372+
mapDraw()})}
373+
/* The selection in progress, when the host is offering one. Drawn in white
374+
rather than a palette colour so it cannot be mistaken for a saved area. */
375+
function mapDrawPick(g,w,h,dpr){
376+
if(typeof mapAreaPickRects!=='function')return;
377+
var rs=mapAreaPickRects()||[];if(!rs.length)return;
378+
var sx=w/MAP.vp.width,sy=h/MAP.vp.height;
379+
for(var i=0;i<rs.length;i++){var r=rs[i];
380+
var p0=mapW2S({x:r.x1*16,z:r.z1*16}),p1=mapW2S({x:(r.x2+1)*16,z:(r.z2+1)*16});
381+
var x=p0.x*sx,y=p0.y*sy,ww=(p1.x-p0.x)*sx,hh=(p1.y-p0.y)*sy;
382+
g.fillStyle='rgba(255,255,255,.22)';g.fillRect(x,y,ww,hh);
383+
g.strokeStyle='rgba(255,255,255,.9)';g.lineWidth=1.5*dpr;
384+
g.setLineDash([4*dpr,3*dpr]);g.strokeRect(x,y,ww,hh);g.setLineDash([])}}
362385
function mapDrawAreas(g,w,h,dpr){
363386
if(!MAP.areasOn||!MAP.view)return;
364387
var sx=w/MAP.vp.width,sy=h/MAP.vp.height;
@@ -727,6 +750,7 @@ function mapDraw(){
727750
/* Areas sit on the terrain and under everything that has to stay readable: a
728751
translucent claim over a village icon is fine, over a player is not. */
729752
mapDrawAreas(g,w,h,dpr);
753+
mapDrawPick(g,w,h,dpr);
730754
/* After the grid and the heatmap, before the players: a marker under a grid
731755
line reads as a smudge, and a player must never be hidden behind one. */
732756
mapDrawMarks(g,w,h,dpr);

0 commit comments

Comments
 (0)