Added political and economic map modes

This commit is contained in:
2026-09-19 22:40:24 +02:00
parent 187c7a7ed8
commit eca9e39760
18 changed files with 1302 additions and 10 deletions
+94
View File
@@ -418,6 +418,27 @@ input:focus, select:focus { border-color: var(--accent); }
so they stay legible instead of being covered by whatever is nearby. The
layer needs an explicit z-index because those icons give their own children
z-index 2-4, which would otherwise paint over this layer. */
/* Country names for the far-out political map, drawn as stretched SVG text on
their own layer below the city labels. The SVG has no size of its own; the
overflow is visible so the rotated text can sit anywhere on the map. */
.country-label-layer { pointer-events: none; z-index: 4; }
.political-label-svg {
position: absolute;
left: 0;
top: 0;
width: 0;
height: 0;
overflow: visible;
}
.political-label-svg text {
font-family: inherit;
font-weight: 700;
pointer-events: none;
}
/* Roads are hidden on the political map: flat country colours read better
without them, while the national borders stay to separate the nations. */
#map-world.political #layer-roads { display: none; }
.city-label-layer { pointer-events: none; z-index: 5; }
.city-label {
position: absolute;
@@ -510,6 +531,42 @@ input:focus, select:focus { border-color: var(--accent); }
text-shadow: 0 1px 2px #000;
}
/* Colour scale for the economic map modes, in its own space below the FPS
readout. The gradient itself is painted from the shared ramp in JS. */
.map-legend {
position: absolute;
right: 14px;
top: 38px;
width: 190px;
padding: 10px 12px;
border-radius: 10px;
background: var(--panel-bg);
border: 1px solid var(--panel-border);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55);
pointer-events: none;
z-index: 30;
}
.map-legend-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
margin-bottom: 6px;
}
.map-legend-bar {
height: 12px;
border-radius: 6px;
border: 1px solid rgba(0, 0, 0, 0.5);
}
.map-legend-scale {
display: flex;
justify-content: space-between;
margin-top: 4px;
font-size: 11px;
color: var(--text);
font-variant-numeric: tabular-nums;
}
/* The inspected tile / city panel is anchored to the bottom right. */
#tile-panel {
right: 14px;
@@ -579,11 +636,48 @@ input:focus, select:focus { border-color: var(--accent); }
flex-direction: column;
gap: 4px;
}
.dock-buttons { position: relative; display: flex; gap: 8px; pointer-events: auto; }
.game-corner .news-button {
pointer-events: auto;
padding: 6px 14px;
font-size: 13px;
}
/* The map-mode picker, anchored directly above the buttons. */
.map-modes-popover {
position: absolute;
left: 0;
bottom: calc(100% + 8px);
min-width: 170px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 2px;
border-radius: 10px;
background: var(--panel-bg);
border: 1px solid var(--panel-border);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55);
pointer-events: auto;
z-index: 45;
}
.map-modes-title {
padding: 2px 8px 6px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.map-mode-option {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
font-size: 13px;
color: var(--text);
cursor: pointer;
}
.map-mode-option:hover { background: rgba(110, 175, 255, 0.2); }
.map-mode-option input { accent-color: #6eafff; cursor: pointer; }
.feed-line {
align-self: flex-start;
max-width: 100%;
+32 -1
View File
@@ -108,6 +108,7 @@
<div class="layer" id="layer-paths"></div>
<div class="layer" id="layer-targets"></div>
<div class="layer" id="layer-entities"></div>
<div class="layer country-label-layer" id="layer-political-labels"></div>
<div class="layer city-label-layer" id="layer-labels"></div>
</div>
</div>
@@ -133,6 +134,17 @@
</div>
<div id="fps-label">60 FPS</div>
<!-- Colour scale for the economic map modes, hidden otherwise. -->
<div id="map-legend" class="map-legend hidden">
<div id="map-legend-title" class="map-legend-title"></div>
<div id="map-legend-bar" class="map-legend-bar"></div>
<div class="map-legend-scale">
<span id="map-legend-min"></span>
<span id="map-legend-mid"></span>
<span id="map-legend-max"></span>
</div>
</div>
<div id="tile-panel" class="panel hidden">
<div id="tile-info" class="info-section hidden">
<div id="tile-desc" class="info-title"></div>
@@ -158,7 +170,26 @@
<div id="left-dock">
<div class="game-corner">
<div id="game-feed" class="game-feed"></div>
<button id="news-button" class="aero news-button" title="News">News</button>
<div class="dock-buttons">
<button id="news-button" class="aero news-button" title="News">News</button>
<button id="map-modes-button" class="aero news-button" title="Map modes">Map modes</button>
<!-- Opens directly above its button. -->
<div id="map-modes-popover" class="map-modes-popover hidden">
<div class="map-modes-title">Map mode</div>
<label class="map-mode-option">
<input type="radio" name="map-mode" value="terrain" checked><span>Terrain</span>
</label>
<label class="map-mode-option">
<input type="radio" name="map-mode" value="political"><span>Political</span>
</label>
<label class="map-mode-option">
<input type="radio" name="map-mode" value="gdp"><span>GDP per capita</span>
</label>
<label class="map-mode-option">
<input type="radio" name="map-mode" value="population"><span>Population</span>
</label>
</div>
</div>
</div>
<div id="unit-panel" class="panel hidden">
<div id="unit-info" class="info-section hidden">
+6 -1
View File
@@ -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;
}
+88 -4
View File
@@ -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;
+29 -1
View File
@@ -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 = $('<div id="layer-labels" class="layer city-label-layer"></div>');
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 = $('<div id="layer-political-labels" class="layer country-label-layer"></div>');
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
);
+10
View File
@@ -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.
+67
View File
@@ -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");
}
+6
View File
@@ -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
+3
View File
@@ -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();
},
+135
View File
@@ -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;
},
};
+319
View File
@@ -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;
},
};
+14 -1
View File
@@ -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);
}
}
+78 -2
View File
@@ -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);
+1
View File
@@ -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";
+35
View File
@@ -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");
}
}
+124
View File
@@ -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;
}
}
+224
View File
@@ -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)})`;
}
+37
View File
@@ -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") });