From cdd4fd8144f53f4e2027c2f1158bd1f39e30bb46 Mon Sep 17 00:00:00 2001 From: CaYatur Date: Wed, 29 Jul 2026 14:57:46 +0300 Subject: [PATCH 1/2] Draw the structures, and let an operator stop the map reading (#136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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. --- src/main/smoke.ts | 39 ++++++++++++ src/main/web/panelHtml.ts | 23 ++++++- src/main/web/publicSiteHtml.ts | 3 + src/renderer/src/components/LiveMap.tsx | 81 +++++++++++++++++++------ src/renderer/src/locales/en.ts | 6 ++ src/renderer/src/locales/tr.ts | 6 ++ src/shared/mapIcons.ts | 81 +++++++++++++++++++++++++ src/shared/mapUi.ts | 42 ++++++++++--- src/shared/tileCache.ts | 15 ++++- 9 files changed, 265 insertions(+), 31 deletions(-) create mode 100644 src/shared/mapIcons.ts diff --git a/src/main/smoke.ts b/src/main/smoke.ts index d5a9212..daf2548 100644 --- a/src/main/smoke.ts +++ b/src/main/smoke.ts @@ -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, @@ -1278,6 +1280,43 @@ export async function runModUpdateSmoke(): Promise { 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)') diff --git a/src/main/web/panelHtml.ts b/src/main/web/panelHtml.ts index e1fd185..59161ab 100644 --- a/src/main/web/panelHtml.ts +++ b/src/main/web/panelHtml.ts @@ -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 { STRUCTURE_ICONS } from '@shared/mapIcons' import { MAP_CSS, MAP_HTML, MAP_JS } from '@shared/mapUi' export function getPanelHtml(): string { return ` @@ -349,6 +350,11 @@ h2{margin:8px 0;font-weight:800;letter-spacing:-.4px}
Map performance
+ +
+ 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. +
Regions in memory
@@ -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)}; @@ -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; @@ -873,6 +890,10 @@ 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. */ +var MAP_ICONS=${JSON.stringify(STRUCTURE_ICONS)}; +function mapIconFor(kind){return MAP_ICONS[kind]||MAP_ICONS.other} function mapAvatarUrl(name){return avatarUrl(name,32)} var CRATE_ICON_SVG=${JSON.stringify(CRATE_ICON_SVG)}; diff --git a/src/main/web/publicSiteHtml.ts b/src/main/web/publicSiteHtml.ts index e3c1e02..ebf0d9d 100644 --- a/src/main/web/publicSiteHtml.ts +++ b/src/main/web/publicSiteHtml.ts @@ -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 { STRUCTURE_ICONS } from '@shared/mapIcons' export function getPublicSiteHtml(): string { return ` @@ -537,6 +538,8 @@ 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 MAP_ICONS=${JSON.stringify(STRUCTURE_ICONS)}; +function mapIconFor(kind){return MAP_ICONS[kind]||MAP_ICONS.other} var itemIconUrl=${itemIconUrl.toString()}; var itemIconId=${itemIconId.toString()}; var itemLabel=${itemLabel.toString()}; diff --git a/src/renderer/src/components/LiveMap.tsx b/src/renderer/src/components/LiveMap.tsx index af41aea..9a7b045 100644 --- a/src/renderer/src/components/LiveMap.tsx +++ b/src/renderer/src/components/LiveMap.tsx @@ -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' @@ -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 = { - 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() +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 + } } /** @@ -206,6 +216,9 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element { ) const [showPerf, setShowPerf] = useState(false) const [cleared, setCleared] = useState(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): void => { // 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 { // 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) @@ -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 @@ -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() + } } } } @@ -553,6 +577,11 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element { ))} )} + {!perf.loadOnPan && ( + + )} @@ -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 && (
+ {/* These persist on the server, so they are not a preference of this + window — say so, or they read as one (#136). */} +

+ {t('map.perfScope')} +