Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions src/main/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ import {
sha256Of
} from '@shared/bridgeRelease'
import type { GhRelease } from '@shared/bridgeRelease'
import { iconFor, iconSvg, ICON_BOX, STRUCTURE_ICONS } from '@shared/mapIcons'
import { STRUCTURE_KINDS } from '@shared/regionFormat'
import {
bitsPerIndex,
blockColour,
Expand Down Expand Up @@ -1278,6 +1280,43 @@ export async function runModUpdateSmoke(): Promise<void> {
if (normalizeMapPerf(null).memoryRegions !== MAP_PERF_DEFAULTS.memoryRegions) {
return fail('an absent config did not fall back to the defaults')
}
if (normalizeMapPerf({}).loadOnPan !== true) return fail('loading as the view moves must default on')
if (normalizeMapPerf({ loadOnPan: false }).loadOnPan !== false) {
return fail('loading as the view moves cannot be turned off')
}
}

// ---- structure glyphs (#136) ----
//
// Path data is drawn by `new Path2D(...)`, which throws nothing on
// nonsense — a malformed path renders as an empty shape, so a typo
// produces a marker with a hole in it and no error anywhere.
{
for (const kind of STRUCTURE_KINDS) {
const ic = iconFor(kind)
if (!ic.path) return fail('no glyph for ' + kind)
if (!ic.colour.startsWith('#')) return fail('the ' + kind + ' glyph has no colour')
if (!ic.label) return fail('the ' + kind + ' glyph has no label')
// Every command a path may contain, and nothing else. A stray letter
// silently truncates the shape at that point.
if (!/^[MmLlHhVvCcSsQqTtAaZz0-9smart.,\-\s]+$/.test(ic.path.replace(/[a-z]/gi, (c) => c))) {
return fail('the ' + kind + ' glyph has characters a path cannot hold')
}
if (!/^[Mm]/.test(ic.path.trim())) return fail('the ' + kind + ' glyph does not start with a move')
if (!/[Zz]\s*$/.test(ic.path.trim())) return fail('the ' + kind + ' glyph is not closed')
// Inside the 24-box it claims, or it will not sit where it is placed.
for (const n of ic.path.match(/-?\d+(\.\d+)?/g) ?? []) {
const v = Number(n)
if (v < -1 || v > ICON_BOX + 1) {
return fail('the ' + kind + ' glyph leaves its box: ' + v)
}
}
}
// An unknown kind still draws something rather than nothing.
if (iconFor('not-a-structure') !== STRUCTURE_ICONS.other) {
return fail('an unknown structure kind has no fallback glyph')
}
if (!iconSvg('village', 12).includes('width="12"')) return fail('the legend svg ignores its size')
}

console.log('MODUPDATE-SMOKE: region decoding OK (1.16 packing split, real NBT chunk renders, foliage seen through, cache round-trips)')
Expand Down Expand Up @@ -6180,6 +6219,18 @@ export async function runWebSmoke(): Promise<void> {
return fail('the ' + label + ' page avatarUrl disagrees for ' + JSON.stringify(n))
}
}
// The map legend's glyph builder, embedded the same way. It reads the
// icon table through a name the page has to define under exactly that
// identifier — get it wrong and the legend throws in the browser with
// nothing wrong in the source (#116, again).
if (typeof pctx['mapIconSvg'] !== 'function') {
return fail('the ' + label + ' page has no embedded mapIconSvg')
}
for (const k of ['village', 'mine', 'not-a-kind']) {
if (pctx['mapIconSvg'](k, 14) !== iconSvg(k, 14)) {
return fail('the ' + label + ' page mapIconSvg disagrees for ' + k)
}
}
// Only the site embeds the item helpers; the panel has no inventory.
if (typeof pctx['itemIconUrl'] === 'function') {
for (const idv of ['minecraft:water_bucket', '../evil', '']) {
Expand Down
27 changes: 26 additions & 1 deletion src/main/web/panelHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { CRATE_CSS, CRATE_JS, CRATE_MODAL_HTML } from '@shared/crateUi'
import { STORE_CSS, STORE_JS, STORE_MODAL_HTML, CRATE_ICON_SVG } from '@shared/storeUi'
import { avatarUrl } from '@shared/profile'
import { iconSvg, STRUCTURE_ICONS } from '@shared/mapIcons'
import { MAP_CSS, MAP_HTML, MAP_JS } from '@shared/mapUi'
export function getPanelHtml(): string {
return `<!doctype html><html lang="en"><head>
Expand Down Expand Up @@ -349,13 +350,25 @@ h2{margin:8px 0;font-weight:800;letter-spacing:-.4px}
<div class="row"><button class="btn sm" onclick="togglePerf()"><span id="mpPerfCaret">▸</span></button>
<b>Map performance</b><div class="spacer"></div><span class="dim" id="mpPerfMsg" style="font-size:12px"></span></div>
<div id="mpPerfBody" style="display:none;margin-top:10px">
<div class="dim" style="font-size:12px;margin-bottom:10px">
These are settings for <b>this server</b>, not for this window — the desktop app,
this panel and the public site all read the same tiles, so a change here applies to
every one of them.
</div>
<label class="row" style="gap:8px;margin-bottom:6px">
<input type="checkbox" id="mpPerfCache" onchange="savePerf()"/> Cache parsed tiles on disk
</label>
<div class="dim" style="font-size:12px;margin-bottom:10px">
A region costs a few hundred milliseconds to parse and about a millisecond to read back.
With this on it is parsed once and re-parsed only when the server rewrites it.
</div>
<label class="row" style="gap:8px;margin-bottom:6px">
<input type="checkbox" id="mpPerfPan" onchange="savePerf()"/> Keep loading as the view moves
</label>
<div class="dim" style="font-size:12px;margin-bottom:10px">
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.
</div>
<div class="row" style="align-items:flex-end">
<div style="min-width:140px"><div class="dim" style="font-size:12px">Regions in memory</div>
<input id="mpPerfMem" type="number" min="2" max="64" onchange="savePerf()"/></div>
Expand Down Expand Up @@ -834,12 +847,15 @@ function loadPerf(){
mapGet('/api/servers/'+current.id+'/map/perf').then(function(p){
if(!p)return;
document.getElementById('mpPerfCache').checked=p.cache!==false;
document.getElementById('mpPerfPan').checked=p.loadOnPan!==false;
document.getElementById('mpPerfMem').value=p.memoryRegions;
document.getElementById('mpPerfGap').value=p.parseGapMs;
document.getElementById('mpPerfLimit').value=p.cacheLimitMB})}
document.getElementById('mpPerfLimit').value=p.cacheLimitMB;
MAP.loadOnPan=p.loadOnPan!==false;mapDraw()})}
function savePerf(){
if(!current)return;
var body={cache:document.getElementById('mpPerfCache').checked,
loadOnPan:document.getElementById('mpPerfPan').checked,
memoryRegions:Number(document.getElementById('mpPerfMem').value),
parseGapMs:Number(document.getElementById('mpPerfGap').value),
cacheLimitMB:Number(document.getElementById('mpPerfLimit').value)};
Expand All @@ -851,6 +867,7 @@ function savePerf(){
document.getElementById('mpPerfMem').value=p.memoryRegions;
document.getElementById('mpPerfGap').value=p.parseGapMs;
document.getElementById('mpPerfLimit').value=p.cacheLimitMB;
MAP.loadOnPan=p.loadOnPan!==false;mapDraw();
document.getElementById('mpPerfMsg').textContent='Saved'})}
function clearMapCache(){
if(!current)return;
Expand All @@ -873,6 +890,14 @@ function mapPost(u){return api(u,{method:'POST'}).then(function(r){
point it elsewhere, and so the public site can refuse to draw heads at
all (#104). */
var avatarUrl=${avatarUrl.toString()};
/* The structure glyphs, shared with the desktop so a village is the same shape
in all three surfaces (#136). Self-contained, like every embedded helper. */
/* Named exactly as the shared module names it: iconSvg is embedded by
stringifying it and reads the table through this identifier. */
var STRUCTURE_ICONS=${JSON.stringify(STRUCTURE_ICONS)};
var MAP_ICONS=STRUCTURE_ICONS;
function mapIconFor(kind){return STRUCTURE_ICONS[kind]||STRUCTURE_ICONS.other}
var mapIconSvg=${iconSvg.toString()};
function mapAvatarUrl(name){return avatarUrl(name,32)}
var CRATE_ICON_SVG=${JSON.stringify(CRATE_ICON_SVG)};

Expand Down
5 changes: 5 additions & 0 deletions src/main/web/publicSiteHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { CRATE_CSS, CRATE_JS, CRATE_MODAL_HTML } from '@shared/crateUi'
import { STORE_CSS, STORE_JS, STORE_MODAL_HTML, CRATE_ICON_SVG } from '@shared/storeUi'
import { MAP_CSS, MAP_HTML, MAP_JS } from '@shared/mapUi'
import { avatarUrl, itemIconId, itemIconUrl, itemLabel } from '@shared/profile'
import { iconSvg, STRUCTURE_ICONS } from '@shared/mapIcons'

export function getPublicSiteHtml(): string {
return `<!doctype html><html lang="en"><head>
Expand Down Expand Up @@ -537,6 +538,10 @@ function staleSession(){
derived offline one, which no skin service has ever seen — every head was a
broken image on exactly the servers this app is most used on (#116). */
var avatarUrl=${avatarUrl.toString()};
var STRUCTURE_ICONS=${JSON.stringify(STRUCTURE_ICONS)};
var MAP_ICONS=STRUCTURE_ICONS;
function mapIconFor(kind){return STRUCTURE_ICONS[kind]||STRUCTURE_ICONS.other}
var mapIconSvg=${iconSvg.toString()};
var itemIconUrl=${itemIconUrl.toString()};
var itemIconId=${itemIconId.toString()};
var itemLabel=${itemLabel.toString()};
Expand Down
4 changes: 4 additions & 0 deletions src/main/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,10 @@ async function handlePublic(
bounds: mapBounds(players.map((p) => ({ ...p, name: p.name ?? '', y: 0 }))),
round: cfg.round,
heads: cfg.heads,
// The operator's map budget applies to every surface that reads these
// tiles, and the public site is one of them — the settings UI says so, so
// the feed has to carry it (#136).
loadOnPan: normalizeMapPerf(getServer(cfg.serverId)?.map).loadOnPan,
at: now
})
}
Expand Down
81 changes: 63 additions & 18 deletions src/renderer/src/components/LiveMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { fitView, heatmap, mapBounds, panBy, screenToWorld, worldToScreen, zoomA
import type { LivePlayer, MapView, Viewport } from '@shared/livemap'
import { avatarUrl } from '@shared/profile'
import type { StructureMark } from '@shared/regionFormat'
import { iconFor, ICON_BOX } from '@shared/mapIcons'
import type { BridgeStatus } from '@shared/bridgeRelease'
import { BridgeNotice } from './BridgeNotice'

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

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

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

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

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

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

// After the grid and the heatmap, before the players.
if (marks) {
g.textAlign = 'center'
g.textBaseline = 'middle'
g.font = `bold ${10 * dpr}px Inter, system-ui, sans-serif`
for (const c of drawableChunks()) {
for (const mk of markStore.current.get(c.cx + ',' + c.cz) ?? []) {
if (markKind && mk.kind !== markKind) continue
const st = MARK_STYLE[mk.kind] ?? MARK_STYLE.other
const ic = iconFor(mk.kind)
const x = px(mk.x)
const y = pz(mk.z)
const r = 9 * dpr
// A disc behind the glyph, so a silhouette does not disappear against
// terrain its own colour.
g.beginPath()
g.arc(x, y, 7 * dpr, 0, Math.PI * 2)
g.fillStyle = st[0]
g.arc(x, y, r, 0, Math.PI * 2)
g.fillStyle = 'rgba(16,16,20,.72)'
g.fill()
g.lineWidth = 1.5 * dpr
g.strokeStyle = 'rgba(0,0,0,.6)'
g.strokeStyle = ic.colour
g.stroke()
g.fillStyle = '#101014'
g.fillText(st[1], x, y + 0.5 * dpr)
const path = iconPath(mk.kind)
if (path) {
const s = (r * 1.5) / ICON_BOX
g.save()
g.translate(x - r * 0.75, y - r * 0.75)
g.scale(s, s)
g.fillStyle = ic.colour
g.fill(path)
g.restore()
}
}
}
}
Expand Down Expand Up @@ -553,6 +577,11 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element {
))}
</select>
)}
{!perf.loadOnPan && (
<button className="btn sm" onClick={() => setLoadNow((n) => n + 1)}>
{t('map.loadHere')}
</button>
)}
<button className="btn sm" onClick={() => setView(null)}>
{t('map.resetView')}
</button>
Expand All @@ -565,6 +594,11 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element {
an operator meets the cost, so it is where the dials belong (#133). */}
{showPerf && (
<div className="panel" style={{ marginBottom: 10 }}>
{/* These persist on the server, so they are not a preference of this
window — say so, or they read as one (#136). */}
<p className="hint" style={{ marginTop: 0 }}>
{t('map.perfScope')}
</p>
<label className="switch" style={{ marginBottom: 8 }}>
<input
type="checkbox"
Expand All @@ -576,6 +610,17 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element {
<p className="hint" style={{ marginTop: 0 }}>
{t('map.perfCacheHint')}
</p>
<label className="switch" style={{ marginBottom: 8 }}>
<input
type="checkbox"
checked={perf.loadOnPan}
onChange={(e) => savePerf({ loadOnPan: e.target.checked })}
/>
{t('map.perfPan')}
</label>
<p className="hint" style={{ marginTop: 0 }}>
{t('map.perfPanHint')}
</p>
<div className="row wrap" style={{ gap: 12, alignItems: 'flex-end' }}>
<div style={{ minWidth: 150 }}>
<div className="dim" style={{ fontSize: 12 }}>
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@ export default {
heads: 'Heads',
goTo: 'Centre the map here',
performance: 'Performance',
perfScope:
'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.',
perfPan: 'Keep loading as the view moves',
perfPanHint:
'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.',
loadHere: 'Load this view',
perfCache: 'Cache parsed tiles on disk',
perfCacheHint:
'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.',
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/src/locales/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ const tr: typeof en = {
heads: 'Kafalar',
goTo: 'Haritayı buraya ortala',
performance: 'Performans',
perfScope:
'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.',
perfPan: 'Görünüm hareket ettikçe yüklemeye devam et',
perfPanHint:
'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.',
loadHere: 'Bu görünümü yükle',
perfCache: 'Çözümlenmiş parçaları diskte önbelleğe al',
perfCacheHint:
'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.',
Expand Down
Loading
Loading