Files
Battle-for-Tismo/client/js/map_view/modes.js
T
adrien 1324128245 Showed idle trade routes and flagged migrant flows on the economic maps
The delivery graph now ships every route the viewer's own storage nodes could trade over, not just the legs that ran, and keeps exports as well as imports so goods leaving the nation appear. The map draws the idle routes grey beneath the active white legs, with a key entry for them.

The population map replaces its bare flow arrows with the arrows plus a disc textured with the origin nation's flag drifting from city to city, mirroring the merchandise that rides the trade graph.
2026-09-22 21:44:29 +02:00

441 lines
18 KiB
JavaScript

// 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, mapToLocal } from "../../../shared/hex.js";
import { CAMERA_TILT, 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";
import { makeSegment, makeArrow } from "./utils.js";
import { compact } from "../../../shared/text_format.js";
import { RESOURCES } from "../../../shared/data/resources.js";
export const MAP_MODES = ["terrain", "political", "gdp", "population"];
// The migration map: one hue for the route so it stays legible over the
// population gradient, with the month's flow mapped between these thicknesses.
// A disc textured with the origin nation's flag rides each route, mirroring the
// merchandise icons that ride the trade graph.
const MIGRATION_COLOUR = "#ff3ec8";
const MIGRATION_ARROW_MIN = 1.5;
const MIGRATION_ARROW_MAX = 12;
// The delivery graph draws one line per leg, so the goods carried are
// shown by the merchandise icon riding the line rather than by the line's
// colour. Every icon makes the round trip in this many milliseconds. Routes
// with no goods moving along them are drawn in the idle grey instead.
const DELIVERY_COLOUR = "#ffffff";
const DELIVERY_IDLE_COLOUR = "#7b7f85";
const DELIVERY_PERIOD_MS = 2600;
const RESOURCE_META = Object.fromEntries(RESOURCES.map((r) => [r.id, r]));
// A stable offset in 0..1 so every leg starts its run at a different point of
// the cycle, spreading the icons out instead of moving them in lockstep.
function deliveryPhase(resource, fromX, fromY, toX, toY) {
const seed = `${resource}:${fromX},${fromY}->${toX},${toY}`;
let hash = 2166136261;
for (let i = 0; i < seed.length; i++) {
hash = Math.imul(hash ^ seed.charCodeAt(i), 16777619);
}
return ((hash >>> 0) % 997) / 997;
}
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._repaintMode();
},
// 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 };
// The scale a mode reads its figures through (the population mode is
// logarithmic). Kept on the view so both renderers agree.
this.economicScale = (ECONOMIC_MODES[kind] || {}).scale || null;
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)}`;
},
// -------------------------------------------------- migration graph -----
// Replaces the migration graph shipped in the snapshot. It is only drawn in
// the population mode, which is an economic mode, so a political or terrain
// map never reveals it.
setMigrationGraph(graph) {
if (!graph) return;
this.migrationGraph = {
links: Array.isArray(graph.links) ? graph.links : [],
};
this._renderResourceGraph();
},
// ------------------------------------------------- delivery graph ------
// Replaces the delivery graph shipped in the snapshot. It is only drawn in
// the economic modes, so a political or terrain map never reveals it.
setResourceGraph(graph) {
if (!graph) return;
this.resourceGraph = {
nodes: Array.isArray(graph.nodes) ? graph.nodes : [],
links: Array.isArray(graph.links) ? graph.links : [],
edges: Array.isArray(graph.edges) ? graph.edges : [],
};
this._renderResourceGraph();
},
// Draws the economic overlay's graph. The population map carries the
// migration arrows; every other economic mode carries the delivery graph of
// storage nodes and delivery legs. The layer shares the world's coordinate
// system, so pan and zoom move it for free.
_renderResourceGraph() {
if (!this.$resources || !this.$resources.length) return;
const migration = this.mapMode === "population";
const graph = migration ? this.migrationGraph : this.resourceGraph;
const empty = graph.links.length === 0 && (!graph.edges || graph.edges.length === 0);
if (!this.economic || empty) {
if (this.$resources[0].firstChild) this.$resources.empty();
this._deliveryMovers = [];
this._renderGraphLegend();
return;
}
if (migration) {
this._deliveryMovers = [];
this._renderMigrationFlow(graph.links);
} else {
this._renderDeliveryLines(graph);
}
this._renderGraphLegend();
},
// Every potential route grey, the routes that actually carried goods white
// over them, a dot on every storage node and a merchandise icon per active
// leg, animated along the line by `_animateDeliveryIcons` so the goods moving
// between regions are obvious at a glance.
_renderDeliveryLines(graph) {
const fragment = document.createDocumentFragment();
const movers = [];
for (const edge of graph.edges || []) {
const [fromX, fromY, toX, toY] = edge;
fragment.appendChild(
makeSegment(mapToLocal(fromX, fromY), mapToLocal(toX, toY), DELIVERY_IDLE_COLOUR, 2)
);
}
for (const link of graph.links) {
const [fromX, fromY, toX, toY, resource] = link;
const a = mapToLocal(fromX, fromY);
const b = mapToLocal(toX, toY);
fragment.appendChild(makeSegment(a, b, DELIVERY_COLOUR, 3));
const meta = RESOURCE_META[resource];
if (!meta || !meta.icon) continue;
const mover = document.createElement("img");
mover.className = "resource-mover";
mover.src = `assets/${meta.icon}`;
mover.alt = meta.name;
mover.draggable = false;
// The layer is foreshortened on Y; undo the tilt on the sprite so the
// merchandise stays upright while its centre rides the line.
mover.style.transform = `scaleY(${1 / CAMERA_TILT})`;
movers.push({
el: mover,
a,
b,
phase: deliveryPhase(resource, fromX, fromY, toX, toY),
});
fragment.appendChild(mover);
}
for (const node of graph.nodes) {
const point = mapToLocal(node[0], node[1]);
const dot = document.createElement("div");
dot.className = "resource-node";
dot.style.left = `${point.x}px`;
dot.style.top = `${point.y}px`;
fragment.appendChild(dot);
}
this.$resources.empty()[0].appendChild(fragment);
this._deliveryMovers = movers;
this._animateDeliveryIcons();
},
// Slides every graph icon along its leg (merchandise on the trade routes,
// flags on the migration ones), the whole fleet sharing one wall-clock cycle
// so a snapshot rebuild leaves the animation uninterrupted.
_animateDeliveryIcons() {
const movers = this._deliveryMovers;
if (!movers || movers.length === 0) return;
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
const travel = (now % DELIVERY_PERIOD_MS) / DELIVERY_PERIOD_MS;
for (const mover of movers) {
const t = (travel + mover.phase) % 1;
mover.el.style.left = `${mover.a.x + (mover.b.x - mover.a.x) * t}px`;
mover.el.style.top = `${mover.a.y + (mover.b.y - mover.a.y) * t}px`;
// Fade at both ends, so the jump back to the origin is hidden and the
// fleet reads as a continuous flow.
mover.el.style.opacity = Math.min(1, t / 0.1, (1 - t) / 0.1).toFixed(3);
}
},
// One arrow per city pair, its thickness scaled to the people who moved over
// the month so the volume reads at a glance, with a flag disc per route
// drifting from origin to destination like the trade graph's merchandise.
_renderMigrationFlow(links) {
let max = 0;
for (const link of links) if (link[4] > max) max = link[4];
const fragment = document.createDocumentFragment();
const movers = [];
for (const link of links) {
const [fromX, fromY, toX, toY, people, civ] = link;
const a = mapToLocal(fromX, fromY);
const b = mapToLocal(toX, toY);
const width = MIGRATION_ARROW_MIN +
(MIGRATION_ARROW_MAX - MIGRATION_ARROW_MIN) * (max > 0 ? people / max : 0);
fragment.appendChild(makeArrow(a, b, MIGRATION_COLOUR, width));
const flag = this._migrationFlag(civ);
if (!flag) continue;
const mover = document.createElement("div");
mover.className = "migrant-mover";
mover.style.backgroundImage = `url("assets/flags/${flag}")`;
// The layer is foreshortened on Y; undo the tilt on the disc so the flag
// stays circular while its centre rides the route.
mover.style.transform = `scaleY(${1 / CAMERA_TILT})`;
movers.push({
el: mover,
a,
b,
phase: deliveryPhase(`migrant:${civ}`, fromX, fromY, toX, toY),
});
fragment.appendChild(mover);
}
this.$resources.empty()[0].appendChild(fragment);
this._deliveryMovers = movers;
this._animateDeliveryIcons();
},
// The flag art of the nation an origin city belongs to, or null when the
// catalogue is not in yet. `this.civilisations` is indexed by civ number.
_migrationFlag(civ) {
const entry = this.civilisations && this.civilisations[civ];
return entry && entry.flag ? entry.flag : null;
},
// The key for the economic overlay's graph. The population mode keys the
// migration arrows; every other mode keys the commodities and storage nodes.
// Shown only with a graph.
_renderGraphLegend() {
const $el = this.$legendGraph;
if (!$el || !$el.length) return;
const migration = this.mapMode === "population";
const graph = migration ? this.migrationGraph : this.resourceGraph;
const hasEdges = !migration && graph.edges && graph.edges.length > 0;
if (!this.economic || (graph.links.length === 0 && !hasEdges)) {
$el.addClass("hidden");
return;
}
$el.removeClass("hidden");
if (this.$legendGraphTitle && this.$legendGraphTitle.length) {
this.$legendGraphTitle.text(migration ? "Migration (last month)" : "Trade routes");
}
const $list = this.$legendResources.empty();
if (migration) {
let max = 0;
for (const link of graph.links) if (link[4] > max) max = link[4];
$list.append(
$("<div class='map-legend-resource'></div>")
.append($("<span class='map-legend-arrow'></span>"))
.append($("<span></span>").text("People between cities (flags show origin)"))
);
$list.append(
$("<div class='map-legend-resource'></div>")
.append($("<span class='map-legend-note'></span>"))
.append($("<span></span>").text(`Busiest route: ${compact(max)}`))
);
return;
}
const present = new Set(graph.links.map((link) => link[4]));
for (const resource of RESOURCES) {
if (!present.has(resource.id)) continue;
$list.append(
$("<div class='map-legend-resource'></div>")
.append(
$("<img class='map-legend-icon' />")
.attr("src", `assets/${resource.icon}`)
.attr("alt", resource.name)
)
.append($("<span></span>").text(resource.name))
);
}
if (this._hasIdleEdges(graph)) {
$list.append(
$("<div class='map-legend-resource'></div>")
.append($("<span class='map-legend-idle'></span>"))
.append($("<span></span>").text("Idle route"))
);
}
$list.append(
$("<div class='map-legend-resource'></div>")
.append($("<span class='map-legend-node'></span>"))
.append($("<span></span>").text("Storage node"))
);
},
// Whether any potential route is carrying nothing: the ones the graph draws
// grey. Keyed to the same directed pair the server ships as an edge.
_hasIdleEdges(graph) {
if (!graph.edges || graph.edges.length === 0) return false;
const active = new Set(
graph.links.map((link) => `${link[0]},${link[1]}->${link[2]},${link[3]}`)
);
return graph.edges.some(
(edge) => !active.has(`${edge[0]},${edge[1]}->${edge[2]},${edge[3]}`)
);
},
// 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. Returns whether
// either flag actually changed, so callers can skip a repaint otherwise.
_syncModeState() {
const political = this._effectivePolitical();
const economic = this._isEconomic();
const changed = political !== this.political || economic !== this.economic;
// Touch the DOM classes only on a real change: this runs on every camera
// move, where the flags almost never move.
if (political !== this.political) this.$world.toggleClass("political", political);
if (economic !== this.economic) this.$world.toggleClass("economic", economic);
this.political = political;
this.economic = economic;
return changed;
},
// Called from the camera whenever the zoom changes: terrain mode may have
// crossed into or out of the far-out political map.
_updatePoliticalMode() {
if (!this._syncModeState()) return;
this._repaintMode();
},
// A mode or zoom-threshold change: the whole surface flips between relief and
// flat, so the fills, borders, fog and selected-region overlay all repaint.
_repaintMode() {
this._refreshMapColours();
if (this.glRenderer && this.topology) {
// The fog only needs the rows on screen, so leave it to the frame loop's
// coalesced rebuild instead of building the whole mesh here. Clearing the
// cover makes the next flush treat it as an escape, so it runs at once.
this._fogDirty = true;
this._fogBuiltAt = 0;
this._fogCover = undefined;
this._fogChanged = null;
this.glRenderer.buildRegionOverlay(this);
}
},
// Repaints the active fills, the borders over them and the labels/legend that
// go with them. This is the per-territory-change path, so it deliberately
// leaves the fog and region overlay alone: their geometry only depends on the
// mode, not on who owns which tile.
_refreshMapColours() {
if (this.glRenderer && this.topology) {
this.glRenderer.buildPolitical(this);
this.glRenderer.buildEconomic(this);
this.glRenderer.buildBorders(this);
} else if (!this.glRenderer && this.topology) {
this._rebuildDomTerrain();
}
if (this.political) this._renderPoliticalLabels();
else if (this.$politicalLabels) this.$politicalLabels.empty();
this._renderResourceGraph();
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(this._economicMidpoint(min, max, meta.scale)));
$legend.find("#map-legend-max").text(meta.format(max));
$legend.find("#map-legend-bar")
.css("background", `linear-gradient(to right, ${ECONOMIC_GRADIENT.join(", ")})`);
},
// The value whose scaled position is the midpoint of the ramp, so a
// logarithmic legend still labels the middle colour honestly.
_economicMidpoint(min, max, scale) {
if (scale && max > min) {
const mid = (scale(min) + scale(max)) / 2;
return Math.expm1(mid);
}
return (min + max) / 2;
},
// 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,
ECONOMIC_GRADIENT, this.economicScale
);
}
div.style.backgroundImage = "none";
div.style.backgroundColor = colour;
const maskPosition = `${-tile.col * HEX_W}px 0`;
div.style.maskPosition = maskPosition;
div.style.webkitMaskPosition = maskPosition;
},
};