Files
Battle-for-Tismo/client/js/map_view/political.js
T

328 lines
13 KiB
JavaScript

// IMPORTANT: a human may modify these values. They should not affect how tests
// behave.
// The political view: when the camera pulls far out the terrain gives way to a
// flat map of each country's colour, and every country's name is drawn once per
// contiguous holding, fitted to the shape of that holding.
//
// The name placement is pure geometry: `countryLabels` groups the territory
// into connected components, finds the largest rotated band that fits inside
// each one, and sizes the text to fill it. The view only has to anchor the
// result on the map. Keeping the fitting here means it can be tested without a
// browser, and the renderer stays a thin drawing layer.
import { mapToLocal, key, parseKey, HEX_W, HEX_H } from "../../../shared/hex.js";
import { isWaterTile } from "./textures.js";
import { CAMERA_TILT } from "./constants.js";
// Neutral ground and open water, used by the DOM fallback where there is no
// terrain atlas to fall back on.
export const NEUTRAL_LAND = "#5b5f49";
export const POLITICAL_WATER = "#16324a";
// A holding needs at least this many tiles before it earns a name; one-hex
// islands would only add clutter (and unreadable text).
const MIN_TILES = 3;
// Below these the text would be unreadable, so the label is dropped instead.
export const MIN_FONT = 7;
const MIN_TEXT_LENGTH = 20;
// A name is never rotated further than this from the horizontal, so it stays
// easy to read no matter how the holding is shaped.
export const MAX_LABEL_ANGLE = (45 * Math.PI) / 180;
// Space kept clear of the holding's edge, as a fraction of the band and with a
// floor so even a small holding has breathing room.
const MARGIN_FRACTION = 0.16;
const MIN_MARGIN = 8;
// The text is vertically centred on the band, so the font size is the usable
// band height times this safety factor (ascent plus descent is a little over
// one em for most faces).
const HEIGHT_PAD = 0.82;
// Kerning may be opened up to this many times the natural run, but the letters
// themselves are never squashed or pulled closer together.
export const MAX_KERNING = 5;
// Splits a territory map (tile key -> civ id) into connected components. On a
// cylindrical world the neighbour lookup already folds the seam, so a holding
// that wraps is still one component; each tile is placed in an unwrapped pixel
// frame by walking the component and accumulating the shortest pixel deltas, so
// the seam never splits the shape it is fitted to.
export function territoryComponents(territory, topology) {
const byCiv = new Map();
for (const [k, civ] of territory) {
if (!byCiv.has(civ)) byCiv.set(civ, []);
byCiv.get(civ).push(k);
}
const components = [];
for (const [civ, keys] of byCiv) {
const remaining = new Set(keys);
while (remaining.size > 0) {
const startKey = remaining.values().next().value;
remaining.delete(startKey);
const startCoords = parseKey(startKey);
const startPos = mapToLocal(startCoords.x, startCoords.y);
const positions = new Map([[startKey, startPos]]);
const coordsByKey = new Map([[startKey, startCoords]]);
const memberKeys = [];
const queue = [startKey];
while (queue.length > 0) {
const k = queue.pop();
memberKeys.push(k);
const coords = coordsByKey.get(k);
const pos = positions.get(k);
for (const neighbour of topology.neighbours(coords.x, coords.y)) {
const nk = key(neighbour.x, neighbour.y);
if (!remaining.has(nk)) continue;
remaining.delete(nk);
const delta = topology.pixelDelta(coords, neighbour);
positions.set(nk, { x: pos.x + delta.x, y: pos.y + delta.y });
coordsByKey.set(nk, neighbour);
queue.push(nk);
}
}
components.push({
civ,
keys: memberKeys,
points: memberKeys.map((k) => positions.get(k)),
});
}
}
return components;
}
// The principal axis of a point cloud and its extent along that axis, as
// { cx, cy, angle, cos, sin }. The axis is the first eigenvector of the
// covariance matrix (for a 2D cloud this has a closed form), normalised so the
// text is never upside down.
export function principalAxis(points) {
const n = points.length;
let cx = 0;
let cy = 0;
for (const point of points) {
cx += point.x;
cy += point.y;
}
cx /= n;
cy /= n;
let sxx = 0;
let syy = 0;
let sxy = 0;
for (const point of points) {
const dx = point.x - cx;
const dy = point.y - cy;
sxx += dx * dx;
syy += dy * dy;
sxy += dx * dy;
}
sxx /= n;
syy /= n;
sxy /= n;
let angle = 0.5 * Math.atan2(2 * sxy, sxx - syy);
while (angle > Math.PI / 2) angle -= Math.PI;
while (angle <= -Math.PI / 2) angle += Math.PI;
return { cx, cy, angle, cos: Math.cos(angle), sin: Math.sin(angle) };
}
// The largest rotated band that fits inside one component. The points are
// projected onto the principal axis, binned along it, and every contiguous run
// of bins is scored by the area of the band whose height is set by the overlap
// of the bins' perpendicular spans. The best run wins, which gives a label that
// hugs the widest, longest part of an irregular holding instead of spilling out
// of a concave notch.
export function fitLabelBand(points) {
const axis = principalAxis(points);
const { cx, cy } = axis;
// Clamp the base angle to a readable near-horizontal range; the band is then
// fitted along that clamped direction.
const angle = Math.max(-MAX_LABEL_ANGLE, Math.min(MAX_LABEL_ANGLE, axis.angle));
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const binSize = HEX_W;
const bins = new Map();
for (const point of points) {
const dx = point.x - cx;
const dy = point.y - cy;
const u = dx * cos + dy * sin;
const v = -dx * sin + dy * cos;
const index = Math.floor(u / binSize);
let bin = bins.get(index);
if (!bin) {
bin = { index, vmin: Infinity, vmax: -Infinity };
bins.set(index, bin);
}
if (v < bin.vmin) bin.vmin = v;
if (v > bin.vmax) bin.vmax = v;
}
const ordered = [...bins.values()].sort((a, b) => a.index - b.index);
// A tile occupies half a hex either side of its centre, so the true
// perpendicular room is half a hex larger than the centre span.
const pad = HEX_H / 2;
let best = null;
for (let i = 0; i < ordered.length; i++) {
let maxVmin = -Infinity;
let minVmax = Infinity;
for (let j = i; j < ordered.length; j++) {
if (j > i && ordered[j].index !== ordered[j - 1].index + 1) break;
maxVmin = Math.max(maxVmin, ordered[j].vmin);
minVmax = Math.min(minVmax, ordered[j].vmax);
const clearance = (minVmax - maxVmin) / 2 + pad;
if (clearance <= 0) break;
const u0 = (ordered[i].index + 0.5) * binSize;
const u1 = (ordered[j].index + 0.5) * binSize;
const span = u1 - u0 + binSize;
const area = span * clearance;
if (!best || area > best.area) {
best = {
area,
span,
clearance,
vCenter: (minVmax + maxVmin) / 2,
uCenter: (u0 + u1) / 2,
};
}
}
}
if (!best) return null;
// Keep a margin off every edge on both axes before the text is sized.
const heightMargin = Math.max(MIN_MARGIN, best.clearance * MARGIN_FRACTION);
const usableHeight = best.clearance - heightMargin;
const fontSize = 2 * usableHeight * HEIGHT_PAD;
if (fontSize < MIN_FONT) return null;
const lengthMargin = Math.max(MIN_MARGIN, best.span * MARGIN_FRACTION);
const maxLength = best.span - 2 * lengthMargin;
if (maxLength < MIN_TEXT_LENGTH) return null;
const x = cx + best.uCenter * cos - best.vCenter * sin;
const y = cy + best.uCenter * sin + best.vCenter * cos;
return { x, y, angle, fontSize, maxLength };
}
// Decides the final font size and run for one name, given the width it takes at
// the proposed size. The letters are never compressed: if the natural run is
// wider than the band the font shrinks to fit instead, and if it is narrower
// the gap between glyphs is opened up by at most MAX_KERNING times the natural
// run. A null `textLength` means the name is left at its natural spacing.
export function fitTextWidth(naturalWidth, fontSize, maxLength) {
if (!(naturalWidth > 0) || !(maxLength > 0)) return { fontSize, textLength: null };
if (naturalWidth > maxLength) {
return { fontSize: fontSize * (maxLength / naturalWidth), textLength: null };
}
const target = Math.min(maxLength, naturalWidth * MAX_KERNING);
if (target > naturalWidth * (1 + 1e-3)) {
return { fontSize, textLength: target };
}
return { fontSize, textLength: null };
}
// One label per contiguous holding, ready to be anchored on the map. `civ` is
// the owner id, so the caller can tint or group them.
export function countryLabels(territory, civilisations, topology) {
const labels = [];
if (!territory || !topology) return labels;
for (const component of territoryComponents(territory, topology)) {
if (component.points.length < MIN_TILES) continue;
const civ = civilisations ? civilisations[component.civ] : null;
if (!civ || !civ.name) continue;
const band = fitLabelBand(component.points);
if (!band) continue;
labels.push({
civ: component.civ,
name: civ.name,
colour: civ.primaryColour || "#ffffff",
tiles: component.points.length,
...band,
});
}
return labels;
}
export const politicalMethods = {
// Builds one SVG text per contiguous holding. Each name is measured and then
// fitted to its band: a run wider than the band shrinks the font, a narrower
// one is opened up with spacing only (`lengthAdjust="spacing"`), capped so the
// letters are never pulled together or spread more than MAX_KERNING.
_renderPoliticalLabels() {
const $layer = this.$politicalLabels;
if (!$layer || !$layer.length || !this.topology) return;
const labels = countryLabels(this.territory, this.civilisations, this.topology);
this._countryLabels = labels;
if (this._labelSvg) {
this._labelSvg.remove();
this._labelSvg = null;
}
this._labelEntries = [];
const doc = document;
const ns = "http://www.w3.org/2000/svg";
const svg = doc.createElementNS(ns, "svg");
svg.setAttribute("class", "political-label-svg");
// An outer SVG clips to its viewport, and a zero-sized one is not painted
// at all in some browsers, so the viewport is given the world's size (the
// labels' coordinate space). `overflow: visible` still shows the wrapped
// copies that fall outside it.
svg.setAttribute("width", String(this._worldWidth || 1));
svg.setAttribute("height", String(this._worldHeight || 1));
// Attach before measuring: a detached text node has no layout to measure.
$layer[0].appendChild(svg);
for (const label of labels) {
const el = doc.createElementNS(ns, "text");
el.setAttribute("text-anchor", "middle");
el.setAttribute("dominant-baseline", "central");
el.setAttribute("lengthAdjust", "spacing");
el.setAttribute("fill", "#ffffff");
el.setAttribute("stroke", "rgba(0, 0, 0, 0.75)");
el.setAttribute("paint-order", "stroke");
// Set the size before measuring: the natural run has to be read at the
// size the name will actually be drawn at, or fitting it back would
// squash every glyph onto its neighbour.
el.setAttribute("font-size", label.fontSize.toFixed(2));
el.textContent = label.name;
svg.appendChild(el);
const natural = typeof el.getComputedTextLength === "function"
? el.getComputedTextLength()
: 0;
const fitted = fitTextWidth(natural, label.fontSize, label.maxLength);
if (fitted.fontSize < MIN_FONT) {
svg.removeChild(el);
continue;
}
el.setAttribute("font-size", fitted.fontSize.toFixed(2));
el.setAttribute("stroke-width", (fitted.fontSize * 0.08).toFixed(2));
if (fitted.textLength) el.setAttribute("textLength", fitted.textLength.toFixed(2));
this._labelEntries.push({ el, label });
}
this._labelSvg = svg;
this._positionPoliticalLabels();
},
// Anchors every name on the wrapped copy nearest the camera. The ground is
// foreshortened, so the Y is tilted and the on-screen angle is the ground
// angle folded through the same tilt; the text itself stays upright.
_positionPoliticalLabels() {
if (!this._labelEntries) return;
const tilt = CAMERA_TILT;
for (const { el, label } of this._labelEntries) {
const x = label.x;
const y = label.y * tilt;
const angle = Math.atan2(Math.sin(label.angle) * tilt, Math.cos(label.angle));
const degrees = (angle * 180) / Math.PI;
el.setAttribute("transform",
`translate(${x.toFixed(2)} ${y.toFixed(2)}) rotate(${degrees.toFixed(2)})`);
}
},
// Paints one DOM terrain tile as a flat political colour. The atlas mask on
// `.hex.terrain` still clips it to the hexagon, so no per-tile clip is needed.
_applyPoliticalColour(div, tile, k) {
let background = POLITICAL_WATER;
if (!isWaterTile(tile)) {
const owner = this.territory ? this.territory.get(k) : undefined;
const civ = owner !== undefined && this.civilisations
? this.civilisations[owner]
: null;
background = civ ? civ.primaryColour : NEUTRAL_LAND;
}
div.style.backgroundImage = "none";
div.style.backgroundColor = background;
const maskPosition = `${-tile.col * HEX_W}px 0`;
div.style.maskPosition = maskPosition;
div.style.webkitMaskPosition = maskPosition;
},
};