diff --git a/public/modules/dynamic/editors/states-editor.js b/public/modules/dynamic/editors/states-editor.js
index e5a8efc9c8..3e8b2ea0ae 100644
--- a/public/modules/dynamic/editors/states-editor.js
+++ b/public/modules/dynamic/editors/states-editor.js
@@ -86,6 +86,13 @@ function insertEditorHtml() {
+
+
+
+
+
+
+
Brush size:
@@ -126,6 +133,8 @@ function addListeners() {
ensureEl("statesRandomize").on("click", randomizeStatesExpansion);
ensureEl("statesGrowthRate").on("input", () => recalculateStates(false));
ensureEl("statesManually").on("click", enterStatesManualAssignent);
+ ensureEl("statesManuallyState").on("change", highlightBrushRow);
+ ensureEl("statesManuallyDemote").on("change", updateDemotePickerLabel);
ensureEl("statesManuallyUndo").on("click", undoStatesManualAssignment);
ensureEl("statesManuallyApply").on("click", applyStatesManualAssignent);
ensureEl("statesManuallyCancel").on("click", () => exitStatesManualAssignment(false));
@@ -192,15 +201,10 @@ function statesEditorAddLines() {
totalBurgs += s.burgs;
}
- // Manual assignment picks the brush state by clicking its row, so every state must have a
- // row in the DOM. Pagination would hide states beyond the first page (the pager is hidden in
- // this mode too), making them unselectable. Render the full set while painting.
- const isManualAssignment = customization === 2;
const pageInfo = getEditorPage(allStates, statesPage);
- const rows = isManualAssignment ? allStates : pageInfo.items;
let lines = "";
- for (const s of rows) {
+ for (const s of pageInfo.items) {
const area = getArea(s.area);
const rural = s.rural * populationRate;
const urban = s.urban * populationRate * urbanization;
@@ -314,15 +318,10 @@ function statesEditorAddLines() {
ensureEl("statesFooterPopulation").innerHTML = si(totalPopulation);
ensureEl("statesFooterPopulation").dataset.population = totalPopulation;
- // No pager while painting: all rows are rendered and the footer is hidden anyway.
- if (isManualAssignment) {
- ensureEl("statesFooter").querySelector(":scope > .editorPagination")?.remove();
- } else {
- renderEditorPagination(ensureEl("statesFooter"), pageInfo, page => {
- statesPage.page = page;
- statesEditorAddLines();
- });
- }
+ renderEditorPagination(ensureEl("statesFooter"), pageInfo, page => {
+ statesPage.page = page;
+ statesEditorAddLines();
+ });
// add listeners
$body.querySelectorAll(":scope > div").forEach($line => {
@@ -947,22 +946,97 @@ function enterStatesManualAssignent() {
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "none"));
$("#statesEditor").dialog({ position: { my: "right top", at: "right-10 top+10", of: "svg", collision: "fit" } });
- tip("Click on state to select, drag the circle to change state", true);
+ // The editor list is paginated, so the brush target is chosen from a lightweight all-states
+ // dropdown (plain text, no emblems) rather than from a rendered row — otherwise states beyond
+ // the current page would be unselectable, and rendering every emblem at once freezes the tab.
+ populateBrushStateSelect();
+ highlightBrushRow();
+ ensureEl("statesManuallyDemote").checked = false;
+ updateDemotePickerLabel();
+
+ tip("Pick a state to paint with, then drag the circle. Click the map to pick the state under the cursor", true);
viewbox
.style("cursor", "crosshair")
.on("click", selectStateOnMapClick)
.call(d3.drag().on("start", dragStateBrush))
.on("touchmove mousemove", moveStateBrush);
- $body.querySelector("div").classList.add("selected");
statesManualHistory = [];
}
+// Fill the brush-target dropdown with every (non-removed) state, ordered to match the editor's
+// active sort. Cheap: one plain `)
+ .join("");
+}
+
+// Active brush target: the state the next stroke paints cells into.
+function getBrushStateId() {
+ const select = ensureEl("statesManuallyState");
+ return select && select.value !== "" ? +select.value : 0;
+}
+
+// Mirror the dropdown selection onto a visible row (purely cosmetic; the row may be off-page).
+function highlightBrushRow() {
+ const id = getBrushStateId();
+ $body.querySelector("div.selected")?.classList.remove("selected");
+ $body.querySelector("div[data-id='" + id + "']")?.classList.add("selected");
+}
+
+// Picker mode: clicking a whole state stages it for demotion into a province instead of brushing.
+function isDemotePickerOn() {
+ return Boolean(ensureEl("statesManuallyDemote")?.checked);
+}
+
+// The dropdown is the brush target normally, but the receiving state in picker mode.
+function updateDemotePickerLabel() {
+ const label = ensureEl("statesManuallyStateLabel");
+ if (label) label.textContent = isDemotePickerOn() ? "Demote into: " : "Paint as: ";
+}
+
+// Stage a whole-state demotion: preview every land cell of `stateId` recoloured into the receiving
+// state and tagged data-demote, so Apply routes it through mergeStates(..., toProvinces). The real
+// reassignment/province creation happens on Apply; this is visual + undoable like a brush stroke.
+function stageStateDemotion(stateId) {
+ const ownerId = getBrushStateId();
+ if (!stateId) return tip("Neutral land cannot be demoted to a province", false, "error");
+ if (!ownerId) return tip("Pick a receiving state from the dropdown first", false, "error");
+ if (ownerId === stateId) return tip("A state cannot be demoted into itself", false, "error");
+
+ saveStatesManualSnapshot();
+ const temp = statesBody.select("#temp");
+ const color = pack.states[ownerId].color || "#ffffff";
+ const { state: cellState, h } = pack.cells;
+ for (let i = 0; i < cellState.length; i++) {
+ if (cellState[i] !== stateId || h[i] < 20) continue;
+ const existing = temp.select("polygon[data-cell='" + i + "']");
+ if (existing.size())
+ existing.attr("data-state", ownerId).attr("data-demote", stateId).attr("fill", color).attr("stroke", color);
+ else
+ temp
+ .append("polygon")
+ .attr("data-cell", i)
+ .attr("data-state", ownerId)
+ .attr("data-demote", stateId)
+ .attr("points", getPackPolygon(i))
+ .attr("fill", color)
+ .attr("stroke", color);
+ }
+ tip(`${pack.states[stateId].fullName} staged to become a province of ${pack.states[ownerId].name}. Apply to commit`, true);
+}
+
function selectStateOnLineClick() {
if (customization !== 2) return;
if (this.parentNode.id !== "statesBodySection") return;
- $body.querySelector("div.selected").classList.remove("selected");
- this.classList.add("selected");
+ const select = ensureEl("statesManuallyState");
+ if (select) select.value = this.dataset.id;
+ highlightBrushRow();
}
function selectStateOnMapClick() {
@@ -970,16 +1044,21 @@ function selectStateOnMapClick() {
const i = findCell(point[0], point[1]);
if (pack.cells.h[i] < 20) return;
+ // In picker mode, the clicked cell's true owner (from pack, ignoring any staged preview) is the
+ // state being demoted into a province.
+ if (isDemotePickerOn()) return stageStateDemotion(pack.cells.state[i]);
+
const assigned = statesBody.select("#temp").select("polygon[data-cell='" + i + "']");
const state = assigned.size() ? +assigned.attr("data-state") : pack.cells.state[i];
- const $row = $body.querySelector("div[data-id='" + state + "']");
- if (!$row) return; // clicked state's row is on another page; ignore to avoid a crash
- $body.querySelector("div.selected")?.classList.remove("selected");
- $row.classList.add("selected");
+ // Set the brush to the clicked state even when its row is on another page.
+ const select = ensureEl("statesManuallyState");
+ if (select) select.value = state;
+ highlightBrushRow();
}
function dragStateBrush() {
+ if (isDemotePickerOn()) return; // picker stages whole states on click; no cell brushing
const r = +statesBrush.value;
saveStatesManualSnapshot();
@@ -998,8 +1077,7 @@ function dragStateBrush() {
function changeStateForSelection(selection) {
const temp = statesBody.select("#temp");
- const $selected = $body.querySelector("div.selected");
- const stateNew = +$selected.dataset.id;
+ const stateNew = getBrushStateId();
const color = pack.states[stateNew].color || "#ffffff";
const preventOverwrite = document.getElementById("statesManuallyProtect")?.checked;
@@ -1035,10 +1113,24 @@ function applyStatesManualAssignent() {
const affectedStates = [];
const affectedProvinces = [];
+ // Whole-state demotions staged by the picker, grouped by receiving state. Each group is committed
+ // via mergeStates(..., toProvinces=true) — the same path as the dialog's "merge down to provinces".
+ // Collected up front; their cells are skipped by the plain reassignment below (mergeStates owns them).
+ const demotionsByOwner = new Map();
+
statesBody
.select("#temp")
.selectAll("polygon")
.each(function () {
+ if (this.dataset.demote) {
+ const ownerId = +this.dataset.state;
+ const sourceId = +this.dataset.demote;
+ if (ownerId && sourceId && ownerId !== sourceId) {
+ if (!demotionsByOwner.has(ownerId)) demotionsByOwner.set(ownerId, new Set());
+ demotionsByOwner.get(ownerId).add(sourceId);
+ }
+ return;
+ }
const i = +this.dataset.cell;
const c = +this.dataset.state;
affectedStates.push(cells.state[i], c);
@@ -1057,7 +1149,13 @@ function applyStatesManualAssignent() {
if (layerIsOn("toggleProvinces")) drawProvinces();
}
+ // Leave paint mode (this removes #temp) before committing demotions; mergeStates does all the
+ // cell/burg/province/regiment reassignment, province creation and redraws itself.
exitStatesManualAssignment(false);
+ demotionsByOwner.forEach((sourceSet, ownerId) => {
+ const ids = [...sourceSet].filter(id => pack.states[id] && !pack.states[id].removed);
+ if (ids.length) mergeStates(ids, ownerId, true);
+ });
}
function adjustProvinces(affectedProvinces) {
@@ -1478,8 +1576,12 @@ function openStateMergeDialog() {
}
}
});
+}
- function mergeStates(statesToMerge, rulingStateId, mergeToProvinces = false) {
+// Merge `statesToMerge` into `rulingStateId`. With `mergeToProvinces`, each merged state is demoted
+// into a single province of the ruling state (keeping its name/form/colour/emblem) instead of being
+// dissolved. At module scope so both the merge dialog and the paint-mode picker can call it.
+function mergeStates(statesToMerge, rulingStateId, mergeToProvinces = false) {
const rulingState = pack.states[rulingStateId];
const rulingStateArmy = document.getElementById("army" + rulingStateId);
@@ -1510,7 +1612,11 @@ function openStateMergeDialog() {
// falling back to a generic "Province" if the state had no form name.
const formName = state.formName || "Province";
const fullName = name + " " + formName;
- const color = state.color;
+ // Province fills render at full opacity (#provs opacity:1), so generated provinces use a
+ // mixed/brightened shade of the state colour rather than the raw colour. Match that, else a
+ // dark-themed state's raw colour renders as a solid black province. getMixedColor keeps ~80%
+ // of the state colour, so the province still reads as "this used to be that state".
+ const color = getMixedColor(state.color);
const coa = state.coa; // reuse the former state's emblem
const pole = state.pole || pack.cells.p[center];
@@ -1566,7 +1672,10 @@ function openStateMergeDialog() {
if (statesToMerge.includes(burg.state)) {
if (burg.capital) {
burg.capital = 0;
- Burgs.changeGroup(burg);
+ // On a plain merge an absorbed capital becomes an ordinary burg. When demoting to a
+ // province it stays the province's centre, so keep its icon/label as-is (changeGroup
+ // would re-bucket it by population and spawn a town/village icon over the old capital).
+ if (!mergeToProvinces) Burgs.changeGroup(burg);
}
burg.state = rulingStateId;
}
@@ -1599,7 +1708,6 @@ function openStateMergeDialog() {
drawStateLabels([rulingStateId]);
refreshStatesEditor();
- }
}
function downloadStatesCsv() {
diff --git a/public/modules/ui/editors.js b/public/modules/ui/editors.js
index 9ec4c937ec..8a30c0829c 100644
--- a/public/modules/ui/editors.js
+++ b/public/modules/ui/editors.js
@@ -1079,7 +1079,7 @@ function refreshAllEditors() {
// dynamically loaded editors
async function editStates() {
if (customization) return;
- const Editor = await import("../dynamic/editors/states-editor.js?v=1.122.15");
+ const Editor = await import("../dynamic/editors/states-editor.js?v=1.122.18");
Editor.open();
}