Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 137 additions & 29 deletions public/modules/dynamic/editors/states-editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ function insertEditorHtml() {

<button id="statesManually" data-tip="Manually re-assign states" class="icon-brush"></button>
<div id="statesManuallyButtons" style="display: none">
<div data-tip="State to paint with. Lists every state, so states beyond the current page are still selectable" style="margin-block: 0.3em;">
<label><span id="statesManuallyStateLabel">Paint as: </span><select id="statesManuallyState" style="max-width: 14em"></select></label>
</div>
<div data-tip="Picker mode: click a whole state on the map to stage it for demotion into a single province of the state selected above (keeping its name, form, colour and emblem). Staged states are committed on Apply." style="margin-block: 0.3em;">
<input id="statesManuallyDemote" class="checkbox" type="checkbox" />
<label for="statesManuallyDemote" class="checkbox-label"><i>demote clicked state to a province</i></label>
</div>
<div data-tip="Change brush size. Shortcuts: + / ] to increase; - / [ to decrease" style="margin-block: 0.3em;">
<slider-input id="statesBrush" min="1" max="100" value="15">Brush size:</slider-input>
</div>
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -947,39 +946,119 @@ 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 <option> per state instead of a full emblem-bearing row.
function populateBrushStateSelect() {
const select = ensureEl("statesManuallyState");
if (!select) return;
const states = pack.states.filter(s => !s.removed);
sortDataByActiveHeader(ensureEl("statesHeader"), states, STATES_SORT_ACCESSORS);
select.innerHTML = states
.map(s => `<option value="${s.i}">${s.i ? s.fullName : s.name}</option>`)
.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() {
const point = d3.mouse(this);
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();

Expand All @@ -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;

Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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];

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -1599,7 +1708,6 @@ function openStateMergeDialog() {
drawStateLabels([rulingStateId]);

refreshStatesEditor();
}
}

function downloadStatesCsv() {
Expand Down
2 changes: 1 addition & 1 deletion public/modules/ui/editors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
Loading