688 lines
29 KiB
TypeScript
688 lines
29 KiB
TypeScript
import type { LivePlayer, MapBounds, MapClaim, MapDimension, MapInfo, MapLayers, MapOverlay, MapPin, MapSelection } from './types';
|
|
import { dimSlug } from './types';
|
|
|
|
/** A framework-free map renderer: pans and zooms the tile pyramid on a canvas and draws claims, pins and players on top. */
|
|
|
|
export interface Theme {
|
|
background: string;
|
|
grid: string;
|
|
label: string;
|
|
labelBg: string;
|
|
accent: string;
|
|
self: string;
|
|
}
|
|
|
|
const DARK: Theme = { background: '#0d1220', grid: 'rgba(255,255,255,0.045)', label: '#f4f6ff', labelBg: 'rgba(10,14,26,0.78)', accent: '#8b6cff', self: '#34d399' };
|
|
|
|
const PIN_COLORS: Record<string, string> = {
|
|
spawn: '#fbbf24', warp: '#38bdf8', home: '#a78bfa', guild_home: '#fb7185', market: '#34d399', shop: '#f59e0b'
|
|
};
|
|
|
|
export interface MapViewOptions {
|
|
tileUrl: (slug: string, zoom: number, x: number, y: number) => string;
|
|
avatarUrl?: (uuid: string) => string;
|
|
theme?: Partial<Theme>;
|
|
selfUuid?: string | null;
|
|
onselect?: (selection: MapSelection | null) => void;
|
|
onhover?: (hover: { cursor: { x: number; z: number } | null; label: string | null }) => void;
|
|
onview?: (view: { x: number; z: number; scale: number }) => void;
|
|
}
|
|
|
|
interface Tile { img: HTMLImageElement; state: 'loading' | 'ok' | 'missing'; at: number; used: number }
|
|
|
|
const MIN_SCALE = 1 / 128;
|
|
const MAX_SCALE = 20;
|
|
const TILE = 256;
|
|
const MAX_LOADS = 10;
|
|
|
|
export class MapView {
|
|
private ctx: CanvasRenderingContext2D;
|
|
private theme: Theme;
|
|
private info: MapInfo | null = null;
|
|
private dimension: MapDimension | null = null;
|
|
private overlay: MapOverlay = { players: [], claims: [], pins: [] };
|
|
private layers: MapLayers = { claims: true, pins: true, players: true };
|
|
private tiles = new Map<string, Tile>();
|
|
private queue: string[] = [];
|
|
private loading = 0;
|
|
private icons = new Map<string, HTMLImageElement | 'failed'>();
|
|
private paths = new Map<string, Path2D>();
|
|
private width = 300;
|
|
private height = 200;
|
|
private dpr = 1;
|
|
private cx = 0;
|
|
private cz = 0;
|
|
private scale = 1;
|
|
private hoverId: string | null = null;
|
|
private selected: MapSelection | null = null;
|
|
private following: string | null = null;
|
|
private raf = 0;
|
|
private fly: { fromX: number; fromZ: number; fromS: number; toX: number; toZ: number; toS: number; start: number; ms: number } | null = null;
|
|
private pointers = new Map<number, { x: number; y: number }>();
|
|
private drag: { x: number; y: number; moved: number } | null = null;
|
|
private pinchStart: { dist: number; scale: number } | null = null;
|
|
private disposed = false;
|
|
private resizeObserver: ResizeObserver;
|
|
|
|
constructor(private canvas: HTMLCanvasElement, private opts: MapViewOptions) {
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) throw new Error('canvas is not available');
|
|
this.ctx = ctx;
|
|
this.theme = { ...DARK, ...(opts.theme ?? {}) };
|
|
canvas.style.touchAction = 'none';
|
|
canvas.addEventListener('pointerdown', this.down);
|
|
canvas.addEventListener('pointermove', this.move);
|
|
canvas.addEventListener('pointerup', this.up);
|
|
canvas.addEventListener('pointercancel', this.up);
|
|
canvas.addEventListener('pointerleave', this.leave);
|
|
canvas.addEventListener('wheel', this.wheel, { passive: false });
|
|
canvas.addEventListener('dblclick', this.dbl);
|
|
this.resizeObserver = new ResizeObserver(() => this.resize());
|
|
this.resizeObserver.observe(canvas);
|
|
this.resize();
|
|
}
|
|
|
|
// ---- public API --------------------------------------------------------------------------------------
|
|
|
|
setInfo(info: MapInfo) {
|
|
const tokenChanged = this.info?.token !== info.token || this.info?.tile_base !== info.tile_base;
|
|
this.info = info;
|
|
if (tokenChanged) {
|
|
// New tile key: tiles that failed earlier may work now; loaded ones stay.
|
|
for (const [k, t] of this.tiles) if (t.state !== 'ok') this.tiles.delete(k);
|
|
}
|
|
const keep = this.dimension ? info.dimensions.find((d) => d.slug === this.dimension!.slug) : undefined;
|
|
this.dimension = keep ?? info.dimensions[0] ?? null;
|
|
if (!keep && this.dimension) this.frameDimension();
|
|
this.invalidate();
|
|
}
|
|
|
|
setOverlay(overlay: MapOverlay) {
|
|
this.overlay = overlay;
|
|
this.paths.clear();
|
|
if (this.following) {
|
|
const p = overlay.players.find((q) => q.uuid === this.following);
|
|
if (p) {
|
|
if (dimSlug(p.dimension) !== this.dimension?.slug) this.setDimension(p.dimension);
|
|
this.fly = { fromX: this.cx, fromZ: this.cz, fromS: this.scale, toX: p.x, toZ: p.z, toS: this.scale, start: performance.now(), ms: 700 };
|
|
}
|
|
}
|
|
if (this.selected?.type === 'player') {
|
|
const fresh = overlay.players.find((p) => p.uuid === (this.selected as { player: LivePlayer }).player.uuid);
|
|
if (fresh) this.selected = { type: 'player', player: fresh };
|
|
}
|
|
this.invalidate();
|
|
}
|
|
|
|
setLayers(layers: MapLayers) { this.layers = { ...layers }; this.invalidate(); }
|
|
|
|
setSelf(uuid: string | null | undefined) { this.opts.selfUuid = uuid ?? null; this.invalidate(); }
|
|
|
|
/** Switch dimension by id (`minecraft:the_nether`) or slug. */
|
|
setDimension(idOrSlug: string) {
|
|
const slug = dimSlug(idOrSlug);
|
|
const next = this.info?.dimensions.find((d) => d.slug === slug);
|
|
if (!next || next.slug === this.dimension?.slug) return;
|
|
this.dimension = next;
|
|
this.select(null);
|
|
this.frameDimension();
|
|
this.invalidate();
|
|
}
|
|
|
|
get currentDimension(): MapDimension | null { return this.dimension; }
|
|
|
|
follow(uuid: string | null) { this.following = uuid; if (uuid) this.invalidate(); }
|
|
|
|
flyTo(x: number, z: number, scale?: number) {
|
|
this.following = null;
|
|
this.fly = { fromX: this.cx, fromZ: this.cz, fromS: this.scale, toX: x, toZ: z, toS: clamp(scale ?? this.scale, MIN_SCALE, MAX_SCALE), start: performance.now(), ms: 520 };
|
|
this.invalidate();
|
|
}
|
|
|
|
zoomBy(factor: number) {
|
|
this.fly = null;
|
|
this.zoomAround(this.width / 2, this.height / 2, factor, true);
|
|
}
|
|
|
|
select(selection: MapSelection | null) {
|
|
this.selected = selection;
|
|
this.opts.onselect?.(selection);
|
|
this.invalidate();
|
|
}
|
|
|
|
frameDimension() {
|
|
const b = this.dimension?.bounds;
|
|
if (!b) return;
|
|
this.fly = null;
|
|
const [minX, maxX, minZ, maxZ] = worldBounds(b);
|
|
this.cx = (minX + maxX) / 2;
|
|
this.cz = (minZ + maxZ) / 2;
|
|
const fit = Math.min(this.width / Math.max(1, maxX - minX), this.height / Math.max(1, maxZ - minZ)) * 0.9;
|
|
this.scale = clamp(fit, 0.05, 2);
|
|
this.emitView();
|
|
}
|
|
|
|
pinsOfKind(kind: string): MapPin[] { return this.overlay.pins.filter((p) => p.kind === kind && dimSlug(p.dimension) === this.dimension?.slug); }
|
|
|
|
getView() { return { x: this.cx, z: this.cz, scale: this.scale }; }
|
|
|
|
destroy() {
|
|
this.disposed = true;
|
|
cancelAnimationFrame(this.raf);
|
|
this.resizeObserver.disconnect();
|
|
const c = this.canvas;
|
|
c.removeEventListener('pointerdown', this.down);
|
|
c.removeEventListener('pointermove', this.move);
|
|
c.removeEventListener('pointerup', this.up);
|
|
c.removeEventListener('pointercancel', this.up);
|
|
c.removeEventListener('pointerleave', this.leave);
|
|
c.removeEventListener('wheel', this.wheel);
|
|
c.removeEventListener('dblclick', this.dbl);
|
|
this.tiles.clear();
|
|
}
|
|
|
|
// ---- geometry ---------------------------------------------------------------------------------------
|
|
|
|
private resize() {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
this.dpr = Math.min(window.devicePixelRatio || 1, 2.5);
|
|
this.width = Math.max(50, rect.width);
|
|
this.height = Math.max(50, rect.height);
|
|
this.canvas.width = Math.round(this.width * this.dpr);
|
|
this.canvas.height = Math.round(this.height * this.dpr);
|
|
this.invalidate();
|
|
}
|
|
|
|
private toScreen(x: number, z: number) { return { x: (x - this.cx) * this.scale + this.width / 2, y: (z - this.cz) * this.scale + this.height / 2 }; }
|
|
private toWorld(sx: number, sy: number) { return { x: (sx - this.width / 2) / this.scale + this.cx, z: (sy - this.height / 2) / this.scale + this.cz }; }
|
|
|
|
private zoomAround(sx: number, sy: number, factor: number, animate = false) {
|
|
const before = this.toWorld(sx, sy);
|
|
const target = clamp(this.scale * factor, MIN_SCALE, MAX_SCALE);
|
|
if (animate) {
|
|
// Keep the point under (sx, sy) fixed while easing the scale.
|
|
const toX = before.x - (sx - this.width / 2) / target;
|
|
const toZ = before.z - (sy - this.height / 2) / target;
|
|
this.fly = { fromX: this.cx, fromZ: this.cz, fromS: this.scale, toX, toZ, toS: target, start: performance.now(), ms: 220 };
|
|
} else {
|
|
this.scale = target;
|
|
this.cx = before.x - (sx - this.width / 2) / this.scale;
|
|
this.cz = before.z - (sy - this.height / 2) / this.scale;
|
|
}
|
|
this.following = null;
|
|
this.invalidate();
|
|
}
|
|
|
|
// ---- input --------------------------------------------------------------------------------------------
|
|
|
|
private localPoint(e: PointerEvent | WheelEvent | MouseEvent) {
|
|
const r = this.canvas.getBoundingClientRect();
|
|
return { x: e.clientX - r.left, y: e.clientY - r.top };
|
|
}
|
|
|
|
private down = (e: PointerEvent) => {
|
|
this.canvas.setPointerCapture(e.pointerId);
|
|
const p = this.localPoint(e);
|
|
this.pointers.set(e.pointerId, p);
|
|
this.fly = null;
|
|
if (this.pointers.size === 1) this.drag = { x: p.x, y: p.y, moved: 0 };
|
|
if (this.pointers.size === 2) {
|
|
const [a, b] = [...this.pointers.values()];
|
|
this.pinchStart = { dist: Math.hypot(a.x - b.x, a.y - b.y), scale: this.scale };
|
|
this.drag = null;
|
|
}
|
|
};
|
|
|
|
private move = (e: PointerEvent) => {
|
|
const p = this.localPoint(e);
|
|
if (this.pointers.has(e.pointerId)) this.pointers.set(e.pointerId, p);
|
|
if (this.pinchStart && this.pointers.size === 2) {
|
|
const [a, b] = [...this.pointers.values()];
|
|
const dist = Math.hypot(a.x - b.x, a.y - b.y);
|
|
this.zoomAround((a.x + b.x) / 2, (a.y + b.y) / 2, (this.pinchStart.scale * dist / this.pinchStart.dist) / this.scale);
|
|
return;
|
|
}
|
|
if (this.drag && this.pointers.has(e.pointerId)) {
|
|
const dx = p.x - this.drag.x, dy = p.y - this.drag.y;
|
|
this.drag.moved += Math.abs(dx) + Math.abs(dy);
|
|
this.drag.x = p.x; this.drag.y = p.y;
|
|
if (this.drag.moved > 3) {
|
|
this.cx -= dx / this.scale;
|
|
this.cz -= dy / this.scale;
|
|
this.following = null;
|
|
this.canvas.style.cursor = 'grabbing';
|
|
this.invalidate();
|
|
this.emitView();
|
|
}
|
|
return;
|
|
}
|
|
const hit = this.hitTest(p.x, p.y);
|
|
const id = hit ? hitId(hit) : null;
|
|
if (id !== this.hoverId) { this.hoverId = id; this.invalidate(); }
|
|
this.canvas.style.cursor = hit ? 'pointer' : 'grab';
|
|
const w = this.toWorld(p.x, p.y);
|
|
this.opts.onhover?.({ cursor: { x: w.x, z: w.z }, label: hit ? hitLabel(hit) : null });
|
|
};
|
|
|
|
private up = (e: PointerEvent) => {
|
|
const wasClick = this.drag && this.drag.moved <= 3 && this.pointers.size === 1;
|
|
this.pointers.delete(e.pointerId);
|
|
if (this.pointers.size < 2) this.pinchStart = null;
|
|
if (wasClick) {
|
|
const p = this.localPoint(e);
|
|
const hit = this.hitTest(p.x, p.y);
|
|
this.select(hit);
|
|
}
|
|
if (this.pointers.size === 0) { this.drag = null; this.canvas.style.cursor = 'grab'; }
|
|
};
|
|
|
|
private leave = () => {
|
|
if (this.hoverId) { this.hoverId = null; this.invalidate(); }
|
|
this.opts.onhover?.({ cursor: null, label: null });
|
|
};
|
|
|
|
private wheel = (e: WheelEvent) => {
|
|
e.preventDefault();
|
|
const p = this.localPoint(e);
|
|
const delta = e.deltaMode === 1 ? e.deltaY * 18 : e.deltaY;
|
|
this.fly = null;
|
|
this.zoomAround(p.x, p.y, Math.exp(-delta * 0.0016));
|
|
this.emitView();
|
|
};
|
|
|
|
private dbl = (e: MouseEvent) => {
|
|
const p = this.localPoint(e);
|
|
if (this.hitTest(p.x, p.y)) return;
|
|
this.zoomAround(p.x, p.y, 2, true);
|
|
};
|
|
|
|
private emitView() { this.opts.onview?.({ x: this.cx, z: this.cz, scale: this.scale }); }
|
|
|
|
// ---- hit testing --------------------------------------------------------------------------------------
|
|
|
|
private visibleSlug() { return this.dimension?.slug ?? ''; }
|
|
|
|
private hitTest(sx: number, sy: number): MapSelection | null {
|
|
const slug = this.visibleSlug();
|
|
if (this.layers.players) {
|
|
let best: LivePlayer | null = null, bestD = 15 * 15;
|
|
for (const p of this.overlay.players) {
|
|
if (dimSlug(p.dimension) !== slug) continue;
|
|
const s = this.toScreen(p.x, p.z);
|
|
const d = (s.x - sx) ** 2 + (s.y - sy) ** 2;
|
|
if (d < bestD) { best = p; bestD = d; }
|
|
}
|
|
if (best) return { type: 'player', player: best };
|
|
}
|
|
if (this.layers.pins) {
|
|
let best: MapPin | null = null, bestD = 15 * 15;
|
|
for (const p of this.overlay.pins) {
|
|
if (dimSlug(p.dimension) !== slug) continue;
|
|
const s = this.toScreen(p.x, p.z);
|
|
const d = (s.x - sx) ** 2 + (s.y - sy) ** 2;
|
|
if (d < bestD) { best = p; bestD = d; }
|
|
}
|
|
if (best) return { type: 'pin', pin: best };
|
|
}
|
|
if (this.layers.claims) {
|
|
const w = this.toWorld(sx, sy);
|
|
for (const c of this.overlay.claims) {
|
|
if (dimSlug(c.dimension) !== slug) continue;
|
|
if (inside(c.outer, w.x, w.z) && !c.holes.some((h) => inside(h, w.x, w.z))) return { type: 'claim', claim: c };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ---- tiles --------------------------------------------------------------------------------------------
|
|
|
|
private tileKey(slug: string, z: number, x: number, y: number) { return `${slug}/${z}/${x}/${y}`; }
|
|
|
|
private tileAt(slug: string, z: number, x: number, y: number, load: boolean): Tile | null {
|
|
const key = this.tileKey(slug, z, x, y);
|
|
const t = this.tiles.get(key);
|
|
if (t) {
|
|
t.used = performance.now();
|
|
if (t.state === 'missing' && load && performance.now() - t.at > 20000) { this.tiles.delete(key); return this.tileAt(slug, z, x, y, load); }
|
|
return t;
|
|
}
|
|
if (!load || !this.info?.token) return null;
|
|
const img = new Image();
|
|
img.decoding = 'async';
|
|
const tile: Tile = { img, state: 'loading', at: performance.now(), used: performance.now() };
|
|
this.tiles.set(key, tile);
|
|
this.queue.push(key);
|
|
this.pump();
|
|
this.evict();
|
|
return tile;
|
|
}
|
|
|
|
private pump() {
|
|
while (this.loading < MAX_LOADS && this.queue.length) {
|
|
const key = this.queue.pop()!; // newest first: what is on screen now matters most
|
|
const tile = this.tiles.get(key);
|
|
if (!tile || tile.state !== 'loading') continue;
|
|
const [slug, z, x, y] = key.split('/');
|
|
this.loading++;
|
|
tile.img.onload = () => { tile.state = 'ok'; this.loading--; this.pump(); this.invalidate(); };
|
|
tile.img.onerror = () => { tile.state = 'missing'; tile.at = performance.now(); this.loading--; this.pump(); };
|
|
tile.img.src = this.opts.tileUrl(slug, Number(z), Number(x), Number(y));
|
|
}
|
|
}
|
|
|
|
private evict() {
|
|
if (this.tiles.size <= 900) return;
|
|
const old = [...this.tiles.entries()].sort((a, b) => a[1].used - b[1].used).slice(0, 250);
|
|
for (const [k] of old) this.tiles.delete(k);
|
|
}
|
|
|
|
private inBounds(z: number, x: number, y: number): boolean {
|
|
const b = this.dimension?.bounds;
|
|
if (!b) return true;
|
|
const span = 2 ** z;
|
|
return x * span <= b.max_x && (x + 1) * span - 1 >= b.min_x && y * span <= b.max_y && (y + 1) * span - 1 >= b.min_y;
|
|
}
|
|
|
|
// ---- drawing ------------------------------------------------------------------------------------------
|
|
|
|
invalidate() {
|
|
if (this.disposed || this.raf) return;
|
|
this.raf = requestAnimationFrame((t) => { this.raf = 0; this.frame(t); });
|
|
}
|
|
|
|
private frame(now: number) {
|
|
if (this.fly) {
|
|
const f = this.fly;
|
|
const k = Math.min(1, (now - f.start) / f.ms);
|
|
const e = 1 - (1 - k) ** 3;
|
|
this.scale = f.fromS * (f.toS / f.fromS) ** e;
|
|
this.cx = f.fromX + (f.toX - f.fromX) * e;
|
|
this.cz = f.fromZ + (f.toZ - f.fromZ) * e;
|
|
if (k >= 1) this.fly = null;
|
|
this.emitView();
|
|
this.invalidate();
|
|
}
|
|
this.draw();
|
|
}
|
|
|
|
private draw() {
|
|
const ctx = this.ctx;
|
|
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
|
ctx.fillStyle = this.theme.background;
|
|
ctx.fillRect(0, 0, this.width, this.height);
|
|
if (!this.dimension || !this.info) return;
|
|
this.drawTiles(ctx);
|
|
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
|
if (this.layers.claims) this.drawClaims(ctx);
|
|
if (this.layers.pins) this.drawPins(ctx);
|
|
if (this.layers.players) this.drawPlayers(ctx);
|
|
}
|
|
|
|
private drawTiles(ctx: CanvasRenderingContext2D) {
|
|
const slug = this.dimension!.slug;
|
|
const maxZoom = this.info!.max_zoom;
|
|
const zoom = clamp(Math.floor(Math.log2(1 / this.scale)), 0, maxZoom);
|
|
const span = TILE * 2 ** zoom; // world blocks per tile
|
|
const px = span * this.scale; // screen pixels per tile
|
|
const tl = this.toWorld(0, 0), br = this.toWorld(this.width, this.height);
|
|
const x0 = Math.floor(tl.x / span), x1 = Math.floor(br.x / span), y0 = Math.floor(tl.z / span), y1 = Math.floor(br.z / span);
|
|
ctx.imageSmoothingEnabled = this.scale < 1;
|
|
ctx.imageSmoothingQuality = 'high';
|
|
// A faint chunk grid when close enough to matter.
|
|
if (this.scale >= 3) this.drawGrid(ctx);
|
|
for (let ty = y0; ty <= y1; ty++) {
|
|
for (let tx = x0; tx <= x1; tx++) {
|
|
if (!this.inBounds(zoom, tx, ty)) continue;
|
|
const dx = Math.floor((tx * span - this.cx) * this.scale + this.width / 2);
|
|
const dy = Math.floor((ty * span - this.cz) * this.scale + this.height / 2);
|
|
const dsize = Math.ceil(px) + 1; // overlap by a pixel so seams never show
|
|
const tile = this.tileAt(slug, zoom, tx, ty, true);
|
|
if (tile?.state === 'ok') { ctx.drawImage(tile.img, dx, dy, dsize, dsize); continue; }
|
|
// Not here yet: show the blurry parent underneath, then the real one replaces it.
|
|
for (let up = 1; zoom + up <= maxZoom; up++) {
|
|
const parent = this.tileAt(slug, zoom + up, tx >> up, ty >> up, tile?.state === 'loading' && up === 1);
|
|
if (parent?.state === 'ok') {
|
|
const part = TILE / 2 ** up;
|
|
const sx = (tx & (2 ** up - 1)) * part, sy = (ty & (2 ** up - 1)) * part;
|
|
ctx.drawImage(parent.img, sx, sy, part, part, dx, dy, dsize, dsize);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private drawGrid(ctx: CanvasRenderingContext2D) {
|
|
const step = 16 * this.scale;
|
|
const a = this.toWorld(0, 0);
|
|
ctx.strokeStyle = this.theme.grid;
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
for (let x = Math.floor(a.x / 16) * 16; (x - this.cx) * this.scale + this.width / 2 < this.width + step; x += 16) {
|
|
const sx = Math.round((x - this.cx) * this.scale + this.width / 2) + 0.5;
|
|
ctx.moveTo(sx, 0); ctx.lineTo(sx, this.height);
|
|
}
|
|
for (let z = Math.floor(a.z / 16) * 16; (z - this.cz) * this.scale + this.height / 2 < this.height + step; z += 16) {
|
|
const sy = Math.round((z - this.cz) * this.scale + this.height / 2) + 0.5;
|
|
ctx.moveTo(0, sy); ctx.lineTo(this.width, sy);
|
|
}
|
|
ctx.stroke();
|
|
}
|
|
|
|
private claimPath(c: MapClaim): Path2D {
|
|
let p = this.paths.get(c.id);
|
|
if (!p) {
|
|
p = new Path2D();
|
|
for (const ring of [c.outer, ...c.holes]) {
|
|
ring.forEach(([x, z], i) => (i ? p!.lineTo(x, z) : p!.moveTo(x, z)));
|
|
p.closePath();
|
|
}
|
|
this.paths.set(c.id, p);
|
|
}
|
|
return p;
|
|
}
|
|
|
|
private drawClaims(ctx: CanvasRenderingContext2D) {
|
|
const slug = this.visibleSlug();
|
|
const tl = this.toWorld(0, 0), br = this.toWorld(this.width, this.height);
|
|
const picked = this.selected?.type === 'claim' ? this.selected.claim.id : null;
|
|
for (const c of this.overlay.claims) {
|
|
if (dimSlug(c.dimension) !== slug || !c.outer.length) continue;
|
|
const xs = c.outer.map((p) => p[0]), zs = c.outer.map((p) => p[1]);
|
|
if (Math.max(...xs) < tl.x || Math.min(...xs) > br.x || Math.max(...zs) < tl.z || Math.min(...zs) > br.z) continue;
|
|
const hot = c.id === this.hoverId || c.id === picked;
|
|
ctx.save();
|
|
ctx.setTransform(this.dpr * this.scale, 0, 0, this.dpr * this.scale, this.dpr * (this.width / 2 - this.cx * this.scale), this.dpr * (this.height / 2 - this.cz * this.scale));
|
|
const path = this.claimPath(c);
|
|
ctx.fillStyle = withAlpha(c.color, hot ? 0.46 : 0.28);
|
|
ctx.fill(path, 'evenodd');
|
|
ctx.lineJoin = 'round';
|
|
ctx.lineWidth = (hot ? 3 : 2) / this.scale;
|
|
ctx.strokeStyle = withAlpha(c.color, 0.95);
|
|
ctx.stroke(path);
|
|
ctx.restore();
|
|
}
|
|
// Labels on top of all shapes, once they are big enough to read.
|
|
for (const c of this.overlay.claims) {
|
|
if (dimSlug(c.dimension) !== slug || !c.outer.length) continue;
|
|
const xs = c.outer.map((p) => p[0]), zs = c.outer.map((p) => p[1]);
|
|
const w = (Math.max(...xs) - Math.min(...xs)) * this.scale, h = (Math.max(...zs) - Math.min(...zs)) * this.scale;
|
|
if (w < 46 || h < 30) continue;
|
|
const s = this.toScreen(c.label_x, c.label_z);
|
|
if (s.x < -80 || s.y < -40 || s.x > this.width + 80 || s.y > this.height + 40) continue;
|
|
const showName = w > 150 && h > 50;
|
|
this.chip(ctx, s.x, s.y, showName ? `${c.tag ? `[${c.tag}] ` : ''}${c.name}` : c.tag || c.name, c.color, c.icon);
|
|
}
|
|
}
|
|
|
|
private drawPins(ctx: CanvasRenderingContext2D) {
|
|
const slug = this.visibleSlug();
|
|
const picked = this.selected?.type === 'pin' ? this.selected.pin.id : null;
|
|
for (const p of this.overlay.pins) {
|
|
if (dimSlug(p.dimension) !== slug) continue;
|
|
const s = this.toScreen(p.x, p.z);
|
|
if (s.x < -30 || s.y < -30 || s.x > this.width + 30 || s.y > this.height + 30) continue;
|
|
const hot = p.id === this.hoverId || p.id === picked;
|
|
const color = PIN_COLORS[p.kind] ?? '#94a3b8';
|
|
const r = hot ? 14 : 12;
|
|
ctx.save();
|
|
ctx.shadowColor = 'rgba(0,0,0,0.5)'; ctx.shadowBlur = 8; ctx.shadowOffsetY = 2;
|
|
ctx.beginPath(); ctx.arc(s.x, s.y, r, 0, Math.PI * 2);
|
|
ctx.fillStyle = color; ctx.fill();
|
|
ctx.restore();
|
|
ctx.lineWidth = 2; ctx.strokeStyle = '#fff'; ctx.stroke();
|
|
drawGlyph(ctx, p.kind, s.x, s.y, r * 0.58);
|
|
if (hot || this.scale >= 0.9) this.tag(ctx, s.x, s.y + r + 9, p.label);
|
|
}
|
|
}
|
|
|
|
private drawPlayers(ctx: CanvasRenderingContext2D) {
|
|
const slug = this.visibleSlug();
|
|
const picked = this.selected?.type === 'player' ? this.selected.player.uuid : null;
|
|
for (const p of this.overlay.players) {
|
|
if (dimSlug(p.dimension) !== slug) continue;
|
|
const s = this.toScreen(p.x, p.z);
|
|
if (s.x < -30 || s.y < -30 || s.x > this.width + 30 || s.y > this.height + 30) continue;
|
|
const me = !!this.opts.selfUuid && p.uuid.toLowerCase() === this.opts.selfUuid.toLowerCase();
|
|
const hot = p.uuid === this.hoverId || p.uuid === picked;
|
|
const ring = me ? this.theme.self : this.theme.accent;
|
|
const r = hot ? 14 : 12;
|
|
// facing direction: Minecraft yaw 0 = south (+z), 90 = west (-x)
|
|
const a = (p.yaw * Math.PI) / 180;
|
|
const fx = -Math.sin(a), fz = Math.cos(a);
|
|
ctx.save();
|
|
ctx.translate(s.x, s.y);
|
|
ctx.rotate(Math.atan2(fz, fx));
|
|
ctx.beginPath(); ctx.moveTo(r + 9, 0); ctx.lineTo(r + 1, -5); ctx.lineTo(r + 1, 5); ctx.closePath();
|
|
ctx.fillStyle = ring; ctx.fill();
|
|
ctx.restore();
|
|
ctx.save();
|
|
ctx.shadowColor = 'rgba(0,0,0,0.55)'; ctx.shadowBlur = 8; ctx.shadowOffsetY = 2;
|
|
ctx.beginPath(); ctx.arc(s.x, s.y, r + 2, 0, Math.PI * 2);
|
|
ctx.fillStyle = ring; ctx.fill();
|
|
ctx.restore();
|
|
const img = this.opts.avatarUrl ? this.icon(this.opts.avatarUrl(p.uuid)) : null;
|
|
ctx.save();
|
|
ctx.beginPath(); ctx.arc(s.x, s.y, r, 0, Math.PI * 2); ctx.clip();
|
|
if (img) { ctx.imageSmoothingEnabled = false; ctx.drawImage(img, s.x - r, s.y - r, r * 2, r * 2); }
|
|
else { ctx.fillStyle = '#1e293b'; ctx.fillRect(s.x - r, s.y - r, r * 2, r * 2); ctx.fillStyle = '#fff'; ctx.font = '700 12px system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText((p.name[0] ?? '?').toUpperCase(), s.x, s.y + 1); }
|
|
ctx.restore();
|
|
this.tag(ctx, s.x, s.y - r - 12, p.name, me ? this.theme.self : undefined);
|
|
}
|
|
}
|
|
|
|
private icon(url: string): HTMLImageElement | null {
|
|
if (!url) return null;
|
|
const got = this.icons.get(url);
|
|
if (got === 'failed') return null;
|
|
if (got) return got.complete && got.naturalWidth ? got : null;
|
|
const img = new Image();
|
|
img.onload = () => this.invalidate();
|
|
img.onerror = () => this.icons.set(url, 'failed');
|
|
img.src = url;
|
|
this.icons.set(url, img);
|
|
return null;
|
|
}
|
|
|
|
/** A small rounded label with an optional round icon, centred on (x, y). */
|
|
private chip(ctx: CanvasRenderingContext2D, x: number, y: number, text: string, color: string, iconUrl: string) {
|
|
ctx.font = '700 12px system-ui, -apple-system, Segoe UI, sans-serif';
|
|
const img = iconUrl ? this.icon(iconUrl) : null;
|
|
const iconW = img ? 20 : 0;
|
|
const w = ctx.measureText(text).width + 18 + iconW, h = 24;
|
|
ctx.save();
|
|
ctx.shadowColor = 'rgba(0,0,0,0.45)'; ctx.shadowBlur = 8; ctx.shadowOffsetY = 2;
|
|
roundRect(ctx, x - w / 2, y - h / 2, w, h, 12);
|
|
ctx.fillStyle = this.theme.labelBg; ctx.fill();
|
|
ctx.restore();
|
|
ctx.lineWidth = 1.5; ctx.strokeStyle = withAlpha(color, 0.9); ctx.stroke();
|
|
let left = x - w / 2 + 9;
|
|
if (img) {
|
|
ctx.save(); ctx.beginPath(); ctx.arc(left + 8, y, 8, 0, Math.PI * 2); ctx.clip(); ctx.drawImage(img, left, y - 8, 16, 16); ctx.restore();
|
|
left += 20;
|
|
}
|
|
ctx.fillStyle = this.theme.label; ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
|
|
ctx.fillText(text, left, y + 0.5);
|
|
}
|
|
|
|
private tag(ctx: CanvasRenderingContext2D, x: number, y: number, text: string, color?: string) {
|
|
ctx.font = '600 11px system-ui, -apple-system, Segoe UI, sans-serif';
|
|
const w = ctx.measureText(text).width + 12;
|
|
roundRect(ctx, x - w / 2, y - 9, w, 18, 9);
|
|
ctx.fillStyle = this.theme.labelBg; ctx.fill();
|
|
if (color) { ctx.lineWidth = 1; ctx.strokeStyle = color; ctx.stroke(); }
|
|
ctx.fillStyle = this.theme.label; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
ctx.fillText(text, x, y + 0.5);
|
|
}
|
|
}
|
|
|
|
// ---- helpers --------------------------------------------------------------------------------------------
|
|
|
|
function clamp(v: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, v)); }
|
|
|
|
/** Zoom-0 tile bounds -> world block bounds [minX, maxX, minZ, maxZ]. */
|
|
function worldBounds(b: MapBounds): [number, number, number, number] {
|
|
return [b.min_x * TILE, (b.max_x + 1) * TILE, b.min_y * TILE, (b.max_y + 1) * TILE];
|
|
}
|
|
|
|
function hitId(s: MapSelection): string { return s.type === 'player' ? s.player.uuid : s.type === 'pin' ? s.pin.id : s.claim.id; }
|
|
function hitLabel(s: MapSelection): string { return s.type === 'player' ? s.player.name : s.type === 'pin' ? s.pin.label : `${s.claim.tag ? `[${s.claim.tag}] ` : ''}${s.claim.name}`; }
|
|
|
|
function inside(ring: [number, number][], x: number, z: number): boolean {
|
|
let in_ = false;
|
|
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
const [ax, az] = ring[i], [bx, bz] = ring[j];
|
|
if ((az > z) !== (bz > z) && x < ((bx - ax) * (z - az)) / (bz - az) + ax) in_ = !in_;
|
|
}
|
|
return in_;
|
|
}
|
|
|
|
export function withAlpha(color: string, alpha: number): string {
|
|
const m = /^#?([0-9a-f]{6})$/i.exec(color.trim());
|
|
if (!m) return color;
|
|
const n = parseInt(m[1], 16);
|
|
return `rgba(${n >> 16}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
|
|
}
|
|
|
|
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + r, y);
|
|
ctx.arcTo(x + w, y, x + w, y + h, r);
|
|
ctx.arcTo(x + w, y + h, x, y + h, r);
|
|
ctx.arcTo(x, y + h, x, y, r);
|
|
ctx.arcTo(x, y, x + w, y, r);
|
|
ctx.closePath();
|
|
}
|
|
|
|
/** A tiny white symbol for each kind of pin, drawn as shapes so no icon font is needed. */
|
|
function drawGlyph(ctx: CanvasRenderingContext2D, kind: string, x: number, y: number, r: number) {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
ctx.fillStyle = '#fff';
|
|
ctx.strokeStyle = '#fff';
|
|
ctx.beginPath();
|
|
switch (kind) {
|
|
case 'spawn': // star
|
|
for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + (i * Math.PI) / 5, rr = i % 2 ? r * 0.45 : r * 1.15; ctx.lineTo(Math.cos(a) * rr, Math.sin(a) * rr); }
|
|
ctx.closePath(); ctx.fill();
|
|
break;
|
|
case 'warp': // diamond with a hole
|
|
ctx.moveTo(0, -r * 1.2); ctx.lineTo(r * 0.95, 0); ctx.lineTo(0, r * 1.2); ctx.lineTo(-r * 0.95, 0); ctx.closePath(); ctx.fill();
|
|
break;
|
|
case 'home': // house
|
|
ctx.moveTo(-r * 1.1, 0); ctx.lineTo(0, -r * 1.1); ctx.lineTo(r * 1.1, 0); ctx.lineTo(r * 0.7, 0); ctx.lineTo(r * 0.7, r); ctx.lineTo(-r * 0.7, r); ctx.lineTo(-r * 0.7, 0); ctx.closePath(); ctx.fill();
|
|
break;
|
|
case 'guild_home': // flag
|
|
ctx.fillRect(-r * 0.8, -r * 1.1, r * 0.28, r * 2.2);
|
|
ctx.moveTo(-r * 0.52, -r * 1.1); ctx.lineTo(r * 1.0, -r * 0.5); ctx.lineTo(-r * 0.52, 0.1 * r); ctx.closePath(); ctx.fill();
|
|
break;
|
|
case 'market': // $
|
|
ctx.font = `800 ${Math.round(r * 2.3)}px system-ui, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('$', 0, r * 0.1);
|
|
break;
|
|
default: // shop: a bag
|
|
ctx.moveTo(-r * 0.9, -r * 0.3); ctx.lineTo(r * 0.9, -r * 0.3); ctx.lineTo(r * 0.7, r * 1.0); ctx.lineTo(-r * 0.7, r * 1.0); ctx.closePath(); ctx.fill();
|
|
ctx.lineWidth = Math.max(1.2, r * 0.22); ctx.beginPath(); ctx.arc(0, -r * 0.3, r * 0.5, Math.PI, 0); ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
}
|