Skip to content

Commit df43398

Browse files
authored
Draw the structures, and let an operator stop the map reading (#136) (#140)
* Draw the structures, and let an operator stop the map reading (#136) **Structures are glyphs now**, not single letters in coloured dots: a house, a chest, a stepped pyramid, a keep, a pickaxe, and a pin for anything unrecognised. SVG path data in `shared/mapIcons.ts` rather than images — a canvas builds a `Path2D` from a path string, so one definition serves the desktop canvas, both web canvases and any HTML legend, with no asset to ship and no blurring when it scales. Each is a single filled silhouette. A marker is about sixteen device pixels across, and anything with interior detail turns to mud at that size. They sit on a dark disc ringed in the kind's colour, because a bare silhouette disappears against terrain its own colour — a grey pickaxe on stone, a red chest on netherrack. `Path2D` is built once per kind and reused; constructing one per marker per frame is parsing the same string hundreds of times a second. **And loading as the view moves is now a setting**, on by default. Off, the map draws what it already holds and asks for nothing more until you press "Load this view" — so panning across a large world costs nothing at all. That is the answer for a machine where the reading itself is the problem, and it is honest about the trade: new ground stays blank until you ask for it. **The performance settings say what they are.** They persist on the server and every surface reads the same tiles, so sitting in a map toolbar they read as a preference of that window. Both the desktop and the panel now say plainly that a change there applies to the app, the panel and the public site alike. Asserted: every glyph starts with a move, closes, stays inside the box it claims (a path that leaves it will not sit where it is placed), carries a colour and a label, and an unrecognised kind still gets one. `new Path2D` throws nothing on malformed data — it renders an empty shape — so a typo would otherwise be a marker with a hole in it and no error anywhere. * Self-review: the setting I said applies everywhere did not, and a legend nobody could read I wrote "a change here applies to the app, the panel and the public site alike" into the settings UI, and the public site ignored `loadOnPan` entirely. The feed carries it now, so a visitor cannot spend more of the server's budget than the operator allowed — and the sentence is true. `MAP.loadOnPan` was also never initialised, so the fetch guard worked by `undefined !== false` happening to be true. That is a coincidence, not a default. `iconSvg` was exported and used by nothing but its own test. It builds the map's icon key now, because glyphs nobody can name are decoration — and that turned up the real defect: it called `iconFor`, which is the stringified-helper trap from #116. Embedded into a page, a function that calls another throws a `ReferenceError` the moment the bundler renames the callee, with nothing wrong in the source. It is self-contained, both pages define the table under the exact identifier it reads, and the embedded-copy check in the smoke now covers it alongside `avatarUrl` and `itemIconUrl`. Two backticks in comments inside template literals, again. The gate run before this one was against a stale build and told me nothing; the green above is a fresh one.
1 parent f651189 commit df43398

10 files changed

Lines changed: 320 additions & 32 deletions

File tree

src/main/smoke.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ import {
9696
sha256Of
9797
} from '@shared/bridgeRelease'
9898
import type { GhRelease } from '@shared/bridgeRelease'
99+
import { iconFor, iconSvg, ICON_BOX, STRUCTURE_ICONS } from '@shared/mapIcons'
100+
import { STRUCTURE_KINDS } from '@shared/regionFormat'
99101
import {
100102
bitsPerIndex,
101103
blockColour,
@@ -1278,6 +1280,43 @@ export async function runModUpdateSmoke(): Promise<void> {
12781280
if (normalizeMapPerf(null).memoryRegions !== MAP_PERF_DEFAULTS.memoryRegions) {
12791281
return fail('an absent config did not fall back to the defaults')
12801282
}
1283+
if (normalizeMapPerf({}).loadOnPan !== true) return fail('loading as the view moves must default on')
1284+
if (normalizeMapPerf({ loadOnPan: false }).loadOnPan !== false) {
1285+
return fail('loading as the view moves cannot be turned off')
1286+
}
1287+
}
1288+
1289+
// ---- structure glyphs (#136) ----
1290+
//
1291+
// Path data is drawn by `new Path2D(...)`, which throws nothing on
1292+
// nonsense — a malformed path renders as an empty shape, so a typo
1293+
// produces a marker with a hole in it and no error anywhere.
1294+
{
1295+
for (const kind of STRUCTURE_KINDS) {
1296+
const ic = iconFor(kind)
1297+
if (!ic.path) return fail('no glyph for ' + kind)
1298+
if (!ic.colour.startsWith('#')) return fail('the ' + kind + ' glyph has no colour')
1299+
if (!ic.label) return fail('the ' + kind + ' glyph has no label')
1300+
// Every command a path may contain, and nothing else. A stray letter
1301+
// silently truncates the shape at that point.
1302+
if (!/^[MmLlHhVvCcSsQqTtAaZz0-9smart.,\-\s]+$/.test(ic.path.replace(/[a-z]/gi, (c) => c))) {
1303+
return fail('the ' + kind + ' glyph has characters a path cannot hold')
1304+
}
1305+
if (!/^[Mm]/.test(ic.path.trim())) return fail('the ' + kind + ' glyph does not start with a move')
1306+
if (!/[Zz]\s*$/.test(ic.path.trim())) return fail('the ' + kind + ' glyph is not closed')
1307+
// Inside the 24-box it claims, or it will not sit where it is placed.
1308+
for (const n of ic.path.match(/-?\d+(\.\d+)?/g) ?? []) {
1309+
const v = Number(n)
1310+
if (v < -1 || v > ICON_BOX + 1) {
1311+
return fail('the ' + kind + ' glyph leaves its box: ' + v)
1312+
}
1313+
}
1314+
}
1315+
// An unknown kind still draws something rather than nothing.
1316+
if (iconFor('not-a-structure') !== STRUCTURE_ICONS.other) {
1317+
return fail('an unknown structure kind has no fallback glyph')
1318+
}
1319+
if (!iconSvg('village', 12).includes('width="12"')) return fail('the legend svg ignores its size')
12811320
}
12821321

12831322
console.log('MODUPDATE-SMOKE: region decoding OK (1.16 packing split, real NBT chunk renders, foliage seen through, cache round-trips)')
@@ -6180,6 +6219,18 @@ export async function runWebSmoke(): Promise<void> {
61806219
return fail('the ' + label + ' page avatarUrl disagrees for ' + JSON.stringify(n))
61816220
}
61826221
}
6222+
// The map legend's glyph builder, embedded the same way. It reads the
6223+
// icon table through a name the page has to define under exactly that
6224+
// identifier — get it wrong and the legend throws in the browser with
6225+
// nothing wrong in the source (#116, again).
6226+
if (typeof pctx['mapIconSvg'] !== 'function') {
6227+
return fail('the ' + label + ' page has no embedded mapIconSvg')
6228+
}
6229+
for (const k of ['village', 'mine', 'not-a-kind']) {
6230+
if (pctx['mapIconSvg'](k, 14) !== iconSvg(k, 14)) {
6231+
return fail('the ' + label + ' page mapIconSvg disagrees for ' + k)
6232+
}
6233+
}
61836234
// Only the site embeds the item helpers; the panel has no inventory.
61846235
if (typeof pctx['itemIconUrl'] === 'function') {
61856236
for (const idv of ['minecraft:water_bucket', '../evil', '']) {

src/main/web/panelHtml.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { CRATE_CSS, CRATE_JS, CRATE_MODAL_HTML } from '@shared/crateUi'
44
import { STORE_CSS, STORE_JS, STORE_MODAL_HTML, CRATE_ICON_SVG } from '@shared/storeUi'
55
import { avatarUrl } from '@shared/profile'
6+
import { iconSvg, STRUCTURE_ICONS } from '@shared/mapIcons'
67
import { MAP_CSS, MAP_HTML, MAP_JS } from '@shared/mapUi'
78
export function getPanelHtml(): string {
89
return `<!doctype html><html lang="en"><head>
@@ -349,13 +350,25 @@ h2{margin:8px 0;font-weight:800;letter-spacing:-.4px}
349350
<div class="row"><button class="btn sm" onclick="togglePerf()"><span id="mpPerfCaret">▸</span></button>
350351
<b>Map performance</b><div class="spacer"></div><span class="dim" id="mpPerfMsg" style="font-size:12px"></span></div>
351352
<div id="mpPerfBody" style="display:none;margin-top:10px">
353+
<div class="dim" style="font-size:12px;margin-bottom:10px">
354+
These are settings for <b>this server</b>, not for this window — the desktop app,
355+
this panel and the public site all read the same tiles, so a change here applies to
356+
every one of them.
357+
</div>
352358
<label class="row" style="gap:8px;margin-bottom:6px">
353359
<input type="checkbox" id="mpPerfCache" onchange="savePerf()"/> Cache parsed tiles on disk
354360
</label>
355361
<div class="dim" style="font-size:12px;margin-bottom:10px">
356362
A region costs a few hundred milliseconds to parse and about a millisecond to read back.
357363
With this on it is parsed once and re-parsed only when the server rewrites it.
358364
</div>
365+
<label class="row" style="gap:8px;margin-bottom:6px">
366+
<input type="checkbox" id="mpPerfPan" onchange="savePerf()"/> Keep loading as the view moves
367+
</label>
368+
<div class="dim" style="font-size:12px;margin-bottom:10px">
369+
Off, the map draws what it already holds and asks for nothing more until you press
370+
“Load this view” — so panning across a large world costs nothing.
371+
</div>
359372
<div class="row" style="align-items:flex-end">
360373
<div style="min-width:140px"><div class="dim" style="font-size:12px">Regions in memory</div>
361374
<input id="mpPerfMem" type="number" min="2" max="64" onchange="savePerf()"/></div>
@@ -834,12 +847,15 @@ function loadPerf(){
834847
mapGet('/api/servers/'+current.id+'/map/perf').then(function(p){
835848
if(!p)return;
836849
document.getElementById('mpPerfCache').checked=p.cache!==false;
850+
document.getElementById('mpPerfPan').checked=p.loadOnPan!==false;
837851
document.getElementById('mpPerfMem').value=p.memoryRegions;
838852
document.getElementById('mpPerfGap').value=p.parseGapMs;
839-
document.getElementById('mpPerfLimit').value=p.cacheLimitMB})}
853+
document.getElementById('mpPerfLimit').value=p.cacheLimitMB;
854+
MAP.loadOnPan=p.loadOnPan!==false;mapDraw()})}
840855
function savePerf(){
841856
if(!current)return;
842857
var body={cache:document.getElementById('mpPerfCache').checked,
858+
loadOnPan:document.getElementById('mpPerfPan').checked,
843859
memoryRegions:Number(document.getElementById('mpPerfMem').value),
844860
parseGapMs:Number(document.getElementById('mpPerfGap').value),
845861
cacheLimitMB:Number(document.getElementById('mpPerfLimit').value)};
@@ -851,6 +867,7 @@ function savePerf(){
851867
document.getElementById('mpPerfMem').value=p.memoryRegions;
852868
document.getElementById('mpPerfGap').value=p.parseGapMs;
853869
document.getElementById('mpPerfLimit').value=p.cacheLimitMB;
870+
MAP.loadOnPan=p.loadOnPan!==false;mapDraw();
854871
document.getElementById('mpPerfMsg').textContent='Saved'})}
855872
function clearMapCache(){
856873
if(!current)return;
@@ -873,6 +890,14 @@ function mapPost(u){return api(u,{method:'POST'}).then(function(r){
873890
point it elsewhere, and so the public site can refuse to draw heads at
874891
all (#104). */
875892
var avatarUrl=${avatarUrl.toString()};
893+
/* The structure glyphs, shared with the desktop so a village is the same shape
894+
in all three surfaces (#136). Self-contained, like every embedded helper. */
895+
/* Named exactly as the shared module names it: iconSvg is embedded by
896+
stringifying it and reads the table through this identifier. */
897+
var STRUCTURE_ICONS=${JSON.stringify(STRUCTURE_ICONS)};
898+
var MAP_ICONS=STRUCTURE_ICONS;
899+
function mapIconFor(kind){return STRUCTURE_ICONS[kind]||STRUCTURE_ICONS.other}
900+
var mapIconSvg=${iconSvg.toString()};
876901
function mapAvatarUrl(name){return avatarUrl(name,32)}
877902
var CRATE_ICON_SVG=${JSON.stringify(CRATE_ICON_SVG)};
878903

src/main/web/publicSiteHtml.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { CRATE_CSS, CRATE_JS, CRATE_MODAL_HTML } from '@shared/crateUi'
66
import { STORE_CSS, STORE_JS, STORE_MODAL_HTML, CRATE_ICON_SVG } from '@shared/storeUi'
77
import { MAP_CSS, MAP_HTML, MAP_JS } from '@shared/mapUi'
88
import { avatarUrl, itemIconId, itemIconUrl, itemLabel } from '@shared/profile'
9+
import { iconSvg, STRUCTURE_ICONS } from '@shared/mapIcons'
910

1011
export function getPublicSiteHtml(): string {
1112
return `<!doctype html><html lang="en"><head>
@@ -537,6 +538,10 @@ function staleSession(){
537538
derived offline one, which no skin service has ever seen — every head was a
538539
broken image on exactly the servers this app is most used on (#116). */
539540
var avatarUrl=${avatarUrl.toString()};
541+
var STRUCTURE_ICONS=${JSON.stringify(STRUCTURE_ICONS)};
542+
var MAP_ICONS=STRUCTURE_ICONS;
543+
function mapIconFor(kind){return STRUCTURE_ICONS[kind]||STRUCTURE_ICONS.other}
544+
var mapIconSvg=${iconSvg.toString()};
540545
var itemIconUrl=${itemIconUrl.toString()};
541546
var itemIconId=${itemIconId.toString()};
542547
var itemLabel=${itemLabel.toString()};

src/main/web/server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,10 @@ async function handlePublic(
634634
bounds: mapBounds(players.map((p) => ({ ...p, name: p.name ?? '', y: 0 }))),
635635
round: cfg.round,
636636
heads: cfg.heads,
637+
// The operator's map budget applies to every surface that reads these
638+
// tiles, and the public site is one of them — the settings UI says so, so
639+
// the feed has to carry it (#136).
640+
loadOnPan: normalizeMapPerf(getServer(cfg.serverId)?.map).loadOnPan,
637641
at: now
638642
})
639643
}

src/renderer/src/components/LiveMap.tsx

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { fitView, heatmap, mapBounds, panBy, screenToWorld, worldToScreen, zoomA
88
import type { LivePlayer, MapView, Viewport } from '@shared/livemap'
99
import { avatarUrl } from '@shared/profile'
1010
import type { StructureMark } from '@shared/regionFormat'
11+
import { iconFor, ICON_BOX } from '@shared/mapIcons'
1112
import type { BridgeStatus } from '@shared/bridgeRelease'
1213
import { BridgeNotice } from './BridgeNotice'
1314

@@ -27,14 +28,23 @@ import { BridgeNotice } from './BridgeNotice'
2728

2829
const CELL_CHOICES = [16, 32, 64, 128]
2930

30-
/** Matches the web map's, so a village is the same dot in all three (#131). */
31-
const MARK_STYLE: Record<string, [string, string]> = {
32-
village: ['#e3b341', 'V'],
33-
dungeon: ['#b5504f', 'D'],
34-
temple: ['#c58bd6', 'T'],
35-
fortress: ['#8b6f4e', 'F'],
36-
mine: ['#9aa0a6', 'M'],
37-
other: ['#6fa8dc', '?']
31+
/**
32+
* `Path2D` per structure kind, built once.
33+
*
34+
* Rebuilding one from its path string for every marker on every frame is
35+
* parsing the same string hundreds of times a second.
36+
*/
37+
const ICON_PATHS = new Map<string, Path2D>()
38+
function iconPath(kind: string): Path2D | null {
39+
const hit = ICON_PATHS.get(kind)
40+
if (hit) return hit
41+
try {
42+
const p = new Path2D(iconFor(kind).path)
43+
ICON_PATHS.set(kind, p)
44+
return p
45+
} catch {
46+
return null
47+
}
3848
}
3949

4050
/**
@@ -206,6 +216,9 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element {
206216
)
207217
const [showPerf, setShowPerf] = useState(false)
208218
const [cleared, setCleared] = useState<number | null>(null)
219+
// Bumped by "Load this view" so one fetch happens even with loading-on-pan
220+
// off. A counter rather than a flag: two presses in a row must both count.
221+
const [loadNow, setLoadNow] = useState(0)
209222

210223
const savePerf = (patch: Partial<MapPerfConfig>): void => {
211224
// Normalised before it is stored, not after it is read: a value that only
@@ -267,6 +280,9 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element {
267280
// and the parse budget, shared with the web surfaces.
268281
useEffect(() => {
269282
if (!world || !view || tilesPending.current) return
283+
// Off, the map draws what it holds and asks for nothing more until the
284+
// operator presses to load (#136).
285+
if (!perf.loadOnPan && !loadNow) return
270286
const want = visibleChunks()
271287
.filter((c: { cx: number; cz: number }) => !tiles.current.has(c.cx + ',' + c.cz))
272288
.slice(0, 64)
@@ -297,7 +313,7 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element {
297313
.catch(() => {
298314
tilesPending.current = false
299315
})
300-
}, [world, view, vp, dim, serverId, visibleChunks, tick2, marks])
316+
}, [world, view, vp, dim, serverId, visibleChunks, tick2, marks, perf.loadOnPan, loadNow])
301317

302318
// A different server or dimension is a different world; nothing carries over.
303319
// The nether shares the overworld's coordinates, so keeping tiles across a
@@ -406,24 +422,32 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element {
406422

407423
// After the grid and the heatmap, before the players.
408424
if (marks) {
409-
g.textAlign = 'center'
410-
g.textBaseline = 'middle'
411-
g.font = `bold ${10 * dpr}px Inter, system-ui, sans-serif`
412425
for (const c of drawableChunks()) {
413426
for (const mk of markStore.current.get(c.cx + ',' + c.cz) ?? []) {
414427
if (markKind && mk.kind !== markKind) continue
415-
const st = MARK_STYLE[mk.kind] ?? MARK_STYLE.other
428+
const ic = iconFor(mk.kind)
416429
const x = px(mk.x)
417430
const y = pz(mk.z)
431+
const r = 9 * dpr
432+
// A disc behind the glyph, so a silhouette does not disappear against
433+
// terrain its own colour.
418434
g.beginPath()
419-
g.arc(x, y, 7 * dpr, 0, Math.PI * 2)
420-
g.fillStyle = st[0]
435+
g.arc(x, y, r, 0, Math.PI * 2)
436+
g.fillStyle = 'rgba(16,16,20,.72)'
421437
g.fill()
422438
g.lineWidth = 1.5 * dpr
423-
g.strokeStyle = 'rgba(0,0,0,.6)'
439+
g.strokeStyle = ic.colour
424440
g.stroke()
425-
g.fillStyle = '#101014'
426-
g.fillText(st[1], x, y + 0.5 * dpr)
441+
const path = iconPath(mk.kind)
442+
if (path) {
443+
const s = (r * 1.5) / ICON_BOX
444+
g.save()
445+
g.translate(x - r * 0.75, y - r * 0.75)
446+
g.scale(s, s)
447+
g.fillStyle = ic.colour
448+
g.fill(path)
449+
g.restore()
450+
}
427451
}
428452
}
429453
}
@@ -553,6 +577,11 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element {
553577
))}
554578
</select>
555579
)}
580+
{!perf.loadOnPan && (
581+
<button className="btn sm" onClick={() => setLoadNow((n) => n + 1)}>
582+
{t('map.loadHere')}
583+
</button>
584+
)}
556585
<button className="btn sm" onClick={() => setView(null)}>
557586
{t('map.resetView')}
558587
</button>
@@ -565,6 +594,11 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element {
565594
an operator meets the cost, so it is where the dials belong (#133). */}
566595
{showPerf && (
567596
<div className="panel" style={{ marginBottom: 10 }}>
597+
{/* These persist on the server, so they are not a preference of this
598+
window — say so, or they read as one (#136). */}
599+
<p className="hint" style={{ marginTop: 0 }}>
600+
{t('map.perfScope')}
601+
</p>
568602
<label className="switch" style={{ marginBottom: 8 }}>
569603
<input
570604
type="checkbox"
@@ -576,6 +610,17 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element {
576610
<p className="hint" style={{ marginTop: 0 }}>
577611
{t('map.perfCacheHint')}
578612
</p>
613+
<label className="switch" style={{ marginBottom: 8 }}>
614+
<input
615+
type="checkbox"
616+
checked={perf.loadOnPan}
617+
onChange={(e) => savePerf({ loadOnPan: e.target.checked })}
618+
/>
619+
{t('map.perfPan')}
620+
</label>
621+
<p className="hint" style={{ marginTop: 0 }}>
622+
{t('map.perfPanHint')}
623+
</p>
579624
<div className="row wrap" style={{ gap: 12, alignItems: 'flex-end' }}>
580625
<div style={{ minWidth: 150 }}>
581626
<div className="dim" style={{ fontSize: 12 }}>

src/renderer/src/locales/en.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,12 @@ export default {
127127
heads: 'Heads',
128128
goTo: 'Centre the map here',
129129
performance: 'Performance',
130+
perfScope:
131+
'These are settings for THIS SERVER, not for this window — the app, the web panel and the public site all read the same tiles, so a change here applies to every one of them.',
132+
perfPan: 'Keep loading as the view moves',
133+
perfPanHint:
134+
'Off, the map draws what it already holds and asks for nothing more until you press “Load this view” — so panning across a large world costs nothing.',
135+
loadHere: 'Load this view',
130136
perfCache: 'Cache parsed tiles on disk',
131137
perfCacheHint:
132138
'A region costs a few hundred milliseconds to parse and about a millisecond to read back. With this on, a region is parsed once and re-parsed only when the server rewrites it.',

src/renderer/src/locales/tr.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,12 @@ const tr: typeof en = {
129129
heads: 'Kafalar',
130130
goTo: 'Haritayı buraya ortala',
131131
performance: 'Performans',
132+
perfScope:
133+
'Bunlar BU SUNUCUNUN ayarları, bu pencerenin değil — uygulama, web paneli ve herkese açık site aynı parçaları okur, yani burada yapılan değişiklik hepsine uygulanır.',
134+
perfPan: 'Görünüm hareket ettikçe yüklemeye devam et',
135+
perfPanHint:
136+
'Kapalıyken harita elindekini çizer ve “Bu görünümü yükle” demeden başka bir şey istemez — böylece büyük bir dünyada gezinmek hiçbir maliyet çıkarmaz.',
137+
loadHere: 'Bu görünümü yükle',
132138
perfCache: 'Çözümlenmiş parçaları diskte önbelleğe al',
133139
perfCacheHint:
134140
'Bir bölgeyi çözümlemek birkaç yüz milisaniye, geri okumak yaklaşık bir milisaniye sürer. Bu açıkken bir bölge bir kez çözümlenir ve yalnızca sunucu onu yeniden yazdığında tekrar çözümlenir.',

0 commit comments

Comments
 (0)