diff --git a/client/js/game_screen.js b/client/js/game_screen.js
index a04e7cd..d894fbc 100644
--- a/client/js/game_screen.js
+++ b/client/js/game_screen.js
@@ -8,7 +8,7 @@
// snapshot plumbing and the order flow.
import { MapView } from "./map_view.js";
-import { CityModal, NationModal, ConfirmModal, NewsModal } from "./modals.js";
+import { CityModal, NationModal, ConfirmModal, NewsModal, MapModesModal } from "./modals.js";
import { GameFeed } from "./feed.js";
import { CIVILISATIONS, PROTO_UNITS } from "../../shared/data.js";
import { panelMethods } from "./game_screen/panels.js";
@@ -65,6 +65,7 @@ export class GameScreen {
this.nationModal = new NationModal();
this.confirmModal = new ConfirmModal();
this.newsModal = new NewsModal();
+ this.mapModesModal = new MapModesModal();
this.feed = new GameFeed($("#game-feed"));
this.$stackMenu = $("#stack-menu");
this.$statTooltip = $("#stat-tooltip");
@@ -89,6 +90,7 @@ export class GameScreen {
this.feed.clear();
this._seenNews = null;
this._hideStackMenu();
+ this.mapModesModal.close();
$(document).off("mousedown.stackmenu");
}
@@ -153,6 +155,8 @@ export class GameScreen {
this.map.setCatalogue(this.civilisations, this.protoUnits);
this.map.applySnapshot(state);
+ // Refresh an active economic map mode against the new figures.
+ this._refreshEconomicMode();
this.selectedUnitIds = this.selectedUnitIds.filter((id) => this._unit(id));
this.map.setSelectedUnits(this.selectedUnitIds);
this._updateTileHighlight();
@@ -344,6 +348,7 @@ export class GameScreen {
this.nationModal.close();
this.confirmModal.close();
this.newsModal.close();
+ this.mapModesModal.close();
if (!modalOpen) this.map.clearSelection();
break;
}
diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js
index c386563..a404785 100644
--- a/client/js/game_screen/panels.js
+++ b/client/js/game_screen/panels.js
@@ -23,13 +23,42 @@ import {
import { defenseFactors, unitDefense, gdpPerCapitaFactors, gdpPerCapitaFromFactors } from "../../../shared/rules.js";
import { dateString } from "../../../shared/game_clock.js";
import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js";
+import { parseKey } from "../../../shared/hex.js";
import { ethnicityChart } from "../ui/ethnicity.js";
+import { isEconomicMode } from "../map_view/economic.js";
+import { isWaterTile } from "../map_view/textures.js";
+
+// A tiny memo cache keyed by bucket and key, used while a per-tile economic
+// figure is computed for every tile at once.
+function memo(caches, bucket, key, compute) {
+ if (!caches) return compute();
+ let cache = caches[bucket];
+ if (!cache) {
+ cache = new Map();
+ caches[bucket] = cache;
+ }
+ if (!cache.has(key)) cache.set(key, compute());
+ return cache.get(key);
+}
export const panelMethods = {
_bindHud() {
$("#city-manage").on("click", () => this._openCityModal());
$("#civ-flag").on("click", () => this._openNationModal());
$("#news-button").on("click", () => this._openNewsModal());
+ $("#map-modes-button").on("click", (event) => {
+ event.stopPropagation();
+ this.mapModesModal.toggle(this.map.mapMode);
+ });
+ this.mapModesModal.onMode = (mode) => this._setMapMode(mode);
+ // A click anywhere outside the picker dismisses it.
+ $(document).on("mousedown.mapmodes", (event) => {
+ if (this.mapModesModal.isShowing() &&
+ !$(event.target).closest("#map-modes-popover").length &&
+ !$(event.target).closest("#map-modes-button").length) {
+ this.mapModesModal.close();
+ }
+ });
this.cityModal.onTrain = (cityId, protoIndex) =>
this.sendOrder({ type: "train", city: cityId, proto: protoIndex });
@@ -696,15 +725,17 @@ export const panelMethods = {
// it, mirroring the server's getTileGdpPerCapita through the shared helper.
// The region that develops the tile supplies the building bonus; government
// and research supply the nation-wide one.
- _tileGdpBreakdown(coords, owner, city, tile) {
+ _tileGdpBreakdown(coords, owner, city, tile, caches = null) {
const k = `${coords.x},${coords.y}`;
const regionCity =
city || (this.snapshot.cities || []).find((c) => c.id === this._tileRegion.get(k));
- const regionalModifier = regionCity ? this._cityGdpModifier(regionCity) : 0;
- const nationalModifier = this._nationalGdpModifier(owner);
+ const regionalModifier = regionCity
+ ? memo(caches, "regional", regionCity.id, () => this._cityGdpModifier(regionCity))
+ : 0;
+ const nationalModifier = memo(caches, "national", owner, () => this._nationalGdpModifier(owner));
const factors = gdpPerCapitaFactors(tile, {
cityKind: city ? (city.isCapital ? "capital" : "city") : null,
- nextToCity: !city && this._isNextToCity(coords, owner),
+ nextToCity: !city && memo(caches, "nextToCity", k, () => this._isNextToCity(coords, owner)),
pillagePenalty: this._tileGdpPenalties.has(k) ? this._tileGdpPenalties.get(k) : 1,
battleDeficit: this._tileBattleGdpDeficits.get(k) || 0,
regionalMultiplier: 1 + regionalModifier,
@@ -718,6 +749,59 @@ export const panelMethods = {
};
},
+ // -------------------------------------------------------- map modes -----
+
+ // Applied when the Map modes picker reports a choice. An economic mode needs
+ // its per-tile figures before the map switches to them.
+ _setMapMode(mode) {
+ if (mode === this.map.mapMode) return;
+ if (isEconomicMode(mode)) {
+ const { values, min, max } = this._computeEconomicValues(mode);
+ this.map.setEconomicData(mode, values, min, max);
+ }
+ this.map.setMapMode(mode);
+ },
+
+ // Recomputes the active economic mode's figures after a snapshot, so the map
+ // and its legend always show current numbers.
+ _refreshEconomicMode() {
+ const mode = this.map.mapMode;
+ if (!isEconomicMode(mode)) return;
+ const { values, min, max } = this._computeEconomicValues(mode);
+ this.map.setEconomicData(mode, values, min, max);
+ },
+
+ // One figure per land tile for an economic mode, with the range the legend
+ // and the colour scale need. GDP per capita mirrors the tile panel's formula;
+ // population is the snapshot's raw per-tile count.
+ _computeEconomicValues(mode) {
+ const values = new Map();
+ let min = Infinity;
+ let max = -Infinity;
+ if (!this.map || !this.map.tiles) return { values, min: 0, max: 0 };
+ const cities = this.snapshot.cities || [];
+ const cityByKey = new Map(cities.map((c) => [`${c.coords[0]},${c.coords[1]}`, c]));
+ const caches = mode === "gdp" ? {} : null;
+ for (const k in this.map.tiles) {
+ const tile = this.map.tiles[k];
+ if (isWaterTile(tile)) continue;
+ let value;
+ if (mode === "population") {
+ value = this._population.get(k) || 0;
+ } else {
+ const coords = parseKey(k);
+ const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : -1;
+ const city = cityByKey.get(k) || null;
+ value = this._tileGdpBreakdown(coords, owner, city, tile, caches).total;
+ }
+ values.set(k, value);
+ if (value < min) min = value;
+ if (value > max) max = value;
+ }
+ if (!Number.isFinite(min)) return { values, min: 0, max: 0 };
+ return { values, min, max };
+ },
+
// The GDP bonus a single city's own buildings give.
_cityGdpModifier(city) {
let total = 0;
diff --git a/client/js/map_view.js b/client/js/map_view.js
index 0a83eca..16cf5cb 100644
--- a/client/js/map_view.js
+++ b/client/js/map_view.js
@@ -12,6 +12,8 @@ import { terrainMethods } from "./map_view/terrain.js";
import { entityMethods } from "./map_view/entities.js";
import { motionMethods } from "./map_view/motion.js";
import { inputMethods } from "./map_view/input.js";
+import { politicalMethods } from "./map_view/political.js";
+import { modeMethods } from "./map_view/modes.js";
import { createGLMapRenderer } from "./map_view/webgl.js";
import { CAMERA_TILT } from "./map_view/constants.js";
@@ -54,6 +56,15 @@ export class MapView {
this.$labels = $('
');
this.$world.append(this.$labels);
}
+ // Country names for the political map live below the city labels, so a city
+ // name is never hidden behind a (much larger) country name.
+ this.$politicalLabels = layers.politicalLabels && layers.politicalLabels.length
+ ? layers.politicalLabels
+ : $("#layer-political-labels");
+ if (!this.$politicalLabels.length) {
+ this.$politicalLabels = $('
');
+ this.$world.append(this.$politicalLabels);
+ }
// The orthographic camera tilts the ground plane, so the terrain and every
// ground overlay are scaled on Y while the sprite layers stay upright.
@@ -85,6 +96,21 @@ export class MapView {
this.protoUnits = [];
// Battlefield tiles, keyed "x,y" and mapped to { defender, attacker }.
this._battles = new Map();
+ // The active map mode, plus the effective fills derived from it. `political`
+ // is also true while terrain mode is zoomed far out; `economic` is true for
+ // the GDP/population modes and comes with a per-tile value map and range.
+ this.mapMode = options.mapMode || "terrain";
+ this.political = false;
+ this.economic = false;
+ this.economicKind = null;
+ this.economicValues = new Map();
+ this.economicRange = { min: 0, max: 0 };
+ this._economicSignatureDone = null;
+ // The colour scale shown while an economic mode is active.
+ this.$legend = $("#map-legend");
+ this._labelSvg = null;
+ this._labelEntries = [];
+ this._countryLabels = [];
this.camera = { x: 0, y: 0, zoom: 1.0 };
this._centered = false;
@@ -197,5 +223,7 @@ Object.assign(
terrainMethods,
entityMethods,
motionMethods,
- inputMethods
+ inputMethods,
+ politicalMethods,
+ modeMethods
);
diff --git a/client/js/map_view/constants.js b/client/js/map_view/constants.js
index e9df068..16942be 100644
--- a/client/js/map_view/constants.js
+++ b/client/js/map_view/constants.js
@@ -78,6 +78,16 @@ export function terrainLod(zoom) {
return TERRAIN_LODS.length - 1;
}
+// Past this zoom the map stops being a terrain map and becomes a political one:
+// each country's territory is painted in its colour and its name is drawn once
+// per contiguous holding. It matches the threshold between the two coarsest
+// terrain levels of detail, where tile art is already sub-pixel.
+export const POLITICAL_ZOOM = 0.5;
+
+export function politicalMode(zoom) {
+ return zoom <= POLITICAL_ZOOM;
+}
+
// A battle spreads the two stacks to either side of the tile centre, with the
// battle icon between them. Side offset is in pixels; step is the spacing
// between the (half-size) units of one stack.
diff --git a/client/js/map_view/economic.js b/client/js/map_view/economic.js
new file mode 100644
index 0000000..72061a7
--- /dev/null
+++ b/client/js/map_view/economic.js
@@ -0,0 +1,67 @@
+// The economic map modes: a colour scale that turns a per-tile figure (GDP per
+// capita or raw population) into a hex colour, plus the catalogue describing
+// each mode.
+//
+// The gradient runs from yellow (poorest / smallest) to blue (richest /
+// largest). Those two hues are the pair most colour-blind players can still
+// tell apart, and the stops are the reversed cividis palette, which keeps a
+// monotonic lightness across the whole ramp so the magnitude also reads without
+// colour vision.
+
+import { parseColour } from "./utils.js";
+import { groupDigits } from "../../../shared/text_format.js";
+
+export const ECONOMIC_GRADIENT = [
+ "#ffea46",
+ "#e4cf5b",
+ "#c4b56c",
+ "#a69d75",
+ "#8a8779",
+ "#707173",
+ "#575d6c",
+ "#39486b",
+ "#00306f",
+ "#00204d",
+];
+
+export const ECONOMIC_MODES = {
+ gdp: {
+ id: "gdp",
+ label: "GDP per capita",
+ format: (value) => `€${groupDigits(Math.round(value))}`,
+ },
+ population: {
+ id: "population",
+ label: "Population",
+ format: (value) => groupDigits(Math.round(value)),
+ },
+};
+
+export function isEconomicMode(mode) {
+ return mode === "gdp" || mode === "population";
+}
+
+// Where a value sits on the scale, clamped to 0..1. A flat range (every tile
+// the same) sits in the middle so the map reads as a single mid colour.
+export function economicFraction(value, min, max) {
+ if (!(max > min)) return 0.5;
+ const t = (value - min) / (max - min);
+ return Math.max(0, Math.min(1, t));
+}
+
+// The colour for a value, as a "#rrggbb" string, interpolated between the two
+// gradient stops that bracket it.
+export function economicColour(value, min, max, stops = ECONOMIC_GRADIENT) {
+ const t = economicFraction(value, min, max);
+ const scaled = t * (stops.length - 1);
+ const index = Math.min(stops.length - 2, Math.floor(scaled));
+ const blend = scaled - index;
+ const a = parseColour(stops[index]);
+ const b = parseColour(stops[index + 1]);
+ const channel = (i) => Math.round(a[i] + (b[i] - a[i]) * blend);
+ return `#${toHex(channel(0))}${toHex(channel(1))}${toHex(channel(2))}`;
+}
+
+function toHex(value) {
+ return value.toString(16).padStart(2, "0");
+}
diff --git a/client/js/map_view/entities.js b/client/js/map_view/entities.js
index d889e5b..d54c34b 100644
--- a/client/js/map_view/entities.js
+++ b/client/js/map_view/entities.js
@@ -10,6 +10,7 @@ export const entityMethods = {
setCatalogue(civilisations, protoUnits) {
this.civilisations = civilisations;
this.protoUnits = protoUnits;
+ if (this.political) this._refreshMapColours();
},
applySnapshot(state) {
@@ -23,6 +24,10 @@ export const entityMethods = {
this._syncCities(state.cities);
this._syncUnits(state.units);
if (!this._centered) this._centerOnCapital(state.cities);
+ // A new world or snapshot may have arrived while a non-terrain mode was
+ // active, so recompute the effective fills and rebuild them.
+ this._syncModeState();
+ if (this.political || this.economic) this._refreshMapColours();
this.animate(0);
},
@@ -69,6 +74,7 @@ export const entityMethods = {
this._renderBorders();
if (this.glRenderer) this.glRenderer.buildBorders(this);
+ if (this.political) this._refreshMapColours();
},
// Mirrors each land tile's region (the city that claims it). The region of a
diff --git a/client/js/map_view/input.js b/client/js/map_view/input.js
index 069b3d4..b89a12d 100644
--- a/client/js/map_view/input.js
+++ b/client/js/map_view/input.js
@@ -38,8 +38,11 @@ export const inputMethods = {
const tx = width / 2 - x * zoom;
const ty = height / 2 - y * zoom * CAMERA_TILT;
this.$world.css("transform", `translate(${tx}px, ${ty}px) scale(${zoom})`);
+ // A zoom step may have crossed into or out of the political map.
+ this._updatePoliticalMode();
if (applyWrap) this._syncChunks();
if (this._selectedTile) this._positionHighlight();
+ this._positionPoliticalLabels();
this._glRender();
},
diff --git a/client/js/map_view/modes.js b/client/js/map_view/modes.js
new file mode 100644
index 0000000..eb4761b
--- /dev/null
+++ b/client/js/map_view/modes.js
@@ -0,0 +1,135 @@
+// The map-mode switch: terrain, political, and the economic colour modes. It
+// owns which fill the map is showing, keeps the effective political flag in
+// step with the camera zoom (terrain mode still turns political when far out),
+// and drives the economic fill and its legend. The political names themselves
+// live in ./political.js; this file only decides what is on screen.
+
+import { HEX_W } from "../../../shared/hex.js";
+import { POLITICAL_ZOOM } from "./constants.js";
+import { ECONOMIC_GRADIENT, ECONOMIC_MODES, economicColour, isEconomicMode } from "./economic.js";
+import { NEUTRAL_LAND, POLITICAL_WATER } from "./political.js";
+import { isWaterTile } from "./textures.js";
+
+export const MAP_MODES = ["terrain", "political", "gdp", "population"];
+
+export const modeMethods = {
+ // Selects a mode and repaints. Terrain mode keeps the far-out auto-switch, so
+ // "political" forces the political map at any zoom while "terrain" only shows
+ // it once the camera has pulled back.
+ setMapMode(mode) {
+ if (!MAP_MODES.includes(mode) || mode === this.mapMode) return;
+ this.mapMode = mode;
+ this._syncModeState();
+ this._refreshMapColours();
+ },
+
+ // Installs the per-tile figures an economic mode paints. Called by the game
+ // screen whenever a snapshot arrives while such a mode is active. A cheap
+ // signature skips the rebuild when nothing moved.
+ setEconomicData(kind, values, min, max) {
+ this.economicKind = kind;
+ this.economicValues = values instanceof Map ? values : new Map(values || []);
+ this.economicRange = { min: min || 0, max: max || 0 };
+ const signature = this._economicSignature();
+ if (signature === this._economicSignatureDone) return;
+ this._economicSignatureDone = signature;
+ if (this.economic) this._refreshMapColours();
+ },
+
+ _economicSignature() {
+ let sum = 0;
+ for (const value of this.economicValues.values()) sum += value;
+ return `${this.economicKind}:${this.economicValues.size}:${Math.round(sum)}`;
+ },
+
+ // Political shows when the user asked for it, or while terrain mode is
+ // zoomed far enough out. Economic modes are exclusive with it.
+ _effectivePolitical() {
+ return this.mapMode === "political" ||
+ (this.mapMode === "terrain" && this.camera.zoom <= POLITICAL_ZOOM);
+ },
+
+ _isEconomic() {
+ return isEconomicMode(this.mapMode);
+ },
+
+ // Recomputes the two effective flags and reflects them on the world element.
+ // The renderers read `political` / `economic` off the view.
+ _syncModeState() {
+ this.political = this._effectivePolitical();
+ this.economic = this._isEconomic();
+ this.$world.toggleClass("political", this.political);
+ this.$world.toggleClass("economic", this.economic);
+ },
+
+ // Called from the camera whenever the zoom changes: terrain mode may have
+ // crossed into or out of the far-out political map.
+ _updatePoliticalMode() {
+ const on = this._effectivePolitical();
+ if (on === this.political) return;
+ this._syncModeState();
+ this._refreshMapColours();
+ },
+
+ // Repaints the active fill and the labels/legend that go with it. The DOM
+ // renderer recolours its chunks; WebGL rebuilds the fill passes.
+ _refreshMapColours() {
+ if (this.glRenderer) {
+ this.glRenderer.buildPolitical(this);
+ this.glRenderer.buildEconomic(this);
+ } else if (this.topology) {
+ this._rebuildDomTerrain();
+ }
+ if (this.political) this._renderPoliticalLabels();
+ else if (this.$politicalLabels) this.$politicalLabels.empty();
+ this._updateLegend();
+ },
+
+ // Rebuilds every materialised chunk so a change of mode or territory colour
+ // is reflected in the DOM terrain. Only the chunks near the camera exist, so
+ // this stays cheap.
+ _rebuildDomTerrain() {
+ if (!this._chunks) return;
+ this._destroyAllChunks();
+ this._chunkDirty = true;
+ this._chunkView = { x: null, y: null, zoom: null };
+ this._syncChunks();
+ },
+
+ // Shows the gradient scale while an economic mode is active, with the range's
+ // low, middle and high figures for the current mode.
+ _updateLegend() {
+ const $legend = this.$legend;
+ if (!$legend || !$legend.length) return;
+ const meta = ECONOMIC_MODES[this.mapMode];
+ if (!this.economic || !meta) {
+ $legend.addClass("hidden");
+ return;
+ }
+ $legend.removeClass("hidden");
+ $legend.find("#map-legend-title").text(meta.label);
+ const { min, max } = this.economicRange;
+ $legend.find("#map-legend-min").text(meta.format(min));
+ $legend.find("#map-legend-mid").text(meta.format((min + max) / 2));
+ $legend.find("#map-legend-max").text(meta.format(max));
+ $legend.find("#map-legend-bar")
+ .css("background", `linear-gradient(to right, ${ECONOMIC_GRADIENT.join(", ")})`);
+ },
+
+ // Paints one DOM terrain tile with its economic colour. Water and tiles with
+ // no figure stay neutral; the atlas mask still clips the hexagon.
+ _applyEconomicColour(div, tile, k) {
+ let colour = POLITICAL_WATER;
+ if (!isWaterTile(tile)) {
+ const value = this.economicValues.get(k);
+ colour = value === undefined
+ ? NEUTRAL_LAND
+ : economicColour(value, this.economicRange.min, this.economicRange.max);
+ }
+ div.style.backgroundImage = "none";
+ div.style.backgroundColor = colour;
+ const maskPosition = `${-tile.col * HEX_W}px 0`;
+ div.style.maskPosition = maskPosition;
+ div.style.webkitMaskPosition = maskPosition;
+ },
+};
diff --git a/client/js/map_view/political.js b/client/js/map_view/political.js
new file mode 100644
index 0000000..8b46c17
--- /dev/null
+++ b/client/js/map_view/political.js
@@ -0,0 +1,319 @@
+// 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 = 2;
+
+// 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");
+ // 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 + this._wrapShift(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;
+ },
+};
diff --git a/client/js/map_view/terrain.js b/client/js/map_view/terrain.js
index 05a9823..49fa01b 100644
--- a/client/js/map_view/terrain.js
+++ b/client/js/map_view/terrain.js
@@ -68,6 +68,17 @@ export const terrainMethods = {
this.$entities.empty();
this.$labels.empty();
+ if (this.$politicalLabels) this.$politicalLabels.empty();
+ this._labelSvg = null;
+ this._labelEntries = [];
+ // The new world invalidates any political or economic colouring; the next
+ // camera sync re-applies the mode if the camera is still far out.
+ this.political = false;
+ this.economic = false;
+ this.economicValues = new Map();
+ this._economicSignatureDone = null;
+ this.$world.removeClass("political");
+ this.$world.removeClass("economic");
this._unitViews.clear();
this._unitMotion.clear();
this._unitData.clear();
@@ -341,7 +352,9 @@ export const terrainMethods = {
div.className = "hex terrain detail-layer";
div.style.left = `${local.x - rec.x - HEX_W / 2}px`;
div.style.top = `${local.y - rec.y - HEX_H / 2}px`;
- this._applyTerrainTexture(div, tile, local);
+ if (this.economic) this._applyEconomicColour(div, tile, k);
+ else if (this.political) this._applyPoliticalColour(div, tile, k);
+ else this._applyTerrainTexture(div, tile, local);
fragment.appendChild(div);
}
}
diff --git a/client/js/map_view/webgl.js b/client/js/map_view/webgl.js
index dc6497b..fe9addd 100644
--- a/client/js/map_view/webgl.js
+++ b/client/js/map_view/webgl.js
@@ -60,6 +60,7 @@ import {
import { createHeightField } from "./heightmap.js";
import { coastDistanceField } from "./coast.js";
import { generateWaterBump } from "./water.js";
+import { economicColour } from "./economic.js";
// Full terrain vertex layout, in floats: x, y, u, v, wx, wy, height, the base
// colour (grass, sand, atlas), one weight per overlay, then the two slope
@@ -362,6 +363,10 @@ export class GLMapRenderer {
this.fog = { buffer: null, count: 0 };
// The selected/inspected tile's region: a soft fill plus dotted borders.
this.regionOverlay = { buffer: null, count: 0 };
+ // The far-out political map: one flat country colour per owned tile.
+ this.politicalFill = { buffer: null, count: 0 };
+ // The economic modes: one flat colour per tile, from the shared scale.
+ this.economicFill = { buffer: null, count: 0 };
// The fog is rendered off-screen and blurred before it is composited, so
// its frontier is a soft gradient rather than the hex-tiling edges.
this.fullscreenProgram = null;
@@ -430,7 +435,7 @@ export class GLMapRenderer {
if (!gl) return;
for (const pass of [
this.terrain, this.ocean, this.roads, this.borders, this.fog, this.regionOverlay,
- ...this.terrainLevels,
+ this.politicalFill, this.economicFill, ...this.terrainLevels,
]) {
if (!pass || !pass.buffer) continue;
gl.deleteBuffer(pass.buffer);
@@ -1669,6 +1674,72 @@ export class GLMapRenderer {
this._upload(this.regionOverlay, arr, this.gl.DYNAMIC_DRAW);
}
+ // The far-out political map: every owned tile is filled with its country's
+ // colour. The hexagon rides the same warped boundary and relief as the
+ // terrain it covers, so the colour stops exactly on the coastline. The land
+ // the terrain already draws supplies the neutral ground for unclaimed tiles;
+ // here we only need to paint what a nation owns.
+ buildPolitical(view) {
+ const arr = [];
+ const territory = view && view.territory;
+ if (!view || !view.political || !territory || territory.size === 0 ||
+ !view.topology || !view.civilisations) {
+ this._upload(this.politicalFill, arr);
+ return;
+ }
+ const seed = view.seed | 0;
+ const period = periodUnits(view.topology);
+ const field = this._heightField(view);
+ for (const [k, owner] of territory) {
+ const civ = view.civilisations[owner];
+ if (!civ) continue;
+ const rgb = parseColour(civ.primaryColour);
+ const colour = [rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1];
+ this._pushTileFill(arr, view, parseKey(k), colour, seed, period, field);
+ }
+ this._upload(this.politicalFill, arr, this.gl.DYNAMIC_DRAW);
+ }
+
+ // The economic modes: every tile in the value map is filled with its place on
+ // the shared colour scale. The geometry is identical to the political fill,
+ // so it rides the same warped boundary and relief.
+ buildEconomic(view) {
+ const arr = [];
+ if (!view || !view.economic || !view.economicValues ||
+ view.economicValues.size === 0 || !view.topology) {
+ this._upload(this.economicFill, arr);
+ return;
+ }
+ const seed = view.seed | 0;
+ const period = periodUnits(view.topology);
+ const field = this._heightField(view);
+ const { min, max } = view.economicRange || { min: 0, max: 0 };
+ for (const [k, value] of view.economicValues) {
+ const rgb = parseColour(economicColour(value, min, max));
+ const colour = [rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1];
+ this._pushTileFill(arr, view, parseKey(k), colour, seed, period, field);
+ }
+ this._upload(this.economicFill, arr, this.gl.DYNAMIC_DRAW);
+ }
+
+ // One filled, relief-following hexagon for a tile, appended to a flat-colour
+ // buffer.
+ _pushTileFill(arr, view, coords, colour, seed, period, field) {
+ const centre = mapToLocal(coords.x, coords.y);
+ const boundary = distortedBoundary(seed, centre, period, 1, null, this._warp);
+ const heightRing = field ? field.ring(coords.x, coords.y) : null;
+ const heightAt = (point) =>
+ (field && heightRing ? field.blend(heightRing, point.x, point.y) : 0);
+ const centreHeight = heightAt(centre);
+ for (let i = 0; i < boundary.length; i++) {
+ const a = boundary[i];
+ const b = boundary[(i + 1) % boundary.length];
+ this._push(arr, centre.x, centre.y, centreHeight, colour);
+ this._push(arr, a.x, a.y, heightAt(a), colour);
+ this._push(arr, b.x, b.y, heightAt(b), colour);
+ }
+ }
+
// -------------------------------------------------------------- render --
// The horizontal periods whose copy of the world intersects the view. On a
@@ -1855,7 +1926,12 @@ export class GLMapRenderer {
// The flat ocean sits under everything; the land mesh is drawn over it.
this._drawPass(this.ocean, 0, shifts, period);
if (this.texReady && terrainPass) this._drawPass(terrainPass, 0, shifts, period, rows);
- this._drawPass(this.roads, 1, shifts, period);
+ const political = !!view.political;
+ const economic = !!view.economic;
+ if (political) this._drawPass(this.politicalFill, 1, shifts, period);
+ else if (economic) this._drawPass(this.economicFill, 1, shifts, period);
+ // Roads would only clutter the flat colours; borders stay.
+ if (!political && !economic) this._drawPass(this.roads, 1, shifts, period);
this._drawPass(this.borders, 1, shifts, period);
this._drawFog(bufferWidth, bufferHeight, shifts, period, rows);
this._drawPass(this.regionOverlay, 1, shifts, period);
diff --git a/client/js/modals.js b/client/js/modals.js
index 93dffd0..b88bf64 100644
--- a/client/js/modals.js
+++ b/client/js/modals.js
@@ -10,4 +10,5 @@ export { CityModal } from "./modals/city.js";
export { NationModal } from "./modals/nation.js";
export { ConfirmModal } from "./modals/confirm.js";
export { NewsModal } from "./modals/news.js";
+export { MapModesModal } from "./modals/map_modes.js";
export { describeNews } from "./modals/format.js";
diff --git a/client/js/modals/map_modes.js b/client/js/modals/map_modes.js
new file mode 100644
index 0000000..3553f37
--- /dev/null
+++ b/client/js/modals/map_modes.js
@@ -0,0 +1,35 @@
+// The map-mode picker: a small popover of radio options anchored above the
+// Map modes button. It only reports the chosen mode; the game screen applies
+// it, so the modal stays free of map state.
+
+export class MapModesModal {
+ constructor() {
+ this.$popover = $("#map-modes-popover");
+ this.onMode = () => {};
+ this.$popover.on("change", "input[name='map-mode']", (event) => {
+ const mode = $(event.currentTarget).val();
+ this.close();
+ this.onMode(mode);
+ });
+ }
+
+ isShowing() {
+ return this.$popover.length > 0 && !this.$popover.hasClass("hidden");
+ }
+
+ show(current) {
+ const $inputs = this.$popover.find("input[name='map-mode']");
+ $inputs.prop("checked", false);
+ $inputs.filter(`[value='${current}']`).prop("checked", true);
+ this.$popover.removeClass("hidden");
+ }
+
+ toggle(current) {
+ if (this.isShowing()) this.close();
+ else this.show(current);
+ }
+
+ close() {
+ this.$popover.addClass("hidden");
+ }
+}
diff --git a/tests/map_modes_test.js b/tests/map_modes_test.js
new file mode 100644
index 0000000..f3cd91a
--- /dev/null
+++ b/tests/map_modes_test.js
@@ -0,0 +1,124 @@
+import { TestCase } from "./framework/test_case.js";
+import { setupDom, teardownDom } from "./framework/dom.js";
+import { MapView } from "../client/js/map_view.js";
+import { CIVILISATIONS, PROTO_UNITS } from "../shared/data.js";
+import {
+ ECONOMIC_GRADIENT,
+ ECONOMIC_MODES,
+ economicColour,
+ economicFraction,
+} from "../client/js/map_view/economic.js";
+import { smallState, mapLayers } from "./framework/helpers.js";
+
+function cssRgb(hex) {
+ const value = hex.replace("#", "");
+ const r = parseInt(value.slice(0, 2), 16);
+ const g = parseInt(value.slice(2, 4), 16);
+ const b = parseInt(value.slice(4, 6), 16);
+ return `rgb(${r}, ${g}, ${b})`;
+}
+
+function luminance(hex) {
+ const value = hex.replace("#", "");
+ const r = parseInt(value.slice(0, 2), 16);
+ const g = parseInt(value.slice(2, 4), 16);
+ const b = parseInt(value.slice(4, 6), 16);
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+
+export class MapModesTest extends TestCase {
+ async test_economic_fraction_clamps_and_centres_a_flat_range() {
+ this.assertApprox(economicFraction(0, 0, 10), 0, 1e-9, "the minimum is zero");
+ this.assertApprox(economicFraction(10, 0, 10), 1, 1e-9, "the maximum is one");
+ this.assertApprox(economicFraction(5, 0, 10), 0.5, 1e-9, "the middle is halfway");
+ this.assertApprox(economicFraction(-4, 0, 10), 0, 1e-9, "below the range clamps");
+ this.assertApprox(economicFraction(40, 0, 10), 1, 1e-9, "above the range clamps");
+ this.assertApprox(economicFraction(7, 7, 7), 0.5, 1e-9, "a flat range sits mid-scale");
+ }
+
+ async test_economic_colour_runs_from_yellow_to_blue() {
+ const low = economicColour(0, 0, 100);
+ const high = economicColour(100, 0, 100);
+ this.assertEqual(low, ECONOMIC_GRADIENT[0], "the poorest tile is the first stop");
+ this.assertEqual(high, ECONOMIC_GRADIENT[ECONOMIC_GRADIENT.length - 1],
+ "the richest tile is the last stop");
+ this.assertGreater(luminance(low), luminance(high),
+ "the scale darkens as the value rises, so magnitude reads without colour");
+ const mid = economicColour(50, 0, 100);
+ this.assertTrue(/^#[0-9a-f]{6}$/.test(mid), `a valid colour: ${mid}`);
+ }
+
+ async test_every_economic_mode_has_a_label_and_formatter() {
+ for (const id of ["gdp", "population"]) {
+ const meta = ECONOMIC_MODES[id];
+ this.assertNotNull(meta, `${id} is catalogued`);
+ this.assertTrue(typeof meta.label === "string" && meta.label.length > 0, "it has a label");
+ this.assertTrue(typeof meta.format === "function", "it has a formatter");
+ this.assertTrue(meta.format(1234).length > 0, "the formatter returns text");
+ }
+ }
+
+ async test_the_economic_legend_names_its_range() {
+ const state = smallState();
+ const env = await setupDom();
+ const map = this.buildMap(env);
+ try {
+ map.applySnapshot(state.snapshot(0));
+ map.setMapMode("gdp");
+ const values = new Map();
+ for (const k in map.tiles) values.set(k, 1234);
+ map.setEconomicData("gdp", values, 100, 1234);
+ this.assertTrue(map.economic, "the GDP mode is active");
+ this.assertFalse(map.political, "the political fill is off");
+ this.assertFalse(env.$("#map-legend").hasClass("hidden"), "the legend is shown");
+ this.assertEqual(env.$("#map-legend-title").text(), "GDP per capita", "the legend is titled");
+ this.assertEqual(env.$("#map-legend-min").text(), "€100", "the legend shows the minimum");
+ this.assertEqual(env.$("#map-legend-max").text(), "€1 234", "the legend shows the maximum");
+ // Every land tile carries the top-of-range colour, so the fill is live.
+ const top = economicColour(1234, 100, 1234);
+ let painted = false;
+ env.$("#layer-terrain .hex.terrain").each((_index, element) => {
+ const value = element.style.backgroundColor;
+ if (value === top || value === cssRgb(top)) painted = true;
+ });
+ this.assertTrue(painted, "tiles are painted on the colour scale");
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_switching_modes_repaints_and_resets_the_legend() {
+ const state = smallState();
+ const env = await setupDom();
+ const map = this.buildMap(env);
+ try {
+ map.applySnapshot(state.snapshot(0));
+ // Terrain at this zoom is the default plain map.
+ this.assertFalse(map.political, "terrain mode at close zoom is not political");
+ this.assertFalse(map.economic, "terrain mode is not economic");
+
+ map.setMapMode("political");
+ this.assertTrue(map.political, "political mode forces the political fill");
+ this.assertTrue(map.$world.hasClass("political"), "the world is flagged political");
+ this.assertTrue(env.$("#map-legend").hasClass("hidden"), "no economic legend");
+
+ map.setMapMode("population");
+ this.assertTrue(map.economic, "population mode is economic");
+ this.assertFalse(map.political, "political is off in an economic mode");
+
+ map.setMapMode("terrain");
+ this.assertFalse(map.economic, "terrain clears the economic fill");
+ this.assertTrue(env.$("#map-legend").hasClass("hidden"), "the legend is hidden again");
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ buildMap(env) {
+ const map = new MapView(env.$("#map-viewport"), env.$("#map-world"), mapLayers(env.$));
+ map.$viewport.width = () => 800;
+ map.$viewport.height = () => 600;
+ map.setCatalogue(CIVILISATIONS, PROTO_UNITS);
+ return map;
+ }
+}
diff --git a/tests/political_map_test.js b/tests/political_map_test.js
new file mode 100644
index 0000000..16548e1
--- /dev/null
+++ b/tests/political_map_test.js
@@ -0,0 +1,224 @@
+import { TestCase } from "./framework/test_case.js";
+import { setupDom, teardownDom } from "./framework/dom.js";
+import { MapView } from "../client/js/map_view.js";
+import { MapTopology, key, parseKey } from "../shared/hex.js";
+import { CIVILISATIONS, PROTO_UNITS } from "../shared/data.js";
+import { politicalMode, POLITICAL_ZOOM } from "../client/js/map_view/constants.js";
+import {
+ countryLabels,
+ fitLabelBand,
+ fitTextWidth,
+ territoryComponents,
+ MAX_KERNING,
+ MAX_LABEL_ANGLE,
+ MIN_FONT,
+} from "../client/js/map_view/political.js";
+import { smallState, mapLayers } from "./framework/helpers.js";
+
+const CIVS = [
+ { id: "a", name: "France", primaryColour: "#123456" },
+ { id: "b", name: "Britain", primaryColour: "#abcdef" },
+];
+
+function territoryOf(entries) {
+ return new Map(entries.map(([x, y, civ]) => [key(x, y), civ]));
+}
+
+export class PoliticalMapTest extends TestCase {
+ async test_components_split_by_owner_and_contiguity() {
+ const topo = new MapTopology({ x: 8, y: 8 }, false);
+ const territory = territoryOf([
+ [0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0],
+ [5, 5, 0],
+ [0, 3, 1], [1, 3, 1],
+ ]);
+ const components = territoryComponents(territory, topo);
+ this.assertSize(components, 3, "one holding per contiguous run and owner");
+ const sizes = components.map((c) => c.points.length).sort((a, b) => a - b);
+ this.assertEqual(sizes, [1, 2, 4], "each holding keeps its own tiles");
+ for (const component of components) {
+ this.assertGreater(component.points.length, 0, "every holding has points");
+ }
+ }
+
+ async test_a_holding_across_the_wrap_is_unwrapped() {
+ const topo = new MapTopology({ x: 8, y: 4 }, true);
+ // On a width-8 cylindrical world the columns are -4..3, so 3 and -4 are the
+ // two sides of the seam and are neighbours.
+ const territory = territoryOf([[3, 0, 0], [-4, 0, 0], [-4, 1, 0]]);
+ const components = territoryComponents(territory, topo);
+ this.assertSize(components, 1, "the seam does not split the holding");
+ const points = components[0].points;
+ let span = 0;
+ for (const a of points) {
+ for (const b of points) span = Math.max(span, Math.hypot(a.x - b.x, a.y - b.y));
+ }
+ this.assertLess(span, 4 * 63, "the wrapped tiles are placed side by side");
+ }
+
+ async test_a_wide_holding_is_labelled_horizontally() {
+ const topo = new MapTopology({ x: 10, y: 8 }, false);
+ const entries = [];
+ for (let y = 0; y < 2; y++) {
+ for (let x = 0; x < 6; x++) entries.push([x, y, 0]);
+ }
+ const band = fitLabelBand(territoryComponents(territoryOf(entries), topo)[0].points);
+ this.assertNotNull(band, "a block gets a band");
+ this.assertLess(Math.abs(band.angle), 0.35, "the long axis is horizontal");
+ this.assertGreater(band.fontSize, 0, "the text has a size");
+ this.assertGreater(band.maxLength, 0, "the text has a length");
+ }
+
+ async test_a_tall_holding_is_never_rotated_far_from_horizontal() {
+ const topo = new MapTopology({ x: 8, y: 10 }, false);
+ const entries = [];
+ for (let y = 0; y < 6; y++) {
+ for (let x = 0; x < 2; x++) entries.push([x, y, 0]);
+ }
+ const band = fitLabelBand(territoryComponents(territoryOf(entries), topo)[0].points);
+ this.assertNotNull(band, "a tall block gets a band");
+ this.assertLess(Math.abs(band.angle), MAX_LABEL_ANGLE + 1e-6,
+ "the base angle stays inside the readable range");
+ }
+
+ async test_text_is_never_compressed_and_kerning_is_capped() {
+ // A run already wider than the band shrinks the font instead of squeezing
+ // the letters together.
+ const shrunk = fitTextWidth(300, 20, 200);
+ this.assertApprox(shrunk.fontSize, (20 * 200) / 300, 1e-9, "the font shrinks to fit");
+ this.assertNull(shrunk.textLength, "no spacing is forced when the font shrank");
+
+ // A short run is opened up, but never by more than MAX_KERNING times.
+ const widened = fitTextWidth(50, 20, 900);
+ this.assertApprox(widened.fontSize, 20, 1e-9, "the font is untouched");
+ this.assertApprox(widened.textLength, 50 * MAX_KERNING, 1e-9, "kerning is capped");
+
+ // A run that already fits and needs no help is left alone.
+ const natural = fitTextWidth(100, 20, 100);
+ this.assertNull(natural.textLength, "a fitting run keeps its natural spacing");
+ }
+
+ async test_the_label_keeps_a_margin_off_the_holding() {
+ const topo = new MapTopology({ x: 12, y: 10 }, false);
+ const entries = [];
+ for (let y = 0; y < 4; y++) {
+ for (let x = 0; x < 9; x++) entries.push([x, y, 0]);
+ }
+ const component = territoryComponents(territoryOf(entries), topo)[0];
+ const band = fitLabelBand(component.points);
+ this.assertNotNull(band, "the block gets a band");
+ const axis = Math.cos(band.angle);
+ const projected = component.points
+ .map((p) => p.x * axis + p.y * Math.sin(band.angle))
+ .sort((a, b) => a - b);
+ const span = projected[projected.length - 1] - projected[0];
+ this.assertLess(band.maxLength, span, "the text run stops short of the extremes");
+ this.assertGreater(band.fontSize, MIN_FONT, "the text is still readable");
+ }
+
+ async test_the_name_is_drawn_once_per_contiguous_holding() {
+ const topo = new MapTopology({ x: 10, y: 10 }, false);
+ const entries = [];
+ for (let y = 0; y < 3; y++) {
+ for (let x = 0; x < 4; x++) entries.push([x, y, 0]);
+ }
+ // A second, separate holding of the same nation.
+ for (let y = 6; y < 8; y++) {
+ for (let x = 6; x < 9; x++) entries.push([x, y, 0]);
+ }
+ // A holding of another nation, away from the first.
+ for (let y = 5; y < 8; y++) {
+ for (let x = 0; x < 4; x++) entries.push([x, y, 1]);
+ }
+ const labels = countryLabels(territoryOf(entries), CIVS, topo);
+ this.assertSize(labels, 3, "one label per contiguous holding");
+ const names = labels.map((label) => label.name).sort();
+ this.assertEqual(names, ["Britain", "France", "France"], "the owner names are used");
+ const france = labels.filter((label) => label.civ === 0);
+ this.assertEqual(france.length, 2, "the split holding is labelled twice");
+ }
+
+ async test_a_label_sits_inside_its_holding() {
+ const topo = new MapTopology({ x: 12, y: 10 }, false);
+ const entries = [];
+ for (let y = 1; y < 5; y++) {
+ for (let x = 2; x < 9; x++) entries.push([x, y, 0]);
+ }
+ const territory = territoryOf(entries);
+ const component = territoryComponents(territory, topo)[0];
+ const band = fitLabelBand(component.points);
+ this.assertNotNull(band, "the block gets a band");
+ const xs = component.points.map((p) => p.x);
+ const ys = component.points.map((p) => p.y);
+ this.assertTrue(band.x >= Math.min(...xs) - 63 && band.x <= Math.max(...xs) + 63,
+ "the label anchors over the holding");
+ this.assertTrue(band.y >= Math.min(...ys) - 56 && band.y <= Math.max(...ys) + 56,
+ "the label anchors over the holding");
+ // The band height is bounded by the room across the holding.
+ this.assertLess(band.fontSize, 4 * 56, "the text fits across the holding");
+ }
+
+ async test_tiny_holdings_get_no_name() {
+ const topo = new MapTopology({ x: 6, y: 6 }, false);
+ const labels = countryLabels(territoryOf([[0, 0, 0]]), CIVS, topo);
+ this.assertEmpty(labels, "a lone tile is not labelled");
+ }
+
+ async test_political_mode_matches_the_zoom_threshold() {
+ this.assertTrue(politicalMode(POLITICAL_ZOOM), "the threshold itself is political");
+ this.assertTrue(politicalMode(0.1), "far out is political");
+ this.assertFalse(politicalMode(POLITICAL_ZOOM + 0.01), "closer in is terrain");
+ }
+
+ async test_the_dom_map_paints_country_colours_and_clears_them() {
+ const state = smallState();
+ const env = await setupDom();
+ const map = new MapView(env.$("#map-viewport"), env.$("#map-world"), mapLayers(env.$));
+ map.$viewport.width = () => 800;
+ map.$viewport.height = () => 600;
+ map.setCatalogue(CIVILISATIONS, PROTO_UNITS);
+ const snapshot = state.snapshot(0);
+ try {
+ map.applySnapshot(snapshot);
+ this.assertGreater(map.territory.size, 0, "the snapshot has territory");
+ map.camera.zoom = 0.2;
+ map._applyCamera();
+ this.assertTrue(map.political, "the far zoom switches to the political map");
+ this.assertTrue(map.$world.hasClass("political"), "the world is flagged political");
+ const owned = snapshottedColourTile(env);
+ this.assertNotNull(owned, "an owned tile is painted a flat country colour");
+ const style = owned.getAttribute("style") || "";
+ this.assertFalse(/terrain\.png|textures\//.test(style), "the terrain texture is gone");
+ this.assertTrue(/background-color/.test(style), "the tile keeps a country colour");
+
+ map.camera.zoom = 1.0;
+ map._applyCamera();
+ this.assertFalse(map.political, "zooming back restores the terrain map");
+ this.assertFalse(map.$world.hasClass("political"), "the political flag is cleared");
+ this.assertSize(env.$("#layer-political-labels text"), 0, "the names are removed");
+ } finally {
+ teardownDom(env);
+ }
+ }
+}
+
+// The first on-screen terrain hex whose colour is one of the catalogue's, i.e.
+// a tile a nation owns. jsdom turns the colour into `rgb(...)`, so compare the
+// numeric channels of every civ rather than the original hex string.
+function snapshottedColourTile(env) {
+ const colours = new Set();
+ for (const civ of CIVILISATIONS) {
+ colours.add(civ.primaryColour.toLowerCase());
+ colours.add(cssColour(civ.primaryColour));
+ }
+ let found = null;
+ env.$("#layer-terrain .hex.terrain").each((_index, element) => {
+ if (!found && colours.has(element.style.backgroundColor)) found = element;
+ });
+ return found;
+}
+
+function cssColour(hex) {
+ const value = hex.replace("#", "");
+ return `rgb(${parseInt(value.slice(0, 2), 16)}, ${parseInt(value.slice(2, 4), 16)}, ${parseInt(value.slice(4, 6), 16)})`;
+}
diff --git a/tests/webgl_geometry_test.js b/tests/webgl_geometry_test.js
index 8e94e71..e9b2c90 100644
--- a/tests/webgl_geometry_test.js
+++ b/tests/webgl_geometry_test.js
@@ -15,6 +15,8 @@ function makeRenderer() {
renderer.borders = { name: "borders" };
renderer.roads = { name: "roads" };
renderer.regionOverlay = { name: "region" };
+ renderer.politicalFill = { name: "political" };
+ renderer.economicFill = { name: "economic" };
renderer._upload = function (pass, arr) {
this.captured[pass.name] = arr;
pass.count = arr.length / 8;
@@ -270,6 +272,41 @@ export class WebglGeometryTest extends TestCase {
this.assertTrue(lifted, "border vertices ride the heightmap");
}
+ async test_the_political_fill_paints_owned_tiles_only() {
+ const renderer = makeRenderer();
+ const state = view(renderer, { territory: [["0,0", 0], ["1,0", 1]] });
+ renderer._warp = renderer._buildWarpSet(state);
+ renderer.buildPolitical(state);
+ this.assertEmpty(renderer.captured.political, "the terrain map has no political fill");
+
+ state.political = true;
+ renderer.buildPolitical(state);
+ const arr = renderer.captured.political;
+ // One flat hexagon per owned tile: six triangles, 8 floats a vertex.
+ this.assertEqual(arr.length, 2 * 6 * 3 * 8, "two owned tiles are painted");
+ for (const value of arr) {
+ this.assertTrue(Number.isFinite(value), "every political vertex is finite");
+ }
+ }
+
+ async test_the_economic_fill_paints_every_valued_tile() {
+ const renderer = makeRenderer();
+ const state = view(renderer);
+ renderer._warp = renderer._buildWarpSet(state);
+ renderer.buildEconomic(state);
+ this.assertEmpty(renderer.captured.economic, "no figures means no economic fill");
+
+ state.economic = true;
+ state.economicValues = new Map([["0,0", 10], ["1,0", 20], ["0,1", 30]]);
+ state.economicRange = { min: 0, max: 30 };
+ renderer.buildEconomic(state);
+ const arr = renderer.captured.economic;
+ this.assertEqual(arr.length, 3 * 6 * 3 * 8, "one flat hexagon per valued tile");
+ for (const value of arr) {
+ this.assertTrue(Number.isFinite(value), "every economic vertex is finite");
+ }
+ }
+
async test_sea_edges_are_straight_except_against_land() {
const renderer = makeRenderer();
const state = view(renderer, { terrainAt: (x) => (x === 0 ? "Sea" : "Land") });