diff --git a/public/modules/dynamic/editors/cultures-editor.js b/public/modules/dynamic/editors/cultures-editor.js
index dee807dbd..6a51cf40e 100644
--- a/public/modules/dynamic/editors/cultures-editor.js
+++ b/public/modules/dynamic/editors/cultures-editor.js
@@ -1,5 +1,6 @@
const $body = insertEditorHtml();
addListeners();
+let culturesManualHistory = [];
const cultureTypes = ["Generic", "River", "Lake", "Naval", "Nomadic", "Hunting", "Highland"];
@@ -51,9 +52,10 @@ function insertEditorHtml() {
-
+
Brush size:
+
+
+
+
+
@@ -102,6 +108,7 @@ function addListeners() {
byId("statesRandomize").on("click", randomizeStatesExpansion);
byId("statesGrowthRate").on("input", () => recalculateStates(false));
byId("statesManually").on("click", enterStatesManualAssignent);
+ byId("statesManuallyUndo").on("click", undoStatesManualAssignment);
byId("statesManuallyApply").on("click", applyStatesManualAssignent);
byId("statesManuallyCancel").on("click", () => exitStatesManualAssignment(false));
byId("statesAdd").on("click", enterAddStateMode);
@@ -249,8 +256,8 @@ function statesEditorAddLines() {
${si(population)}
+ s.type
+ )}
@@ -771,12 +778,12 @@ function showStatesChart() {
option === "area"
? "Area: " + area
: option === "rural"
- ? "Rural population: " + si(rural)
- : option === "urban"
- ? "Urban population: " + si(urban)
- : option === "burgs"
- ? "Burgs number: " + d.data.burgs
- : "Population: " + si(rural + urban);
+ ? "Rural population: " + si(rural)
+ : option === "urban"
+ ? "Urban population: " + si(urban)
+ : option === "burgs"
+ ? "Burgs number: " + d.data.burgs
+ : "Population: " + si(rural + urban);
statesInfo.innerHTML = /* html */ `${state}. ${value}`;
stateHighlightOn(ev);
@@ -794,12 +801,12 @@ function showStatesChart() {
this.value === "area"
? d => d.area
: this.value === "rural"
- ? d => d.rural
- : this.value === "urban"
- ? d => d.urban
- : this.value === "burgs"
- ? d => d.burgs
- : d => d.rural + d.urban;
+ ? d => d.rural
+ : this.value === "urban"
+ ? d => d.urban
+ : this.value === "burgs"
+ ? d => d.burgs
+ : d => d.rural + d.urban;
root.sum(value);
node.data(treeLayout(root).leaves());
@@ -903,6 +910,7 @@ function enterStatesManualAssignent() {
.on("touchmove mousemove", moveStateBrush);
$body.querySelector("div").classList.add("selected");
+ statesManualHistory = [];
}
function selectStateOnLineClick() {
@@ -926,6 +934,7 @@ function selectStateOnMapClick() {
function dragStateBrush() {
const r = +statesBrush.value;
+ saveStatesManualSnapshot();
d3.event.on("drag", () => {
if (!d3.event.dx && !d3.event.dy) return;
@@ -945,11 +954,13 @@ function changeStateForSelection(selection) {
const $selected = $body.querySelector("div.selected");
const stateNew = +$selected.dataset.id;
const color = pack.states[stateNew].color || "#ffffff";
+ const preventOverwrite = byId("statesManuallyProtect")?.checked;
selection.forEach(function (i) {
const exists = temp.select("polygon[data-cell='" + i + "']");
const stateOld = exists.size() ? +exists.attr("data-state") : pack.cells.state[i];
if (stateNew === stateOld) return;
+ if (preventOverwrite && stateOld) return;
if (i === pack.states[stateOld].center) return;
// change of append new element
@@ -1148,6 +1159,7 @@ function adjustProvinces(affectedProvinces) {
function exitStatesManualAssignment(close) {
customization = 0;
+ statesManualHistory = [];
statesBody.select("#temp").remove();
removeCircle();
document.querySelectorAll("#statesBottom > button").forEach(el => (el.style.display = "inline-block"));
@@ -1168,6 +1180,21 @@ function exitStatesManualAssignment(close) {
if (selected) selected.classList.remove("selected");
}
+function saveStatesManualSnapshot() {
+ const temp = statesBody.select("#temp").node();
+ if (!temp) return;
+
+ statesManualHistory.push(temp.innerHTML);
+ if (statesManualHistory.length > 100) statesManualHistory.shift();
+}
+
+function undoStatesManualAssignment() {
+ const temp = statesBody.select("#temp").node();
+ if (!temp || !statesManualHistory.length) return;
+
+ temp.innerHTML = statesManualHistory.pop();
+}
+
function enterAddStateMode() {
if (this.classList.contains("pressed")) {
exitAddStateMode();
diff --git a/public/modules/ui/heightmap-editor.js b/public/modules/ui/heightmap-editor.js
index 4991a3f8b..b1dfc80e6 100644
--- a/public/modules/ui/heightmap-editor.js
+++ b/public/modules/ui/heightmap-editor.js
@@ -140,6 +140,11 @@ function editHeightmap(options) {
return;
}
+ if (pressed.id === "brushFill") {
+ removeCircle();
+ return;
+ }
+
moveCircle(x, y, heightmapBrushRadius.valueAsNumber, "#333");
}
@@ -631,26 +636,36 @@ function editHeightmap(options) {
byId("lineSlider").style.display = "none";
}
- const dragBrushThrottled = throttle(dragBrush, 100);
-
function toggleBrushMode(event) {
- if (event.target.classList.contains("pressed")) {
+ const button = event.target.closest("#brushesButtons > button");
+ if (!button) return;
+
+ if (button.classList.contains("pressed")) {
exitBrushMode();
return;
}
exitBrushMode();
- event.target.classList.add("pressed");
+ button.classList.add("pressed");
+ toggleFillBrushUi(button.id === "brushFill");
- if (event.target.id === "brushLine") {
+ if (button.id === "brushLine") {
byId("lineSlider").style.display = "block";
viewbox.style("cursor", "crosshair").on("click", placeLinearFeature);
+ } else if (button.id === "brushFill") {
+ byId("brushesSliders").style.display = "block";
+ viewbox.style("cursor", "crosshair").on("click", applyFillBrush);
} else {
byId("brushesSliders").style.display = "block";
- viewbox.style("cursor", "crosshair").call(d3.drag().on("start", dragBrushThrottled));
+ viewbox.style("cursor", "crosshair").call(d3.drag().on("start", dragBrush));
}
}
+ function toggleFillBrushUi(isFillBrush) {
+ const radiusRow = byId("heightmapBrushRadius").parentElement;
+ if (radiusRow) radiusRow.style.display = isFillBrush ? "none" : "";
+ }
+
function placeLinearFeature() {
const [x, y] = d3.mouse(this);
const toCell = findGridCell(x, y, grid);
@@ -702,6 +717,106 @@ function editHeightmap(options) {
updateHistory();
}
+ function applyFillBrush() {
+ const [x, y] = d3.mouse(this);
+ const start = findGridCell(x, y, grid);
+ const startHeight = grid.cells.h[start];
+ const isWaterFill = startHeight < 20;
+ const MIN_FILL_CELLS = 3;
+
+ if (cellTypeFilter.value === "water")
+ return tip("Fill brush is not available with 'only water cells' filter", false, "error");
+ if (cellTypeFilter.value === "land" && isWaterFill)
+ return tip("Land filter is active, water areas cannot be filled", false, "error");
+
+ const {selection, reachedBorder} = collectFillSelection(start, isWaterFill, startHeight);
+ if (selection.length < MIN_FILL_CELLS) return tip("No enclosed area found to fill", false, "error");
+ if (isWaterFill && reachedBorder)
+ return tip("Selected water area is open to map border and is not enclosed", false, "error");
+
+ const changed = applyConeToSelection(selection, isWaterFill, startHeight);
+ if (!changed.length) return;
+
+ mockHeightmapSelection(changed);
+ updateHeightmap();
+ }
+
+ function collectFillSelection(start, isWaterFill, targetHeight) {
+ const {h: heights, c: neighbors, i: cells} = grid.cells;
+ const visited = new Uint8Array(cells.length);
+ const stack = [start];
+ const selection = [];
+ let reachedBorder = false;
+
+ while (stack.length) {
+ const cell = stack.pop();
+ if (visited[cell]) continue;
+ visited[cell] = 1;
+
+ if (!matchesFillTarget(heights[cell], isWaterFill, targetHeight)) continue;
+
+ selection.push(cell);
+ if (grid.cells.b[cell]) reachedBorder = true;
+ neighbors[cell].forEach(next => {
+ if (!visited[next]) stack.push(next);
+ });
+ }
+
+ return {selection, reachedBorder};
+ }
+
+ function matchesFillTarget(height, isWaterFill, targetHeight) {
+ return isWaterFill ? height < 20 : height === targetHeight;
+ }
+
+ function applyConeToSelection(selection, isWaterFill, targetHeight) {
+ const power = heightmapBrushPower.valueAsNumber * 10;
+ const {h: heights, c: neighbors, i: cells} = grid.cells;
+ const inSelection = new Uint8Array(cells.length);
+ const edgeDistance = new Uint16Array(cells.length);
+ const changed = [];
+
+ selection.forEach(cell => {
+ inSelection[cell] = 1;
+ });
+
+ // Multi-source BFS from area edge gives each cell distance from edge.
+ const queue = [];
+ let head = 0;
+ selection.forEach(cell => {
+ const isEdgeCell = neighbors[cell].some(next => !inSelection[next]);
+ if (!isEdgeCell) return;
+ inSelection[cell] = 2;
+ queue.push(cell);
+ });
+
+ while (head < queue.length) {
+ const cell = queue[head++];
+ const nextDistance = edgeDistance[cell] + 1;
+ neighbors[cell].forEach(next => {
+ if (inSelection[next] !== 1) return;
+ inSelection[next] = 2;
+ edgeDistance[next] = nextDistance;
+ queue.push(next);
+ });
+ }
+
+ const maxDistance = d3.max(selection, cell => edgeDistance[cell]) || 0;
+ const baseHeight = isWaterFill ? 20 : targetHeight;
+
+ selection.forEach(cell => {
+ const ratio = maxDistance ? edgeDistance[cell] / maxDistance : 1;
+ const rise = Math.max(1, Math.round(power * ratio));
+ const nextHeight = minmax(baseHeight + rise, 0, 100);
+ if (nextHeight === heights[cell]) return;
+
+ heights[cell] = nextHeight;
+ changed.push(cell);
+ });
+
+ return changed;
+ }
+
function dragBrush() {
const r = heightmapBrushRadius.valueAsNumber;
const [x, y] = d3.mouse(this);
diff --git a/public/modules/ui/hotkeys.js b/public/modules/ui/hotkeys.js
index 8dd54f022..f1722e0ac 100644
--- a/public/modules/ui/hotkeys.js
+++ b/public/modules/ui/hotkeys.js
@@ -6,8 +6,8 @@ document.addEventListener("keyup", handleKeyup);
function handleKeydown(event) {
if (!allowHotkeys()) return; // in some cases (e.g. in a textarea) hotkeys are not allowed
- const {code, ctrlKey, altKey} = event;
- if (altKey && !ctrlKey) event.preventDefault(); // disallow alt key combinations
+ const {code, ctrlKey, altKey, shiftKey} = event;
+ if (altKey && !ctrlKey && !shiftKey) event.preventDefault(); // disallow plain alt key combinations
if (ctrlKey && ["KeyS", "KeyC"].includes(code)) event.preventDefault(); // disallow CTRL + S and CTRL + C
if (["F1", "F2", "F6", "F9", "Tab"].includes(code)) event.preventDefault(); // disallow default Fn and Tab
}
@@ -18,9 +18,10 @@ function handleKeyup(event) {
event.stopPropagation();
- const {code, key, ctrlKey, metaKey, shiftKey} = event;
+ const {code, key, ctrlKey, metaKey, shiftKey, altKey} = event;
const ctrl = ctrlKey || metaKey || key === "Control";
- const shift = shiftKey || key === "Shift";
+ const shift = (shiftKey || key === "Shift") && !altKey;
+ const altShift = altKey && (shiftKey || key === "Shift") && !ctrl;
if (code === "F1") showInfo();
else if (code === "F2") regeneratePrompt();
@@ -35,25 +36,25 @@ function handleKeyup(event) {
else if (ctrl && code === "KeyC") saveMap("dropbox");
else if (ctrl && code === "KeyZ" && undo?.offsetParent) undo.click();
else if (ctrl && code === "KeyY" && redo?.offsetParent) redo.click();
- else if (shift && code === "KeyH") editHeightmap();
- else if (shift && code === "KeyB") editBiomes();
- else if (shift && code === "KeyS") editStates();
- else if (shift && code === "KeyP") editProvinces();
- else if (shift && code === "KeyD") editDiplomacy();
- else if (shift && code === "KeyC") editCultures();
- else if (shift && code === "KeyN") editNamesbase();
- else if (shift && code === "KeyZ") editZones();
- else if (shift && code === "KeyR") editReligions();
- else if (shift && code === "KeyY") openEmblemEditor();
- else if (shift && code === "KeyQ") editUnits();
- else if (shift && code === "KeyO") editNotes();
- else if (shift && code === "KeyA") overviewCharts();
- else if (shift && code === "KeyT") overviewBurgs();
- else if (shift && code === "KeyU") overviewRoutes();
- else if (shift && code === "KeyV") overviewRivers();
- else if (shift && code === "KeyM") overviewMilitary();
- else if (shift && code === "KeyK") overviewMarkers();
- else if (shift && code === "KeyE") viewCellDetails();
+ else if ((shift || altShift) && code === "KeyH") editHeightmap();
+ else if ((shift || altShift) && code === "KeyB") editBiomes();
+ else if ((shift || altShift) && code === "KeyS") editStates();
+ else if ((shift || altShift) && code === "KeyP") editProvinces();
+ else if ((shift || altShift) && code === "KeyD") editDiplomacy();
+ else if ((shift || altShift) && code === "KeyC") editCultures();
+ else if ((shift || altShift) && code === "KeyN") editNamesbase();
+ else if ((shift || altShift) && code === "KeyZ") editZones();
+ else if ((shift || altShift) && code === "KeyR") editReligions();
+ else if ((shift || altShift) && code === "KeyY") openEmblemEditor();
+ else if ((shift || altShift) && code === "KeyQ") editUnits();
+ else if ((shift || altShift) && code === "KeyO") editNotes();
+ else if ((shift || altShift) && code === "KeyA") overviewCharts();
+ else if ((shift || altShift) && code === "KeyT") overviewBurgs();
+ else if ((shift || altShift) && code === "KeyU") overviewRoutes();
+ else if ((shift || altShift) && code === "KeyV") overviewRivers();
+ else if ((shift || altShift) && code === "KeyM") overviewMilitary();
+ else if ((shift || altShift) && code === "KeyK") overviewMarkers();
+ else if ((shift || altShift) && code === "KeyE") viewCellDetails();
else if (key === "!") toggleAddBurg();
else if (key === "@") toggleAddLabel();
else if (key === "#") toggleAddRiver();
@@ -87,7 +88,8 @@ function handleKeyup(event) {
else if (code === "KeyK") toggleMarkers();
else if (code === "Equal" && !customization) toggleRulers();
else if (code === "Slash") toggleScaleBar();
- else if (code === "BracketLeft") toggleVignette();
+ else if (code === "BracketLeft" && !handleBracketSizeChange(code)) toggleVignette();
+ else if (code === "BracketRight") handleBracketSizeChange(code);
else if (code === "ArrowLeft") zoom.translateBy(svg, 10, 0);
else if (code === "ArrowRight") zoom.translateBy(svg, -10, 0);
else if (code === "ArrowUp") zoom.translateBy(svg, 0, 10);
@@ -119,6 +121,7 @@ function handleSizeChange(key) {
let brush = null;
if (byId("heightmapBrushRadius")?.offsetParent) brush = byId("heightmapBrushRadius");
+ else if (byId("heightmapBrushPower")?.offsetParent) brush = byId("heightmapBrushPower");
else if (byId("heightmapLinePower")?.offsetParent) brush = byId("heightmapLinePower");
else if (byId("biomesBrush")?.offsetParent) brush = byId("biomesBrush");
else if (byId("culturesBrush")?.offsetParent) brush = byId("culturesBrush");
@@ -140,6 +143,26 @@ function handleSizeChange(key) {
zoom.scaleBy(svg, scaleBy); // if no brush elements displayed, zoom map
}
+function handleBracketSizeChange(code) {
+ const isHeightmapBrushPressed = Boolean(byId("brushesButtons")?.querySelector("button.pressed"));
+ const hasActiveBrush =
+ isHeightmapBrushPressed ||
+ byId("heightmapBrushRadius")?.offsetParent ||
+ byId("heightmapBrushPower")?.offsetParent ||
+ byId("heightmapLinePower")?.offsetParent ||
+ byId("biomesBrush")?.offsetParent ||
+ byId("culturesBrush")?.offsetParent ||
+ byId("statesBrush")?.offsetParent ||
+ byId("provincesBrush")?.offsetParent ||
+ byId("religionsBrush")?.offsetParent ||
+ byId("zonesBrush")?.offsetParent;
+
+ if (!hasActiveBrush) return false;
+
+ handleSizeChange(code === "BracketLeft" ? "-" : "+");
+ return true;
+}
+
function toggleMode() {
if (zonesRemove?.offsetParent) {
zonesRemove.classList.contains("pressed")
diff --git a/public/modules/ui/options.js b/public/modules/ui/options.js
index 5935ce0bb..d9f58e816 100644
--- a/public/modules/ui/options.js
+++ b/public/modules/ui/options.js
@@ -727,7 +727,7 @@ function regeneratePrompt(options) {
if (customization)
return tip("New map cannot be generated when edit mode is active, please exit the mode and retry", false, "error");
const workingTime = (Date.now() - last(mapHistory).created) / 60000; // minutes
- if (workingTime < 5) return regenerateMap(options);
+ if (workingTime < 1) return regenerateMap(options);
alertMessage.innerHTML = /* html */ `Are you sure you want to generate a new map?
All unsaved changes made to the current map will be lost`;
diff --git a/src/index.html b/src/index.html
index 333a0d591..ff99719b9 100644
--- a/src/index.html
+++ b/src/index.html
@@ -1761,7 +1761,9 @@
-
+
|
|
@@ -4173,6 +4175,16 @@
+
+