Fantasy-Map-Generator/modules/ui/editors.js
barrulus c4663e7b9b fix: sky port behavior, default sky altitude, Sky Realm robustness, and unfly state restore
Summary

Ground burgs with sky ports keep land capabilities and land routes.
Only truly flying burgs join the Sky Realm; turning off “flying” restores the ground state.
Replace magic altitude 1000 with a DOM-configurable default.
Guard state hover/pin logic for geometry-less Sky Realm to prevent console errors.
Watabou link uses sky settings only for flying burgs.
Details

Land routing with sky ports:

Change: include sky ports in land road/trail networks; exclude only flying burgs.
Why: sky ports should not impose flying/land restrictions on a ground burg.
File: modules/routes-generator.js
Sky port toggle does not move state:

Change: toggling skyPort no longer assigns the burg’s state to the Sky Realm or reassigns cell ownership.
Why: sky port ≠ flying city; it’s just air connectivity.
File: modules/ui/burg-editor.js
Flying toggle and state restore:

Change: enabling “flying” assigns the burg to the Sky Realm but does not overwrite the cell’s ground state; disabling “flying” restores the burg’s state to the underlying ground state (pack.cells.state[burg.cell]).
Change: when relocating, assign Sky Realm only if the burg is placed over water; ground placements keep ground state.
Why: fixes bug where reverting to ground left burg in Sky Realm and preserves ground sovereignty.
File: modules/ui/burg-editor.js
Default sky altitude via DOM (no more magic 1000):

Change: add hidden input #burgDefaultSkyAltitude (default 1000 m) and read it everywhere altitude is defaulted (editor and “add burg on water” flow).
Why: centralizes default, documents units, and makes it configurable without touching code.
Files: index.html, modules/ui/burg-editor.js, modules/ui/burgs-overview.js
Sky State creation robustness:

Change: initialize a neutral diplomacy row for the new Sky State and extend existing states’ diplomacy arrays.
Change: do not forcibly set the anchor cell ownership to Sky state when creating the Sky Realm.
Why: prevents UI/editor crashes that assume diplomacy is rectangular; avoids accidental land transfer to Sky Realm.
File: modules/ui/editors.js
State hover/pin guards for Sky Realm:

Change: skip fog/hover/highlight when the target state has no geometry (e.g., Sky Realm) or the path is missing.
Why: fixes console errors in State hover and Pin feature.
Files: modules/dynamic/editors/states-editor.js, modules/ui/diplomacy-editor.js, modules/ui/military-overview.js
Watabou integration:

Change: only treat burgs as “sky” for city links when flying is true (not when only skyPort is set).
Why: ground burgs with sky ports should still have gates/roads/hubs/shanty, etc.
File: modules/ui/editors.js
User-visible Effects

Ground cities can have sea port + sky port + roads/trails simultaneously.
Toggling flying on shows altitude row; default altitude is read from the DOM.
Toggling flying off restores the burg to its ground state affiliation.
No more console errors when interacting with state hover/pin around the Sky Realm.
Potential Impacts

Land route generation may produce additional roads because sky-ported (but ground) burgs are now included.
Diplomacy arrays gain a neutral column for the Sky Realm when first created (non-breaking, UI-aligned).
Test Plan

Set sky port on a ground burg: verify roads/trails remain; sea port and other features unaffected.
Toggle flying on: burg state switches to Sky Realm; cell remains with ground state; altitude defaults from #burgDefaultSkyAltitude; air routes generate.
Toggle flying off: burg state returns to the ground state; altitude row hides; routes regenerate.
Hover/fog/pin states with the editors open: no errors with Sky Realm selected or hovered.
Open Watabou (city generator) links:
Ground + sky port: uses ground settings (gates/roads/hubs/shanty allowed).
Flying: uses sky settings (no farms/gates/hub etc. as appropriate).
Files Changed

modules/routes-generator.js
modules/ui/burg-editor.js
modules/ui/burgs-overview.js
modules/ui/editors.js
modules/dynamic/editors/states-editor.js
modules/ui/diplomacy-editor.js
modules/ui/military-overview.js
index.html
2025-09-08 15:09:59 +01:00

1383 lines
40 KiB
JavaScript

// module stub to store common functions for ui editors
"use strict";
modules.editors = true;
// restore default viewbox events
function restoreDefaultEvents() {
svg.call(zoom);
viewbox.style("cursor", "default").on(".drag", null).on("click", clicked).on("touchmove mousemove", onMouseMove);
legend.call(d3.drag().on("start", dragLegendBox));
svg.call(zoom);
}
// handle viewbox click
function clicked() {
const el = d3.event.target;
const parent = el?.parentElement;
const grand = parent?.parentElement;
const great = grand?.parentElement;
const ancestor = great?.parentElement;
if (!ancestor) return;
if (grand.id === "emblems") editEmblem();
else if (parent.id === "rivers") editRiver(el.id);
else if (grand.id === "routes") editRoute(el.id);
else if (ancestor.id === "labels" && el.tagName === "tspan") editLabel();
else if (grand.id === "burgLabels") editBurg();
else if (grand.id === "burgIcons") editBurg();
else if (parent.id === "ice") editIce();
else if (parent.id === "terrain") editReliefIcon();
else if (grand.id === "markers" || great.id === "markers") editMarker();
else if (grand.id === "coastline") editCoastline();
else if (grand.id === "lakes") editLake();
else if (great.id === "armies") editRegiment();
}
// clear elSelected variable
function unselect() {
restoreDefaultEvents();
if (!elSelected) return;
elSelected.call(d3.drag().on("drag", null)).attr("class", null);
debug.selectAll("*").remove();
viewbox.style("cursor", "default");
elSelected = null;
}
// close all dialogs except stated
function closeDialogs(except = "#except") {
try {
$(".dialog:visible")
.not(except)
.each(function () {
$(this).dialog("close");
});
} catch (error) {}
}
// move brush radius circle
function moveCircle(x, y, r = 20) {
let circle = byId("brushCircle");
if (!circle) {
const html = /* html */ `<circle id="brushCircle" cx=${x} cy=${y} r=${r}></circle>`;
byId("debug").insertAdjacentHTML("afterBegin", html);
} else {
circle.setAttribute("cx", x);
circle.setAttribute("cy", y);
circle.setAttribute("r", r);
}
}
function removeCircle() {
if (byId("brushCircle")) byId("brushCircle").remove();
}
// get browser-defined fit-content
function fitContent() {
return !window.chrome ? "-moz-max-content" : "fit-content";
}
// apply sorting behaviour for lines on Editor header click
document.querySelectorAll(".sortable").forEach(function (event) {
event.on("click", function () {
sortLines(this);
});
});
function applySortingByHeader(headerContainer) {
document
.getElementById(headerContainer)
.querySelectorAll(".sortable")
.forEach(function (element) {
element.on("click", function () {
sortLines(this);
});
});
}
function sortLines(headerElement) {
const type = headerElement.classList.contains("alphabetically") ? "name" : "number";
let order = headerElement.className.includes("-down") ? "-up" : "-down";
if (!headerElement.className.includes("icon-sort") && type === "name") order = "-up";
const headers = headerElement.parentNode;
headers.querySelectorAll("div.sortable").forEach(e => {
e.classList.forEach(c => {
if (c.includes("icon-sort")) e.classList.remove(c);
});
});
headerElement.classList.add("icon-sort-" + type + order);
applySorting(headers);
}
function applySorting(headers) {
const header = headers.querySelector("div[class*='icon-sort']");
if (!header) return;
const sortby = header.dataset.sortby;
const name = header.classList.contains("alphabetically");
const desc = header.className.includes("-down") ? -1 : 1;
const list = headers.nextElementSibling;
const lines = Array.from(list.children);
lines
.sort((a, b) => {
const an = name ? a.dataset[sortby] : +a.dataset[sortby];
const bn = name ? b.dataset[sortby] : +b.dataset[sortby];
return (an > bn ? 1 : an < bn ? -1 : 0) * desc;
})
.forEach(line => list.appendChild(line));
}
function addBurg(point) {
const {cells, states} = pack;
const x = rn(point[0], 2);
const y = rn(point[1], 2);
const cellId = findCell(x, y);
const i = pack.burgs.length;
const culture = cells.culture[cellId];
const name = Names.getCulture(culture);
const state = cells.state[cellId];
const feature = cells.f[cellId];
const population = Math.max(cells.s[cellId] / 3 + i / 1000 + (cellId % 100) / 1000, 0.1);
const type = BurgsAndStates.getType(cellId, false);
// generate emblem
const coa = COA.generate(states[state].coa, 0.25, null, type);
coa.shield = COA.getShield(culture, state);
COArenderer.add("burg", i, coa, x, y);
const burg = {
name,
cell: cellId,
x,
y,
state,
i,
culture,
feature,
capital: 0,
port: 0,
temple: 0,
population,
coa,
type
};
pack.burgs.push(burg);
cells.burg[cellId] = i;
const townSize = burgIcons.select("#towns").attr("size") || 0.5;
burgIcons
.select("#towns")
.append("circle")
.attr("id", "burg" + i)
.attr("data-id", i)
.attr("cx", x)
.attr("cy", y)
.attr("r", townSize);
burgLabels
.select("#towns")
.append("text")
.attr("text-rendering", "optimizeSpeed")
.attr("id", "burgLabel" + i)
.attr("data-id", i)
.attr("x", x)
.attr("y", y)
.attr("dy", `${townSize * -1.5}px`)
.text(name);
BurgsAndStates.defineBurgFeatures(burg);
// Do not auto-connect routes for water-placed (flying) burgs
const isWater = cells.h[cellId] < 20;
const newRoute = isWater ? null : Routes.connect(cellId);
if (newRoute && layerIsOn("toggleRoutes")) {
routes
.select("#" + newRoute.group)
.append("path")
.attr("d", Routes.getPath(newRoute))
.attr("id", "route" + newRoute.i);
}
return i;
}
function moveBurgToGroup(id, g) {
const label = document.querySelector("#burgLabels [data-id='" + id + "']");
const icon = document.querySelector("#burgIcons [data-id='" + id + "']");
const anchor = document.querySelector("#anchors [data-id='" + id + "']");
if (!label || !icon) {
ERROR && console.error(`Cannot find label or icon elements for id ${id}`);
return;
}
document.querySelector("#burgLabels > #" + g).appendChild(label);
document.querySelector("#burgIcons > #" + g).appendChild(icon);
const iconSize = icon.parentNode.getAttribute("size");
icon.setAttribute("r", iconSize);
label.setAttribute("dy", `${iconSize * -1.5}px`);
if (anchor) {
document.querySelector("#anchors > #" + g).appendChild(anchor);
const anchorSize = +anchor.parentNode.getAttribute("size");
anchor.setAttribute("width", anchorSize);
anchor.setAttribute("height", anchorSize);
anchor.setAttribute("x", rn(pack.burgs[id].x - anchorSize * 0.47, 2));
anchor.setAttribute("y", rn(pack.burgs[id].y - anchorSize * 0.47, 2));
}
}
// Ensure a dedicated locked Sky State exists; create if missing and return its id
function ensureSkyState(anchorBurgId) {
const {states, burgs, cultures, cells} = pack;
// Reuse existing sky state if present
let sky = states.find(s => s && s.i && !s.removed && s.skyRealm);
if (sky) return sky.i;
// Create a new locked Sky State
const b = burgs[anchorBurgId];
const i = states.length;
const culture = (b && b.culture) || cells.culture?.[b?.cell] || 1;
const type = "Generic";
const name = "Sky Realm";
const color = "#5b8bd4";
const coa = COA.generate(null, null, null, cultures[culture]?.type || "Generic");
coa.shield = COA.getShield(culture, null);
const newState = {
i,
name,
type,
color,
capital: anchorBurgId,
center: b.cell,
culture,
expansionism: 0.1,
coa,
lock: 1,
skyRealm: 1
};
// initialize minimal diplomacy for the new Sky State
try {
const diplomacy = states.map(s => (s && s.i && !s.removed ? "Neutral" : "x"));
diplomacy[i] = "x";
newState.diplomacy = diplomacy;
// extend existing states' diplomacy; skip state 0 (chronicle holder)
for (const s of states) {
if (s && s.i && !s.removed && Array.isArray(s.diplomacy)) s.diplomacy.push("Neutral");
}
} catch (err) {
// fail-safe: do nothing if diplomacy cannot be set up
}
states.push(newState);
// Do not assign the cell itself to Sky state; keep underlying ground state mapping
if (b) {
b.state = i;
b.capital = 1;
// Move to cities layer for capitals
moveBurgToGroup(anchorBurgId, "cities");
}
return i;
}
function moveAllBurgsToGroup(fromGroup, toGroup) {
const groupToMove = document.querySelector(`#burgIcons #${fromGroup}`);
const burgsToMove = Array.from(groupToMove.children).map(x => x.dataset.id);
addBurgsGroup(toGroup);
burgsToMove.forEach(x => moveBurgToGroup(x, toGroup));
}
function addBurgsGroup(group) {
if (document.querySelector(`#burgLabels > #${group}`)) return;
const labelCopy = document.querySelector("#burgLabels > #towns").cloneNode(false);
const iconCopy = document.querySelector("#burgIcons > #towns").cloneNode(false);
const anchorCopy = document.querySelector("#anchors > #towns").cloneNode(false);
// FIXME: using the same id is against the spec!
document.querySelector("#burgLabels").appendChild(labelCopy).id = group;
document.querySelector("#burgIcons").appendChild(iconCopy).id = group;
document.querySelector("#anchors").appendChild(anchorCopy).id = group;
}
function removeBurg(id) {
document.querySelector("#burgLabels [data-id='" + id + "']")?.remove();
document.querySelector("#burgIcons [data-id='" + id + "']")?.remove();
document.querySelector("#anchors [data-id='" + id + "']")?.remove();
const cells = pack.cells;
const burg = pack.burgs[id];
burg.removed = true;
cells.burg[burg.cell] = 0;
const noteId = notes.findIndex(note => note.id === `burg${id}`);
if (noteId !== -1) notes.splice(noteId, 1);
if (burg.coa) {
const coaId = "burgCOA" + id;
if (byId(coaId)) byId(coaId).remove();
emblems.select(`#burgEmblems > use[data-i='${id}']`).remove();
delete burg.coa; // remove to save data
}
}
function toggleCapital(burgId) {
const {burgs, states} = pack;
if (burgs[burgId].capital)
return tip("To change capital please assign a capital status to another burg of this state", false, "error");
const stateId = burgs[burgId].state;
if (!stateId) return tip("Neutral lands cannot have a capital", false, "error");
const prevCapitalId = states[stateId].capital;
states[stateId].capital = burgId;
states[stateId].center = burgs[burgId].cell;
burgs[burgId].capital = 1;
burgs[prevCapitalId].capital = 0;
moveBurgToGroup(burgId, "cities");
moveBurgToGroup(prevCapitalId, "towns");
}
function togglePort(burg) {
const anchor = document.querySelector("#anchors [data-id='" + burg + "']");
if (anchor) anchor.remove();
const b = pack.burgs[burg];
if (b.port) {
b.port = 0;
return;
} // not a port anymore
const haven = pack.cells.haven[b.cell];
const port = haven ? pack.cells.f[haven] : -1;
if (!haven) tip("Port haven is not found, system won't be able to make a searoute", false, "warn");
b.port = port;
const g = b.capital ? "cities" : "towns";
const group = anchors.select("g#" + g);
const size = +group.attr("size");
group
.append("use")
.attr("xlink:href", "#icon-anchor")
.attr("data-id", burg)
.attr("x", rn(b.x - size * 0.47, 2))
.attr("y", rn(b.y - size * 0.47, 2))
.attr("width", size)
.attr("height", size);
}
function getBurgLink(burg) {
if (burg.link) return burg.link;
// Sky-only: use sky generator options only for truly flying burgs
if (burg.flying) return createMfcgLink(burg, true);
const population = burg.population * populationRate * urbanization;
if (population >= options.villageMaxPopulation || burg.citadel || burg.walls || burg.temple || burg.shanty)
return createMfcgLink(burg);
return createVillageGeneratorLink(burg);
}
function createMfcgLink(burg, isSky = false) {
const {cells} = pack;
const {i, name, population: burgPopulation, cell} = burg;
const burgSeed = burg.MFCG || seed + String(burg.i).padStart(4, 0);
const sizeRaw = 2.13 * Math.pow((burgPopulation * populationRate) / urbanDensity, 0.385);
const size = isSky ? 25 : minmax(Math.ceil(sizeRaw), 6, 100);
const population = rn(burgPopulation * populationRate * urbanization);
const river = isSky ? 0 : (cells.r[cell] ? 1 : 0);
const coast = isSky ? 0 : Number(burg.port > 0);
const sea = !isSky && coast && cells.haven[cell]
? (() => {
// calculate sea direction: 0 = south, 0.5 = west, 1 = north, 1.5 = east
const p1 = cells.p[cell];
const p2 = cells.p[cells.haven[cell]];
let deg = (Math.atan2(p2[1] - p1[1], p2[0] - p1[0]) * 180) / Math.PI - 90;
if (deg < 0) deg += 360;
return rn(normalize(deg, 0, 360) * 2, 2);
})()
: null;
const arableBiomes = river ? [1, 2, 3, 4, 5, 6, 7, 8] : [5, 6, 7, 8];
const farms = isSky ? 0 : +arableBiomes.includes(cells.biome[cell]);
const citadel = isSky ? 1 : +burg.citadel;
const urban_castle = isSky ? 1 : +(citadel && each(2)(i));
const hub = isSky ? 0 : Routes.isCrossroad(cell);
const walls = isSky ? 1 : +burg.walls;
const plaza = isSky ? 1 : +burg.plaza;
const temple = isSky ? 1 : +burg.temple;
const shantytown = isSky ? 0 : +burg.shanty;
const greens = isSky ? 1 : undefined;
const gates = isSky ? 0 : -1;
const url = new URL("https://watabou.github.io/city-generator/");
const params = {
name,
population,
size,
seed: burgSeed,
river,
coast,
farms,
citadel,
urban_castle,
hub,
plaza,
temple,
walls,
shantytown
};
if (greens !== undefined) params.greens = greens;
if (gates !== undefined) params.gates = gates;
url.search = new URLSearchParams(params);
if (sea) url.searchParams.append("sea", sea);
return url.toString();
}
function createVillageGeneratorLink(burg) {
const {cells, features} = pack;
const {i, population, cell} = burg;
const pop = rn(population * populationRate * urbanization);
const burgSeed = seed + String(i).padStart(4, 0);
const tags = [];
if (cells.r[cell] && cells.haven[cell]) tags.push("estuary");
else if (cells.haven[cell] && features[cells.f[cell]].cells === 1) tags.push("island,district");
else if (burg.port) tags.push("coast");
else if (cells.conf[cell]) tags.push("confluence");
else if (cells.r[cell]) tags.push("river");
else if (pop < 200 && each(4)(cell)) tags.push("pond");
const roadsNumber = Object.values(pack.cells.routes[cell] || {}).filter(routeId => {
const route = pack.routes.find(route => route.i === routeId);
if (!route) return false;
return route.group === "roads" || route.group === "trails";
}).length;
tags.push(roadsNumber > 1 ? "highway" : roadsNumber === 1 ? "dead end" : "isolated");
const biome = cells.biome[cell];
const arableBiomes = cells.r[cell] ? [1, 2, 3, 4, 5, 6, 7, 8] : [5, 6, 7, 8];
if (!arableBiomes.includes(biome)) tags.push("uncultivated");
else if (each(6)(cell)) tags.push("farmland");
const temp = grid.cells.temp[cells.g[cell]];
if (temp <= 0 || temp > 28 || (temp > 25 && each(3)(cell))) tags.push("no orchards");
if (!burg.plaza) tags.push("no square");
if (pop < 100) tags.push("sparse");
else if (pop > 300) tags.push("dense");
const width = (() => {
if (pop > 1500) return 1600;
if (pop > 1000) return 1400;
if (pop > 500) return 1000;
if (pop > 200) return 800;
if (pop > 100) return 600;
return 400;
})();
const height = rn(width / 2.2);
const url = new URL("https://watabou.github.io/village-generator/");
url.search = new URLSearchParams({pop, name: "", seed: burgSeed, width, height, tags});
return url.toString();
}
// helper: draw legend entry for Air routes
function drawAirRoutesLegend() {
const group = document.querySelector("#airroutes");
if (!group) return tip("Air routes group not found", false, "error");
const stroke = group.getAttribute("stroke") || "#8a2be2";
const data = [["airroutes", stroke, "Air routes"]];
drawLegend("Routes", data);
}
// draw legend box
function drawLegend(name, data) {
legend.selectAll("*").remove(); // fully redraw every time
legend.attr("data", data.join("|")); // store data
const itemsInCol = +styleLegendColItems.value;
const fontSize = +legend.attr("font-size");
const backClr = styleLegendBack.value;
const opacity = +styleLegendOpacity.value;
const lineHeight = Math.round(fontSize * 1.7);
const colorBoxSize = Math.round(fontSize / 1.7);
const colOffset = fontSize;
const vOffset = fontSize / 2;
// append items
const boxes = legend.append("g").attr("stroke-width", 0.5).attr("stroke", "#111111").attr("stroke-dasharray", "none");
const labels = legend.append("g").attr("fill", "#000000").attr("stroke", "none");
const columns = Math.ceil(data.length / itemsInCol);
for (let column = 0, i = 0; column < columns; column++) {
const linesInColumn = Math.ceil(data.length / columns);
const offset = column ? colOffset * 2 + legend.node().getBBox().width : colOffset;
for (let l = 0; l < linesInColumn && data[i]; l++, i++) {
boxes
.append("rect")
.attr("fill", data[i][1])
.attr("x", offset)
.attr("y", lineHeight + l * lineHeight + vOffset)
.attr("width", colorBoxSize)
.attr("height", colorBoxSize);
labels
.append("text")
.attr("text-rendering", "optimizeSpeed")
.text(data[i][2])
.attr("x", offset + colorBoxSize * 1.6)
.attr("y", fontSize / 1.6 + lineHeight + l * lineHeight + vOffset);
}
}
// append label
const offset = colOffset + legend.node().getBBox().width / 2;
labels
.append("text")
.attr("text-rendering", "optimizeSpeed")
.attr("text-anchor", "middle")
.attr("font-weight", "bold")
.attr("font-size", "1.2em")
.attr("id", "legendLabel")
.text(name)
.attr("x", offset)
.attr("y", fontSize * 1.1 + vOffset / 2);
// append box
const bbox = legend.node().getBBox();
const width = bbox.width + colOffset * 2;
const height = bbox.height + colOffset / 2 + vOffset;
legend
.insert("rect", ":first-child")
.attr("id", "legendBox")
.attr("x", 0)
.attr("y", 0)
.attr("width", width)
.attr("height", height)
.attr("fill", backClr)
.attr("fill-opacity", opacity);
fitLegendBox();
}
// fit Legend box to canvas size
function fitLegendBox() {
if (!legend.selectAll("*").size()) return;
const px = isNaN(+legend.attr("data-x")) ? 99 : legend.attr("data-x") / 100;
const py = isNaN(+legend.attr("data-y")) ? 93 : legend.attr("data-y") / 100;
const bbox = legend.node().getBBox();
const x = rn(svgWidth * px - bbox.width),
y = rn(svgHeight * py - bbox.height);
legend.attr("transform", `translate(${x},${y})`);
}
// draw legend with the same data, but using different settings
function redrawLegend() {
if (legend.select("rect").size()) {
const name = legend.select("#legendLabel").text();
const data = legend
.attr("data")
.split("|")
.map(l => l.split(","));
drawLegend(name, data);
}
}
function dragLegendBox() {
const tr = parseTransform(this.getAttribute("transform"));
const x = +tr[0] - d3.event.x,
y = +tr[1] - d3.event.y;
const bbox = legend.node().getBBox();
d3.event.on("drag", function () {
const px = rn(((x + d3.event.x + bbox.width) / svgWidth) * 100, 2);
const py = rn(((y + d3.event.y + bbox.height) / svgHeight) * 100, 2);
const transform = `translate(${x + d3.event.x},${y + d3.event.y})`;
legend.attr("transform", transform).attr("data-x", px).attr("data-y", py);
});
}
function clearLegend() {
legend.selectAll("*").remove();
legend.attr("data", null);
}
// draw color (fill) picker
function createPicker() {
const pos = () => tip("Drag to change the picker position");
const cl = () => tip("Click to close the picker");
const closePicker = () => container.style("display", "none");
const container = d3
.select("body")
.append("svg")
.attr("id", "pickerContainer")
.attr("width", "100%")
.attr("height", "100%");
container
.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", "100%")
.attr("height", "100%")
.attr("opacity", 0.2)
.on("mousemove", cl)
.on("click", closePicker);
const picker = container
.append("g")
.attr("id", "picker")
.call(
d3
.drag()
.filter(() => event.target.tagName !== "INPUT")
.on("start", dragPicker)
);
const controls = picker.append("g").attr("id", "pickerControls");
const h = controls.append("g");
h.append("text").attr("x", 4).attr("y", 14).text("H:");
h.append("line").attr("x1", 18).attr("y1", 10).attr("x2", 107).attr("y2", 10);
h.append("circle").attr("cx", 75).attr("cy", 10).attr("r", 5).attr("id", "pickerH");
h.on("mousemove", () => tip("Set palette hue"));
const s = controls.append("g");
s.append("text").attr("x", 113).attr("y", 14).text("S:");
s.append("line").attr("x1", 124).attr("y1", 10).attr("x2", 206).attr("y2", 10);
s.append("circle").attr("cx", 181.4).attr("cy", 10).attr("r", 5).attr("id", "pickerS");
s.on("mousemove", () => tip("Set palette saturation"));
const l = controls.append("g");
l.append("text").attr("x", 213).attr("y", 14).text("L:");
l.append("line").attr("x1", 226).attr("y1", 10).attr("x2", 306).attr("y2", 10);
l.append("circle").attr("cx", 282).attr("cy", 10).attr("r", 5).attr("id", "pickerL");
l.on("mousemove", () => tip("Set palette lightness"));
controls.selectAll("line").on("click", clickPickerControl);
controls.selectAll("circle").call(d3.drag().on("start", dragPickerControl));
const spaces = picker
.append("foreignObject")
.attr("id", "pickerSpaces")
.attr("x", 4)
.attr("y", 20)
.attr("width", 303)
.attr("height", 20)
.on("mousemove", () => tip("Color value in different color spaces. Edit to change"));
const html = /* html */ ` <label style="margin-right: 6px"
>HSL: <input type="number" id="pickerHSL_H" data-space="hsl" min="0" max="360" value="231" />,
<input type="number" id="pickerHSL_S" data-space="hsl" min="0" max="100" value="70" />,
<input type="number" id="pickerHSL_L" data-space="hsl" min="0" max="100" value="70" />
</label>
<label style="margin-right: 6px"
>RGB: <input type="number" id="pickerRGB_R" data-space="rgb" min="0" max="255" value="125" />,
<input type="number" id="pickerRGB_G" data-space="rgb" min="0" max="255" value="142" />,
<input type="number" id="pickerRGB_B" data-space="rgb" min="0" max="255" value="232" />
</label>
<label>HEX: <input type="text" id="pickerHEX" data-space="hex" style="width:42px" autocorrect="off" spellcheck="false" value="#7d8ee8" /></label>`;
spaces.node().insertAdjacentHTML("beforeend", html);
spaces.selectAll("input").on("change", changePickerSpace);
const colors = picker.append("g").attr("id", "pickerColors").attr("stroke", "#333333");
const hatches = picker.append("g").attr("id", "pickerHatches").attr("stroke", "#333333");
const hatching = d3.selectAll("g#defs-hatching > pattern");
const number = hatching.size();
const clr = d3.range(number).map(i => d3.hsl((i / number) * 360, 0.7, 0.7).hex());
clr.forEach(function (d, i) {
colors
.append("rect")
.attr("id", "picker_" + d)
.attr("fill", d)
.attr("class", i ? "" : "selected")
.attr("x", (i % 14) * 22 + 4)
.attr("y", 40 + Math.floor(i / 14) * 20)
.attr("width", 16)
.attr("height", 16);
});
hatching.each(function (d, i) {
hatches
.append("rect")
.attr("id", "picker_" + this.id)
.attr("fill", "url(#" + this.id + ")")
.attr("x", (i % 14) * 22 + 4)
.attr("y", Math.floor(i / 14) * 20 + 20 + number * 2)
.attr("width", 16)
.attr("height", 16);
});
colors
.selectAll("rect")
.on("click", pickerFillClicked)
.on("mouseover", () => tip("Click to fill with the color"));
hatches
.selectAll("rect")
.on("click", pickerFillClicked)
.on("mouseover", function () {
tip("Click to fill with the hatching " + this.id);
});
// append box
const bbox = picker.node().getBBox();
const width = bbox.width + 8;
const height = bbox.height + 9;
picker
.insert("rect", ":first-child")
.attr("x", 0)
.attr("y", 0)
.attr("width", width)
.attr("height", height)
.attr("fill", "#ffffff")
.attr("stroke", "#5d4651")
.on("mousemove", pos);
picker
.insert("text", ":first-child")
.attr("x", width - 20)
.attr("y", -10)
.attr("id", "pickerCloseText")
.text("✕");
picker
.insert("rect", ":first-child")
.attr("x", width - 23)
.attr("y", -21)
.attr("id", "pickerCloseRect")
.attr("width", 14)
.attr("height", 14)
.on("mousemove", cl)
.on("click", closePicker);
picker
.insert("text", ":first-child")
.attr("x", 12)
.attr("y", -10)
.attr("id", "pickerLabel")
.text("Color Picker")
.on("mousemove", pos);
picker
.insert("rect", ":first-child")
.attr("x", 0)
.attr("y", -30)
.attr("width", width)
.attr("height", 30)
.attr("id", "pickerHeader")
.on("mousemove", pos);
picker.attr("transform", `translate(${(svgWidth - width) / 2},${(svgHeight - height) / 2})`);
}
function updateSelectedRect(fill) {
byId("picker").querySelector("rect.selected").classList.remove("selected");
document
.getElementById("picker")
.querySelector("rect[fill='" + fill.toLowerCase() + "']")
.classList.add("selected");
}
function updateSpaces() {
// hsl
const h = getPickerControl(pickerH, 360);
const s = getPickerControl(pickerS, 1);
const l = getPickerControl(pickerL, 1);
pickerHSL_H.value = rn(h);
pickerHSL_S.value = rn(s * 100); // multiplied by 100
pickerHSL_L.value = rn(l * 100); // multiplied by 100
// rgb
const rgb = d3.color(d3.hsl(h, s, l));
pickerRGB_R.value = rgb.r;
pickerRGB_G.value = rgb.g;
pickerRGB_B.value = rgb.b;
// hex
pickerHEX.value = rgb.hex();
}
function updatePickerColors() {
const colors = d3.select("#picker > #pickerColors").selectAll("rect");
const number = colors.size();
const h = getPickerControl(pickerH, 360);
const s = getPickerControl(pickerS, 1);
const l = getPickerControl(pickerL, 1);
colors.each(function (d, i) {
const clr = d3.hsl((i / number) * 180 + h, s, l).hex();
this.setAttribute("id", "picker_" + clr);
this.setAttribute("fill", clr);
});
}
function openPicker(fill, callback) {
const picker = d3.select("#picker");
if (!picker.size()) createPicker();
d3.select("#pickerContainer").style("display", "block");
if (fill[0] === "#") {
const hsl = d3.hsl(fill);
if (!isNaN(hsl.h)) setPickerControl(pickerH, hsl.h, 360);
if (!isNaN(hsl.s)) setPickerControl(pickerS, hsl.s, 1);
if (!isNaN(hsl.l)) setPickerControl(pickerL, hsl.l, 1);
updateSpaces();
updatePickerColors();
}
updateSelectedRect(fill);
openPicker.updateFill = function () {
const selected = byId("picker").querySelector("rect.selected");
if (!selected) return;
callback(selected.getAttribute("fill"));
};
}
function setPickerControl(control, value, max) {
const min = +control.previousSibling.getAttribute("x1");
const delta = +control.previousSibling.getAttribute("x2") - min;
const percent = value / max;
control.setAttribute("cx", min + delta * percent);
}
function getPickerControl(control, max) {
const min = +control.previousSibling.getAttribute("x1");
const delta = +control.previousSibling.getAttribute("x2") - min;
const current = +control.getAttribute("cx") - min;
return (current / delta) * max;
}
function dragPicker() {
const tr = parseTransform(this.getAttribute("transform"));
const x = +tr[0] - d3.event.x,
y = +tr[1] - d3.event.y;
const picker = d3.select("#picker");
const bbox = picker.node().getBBox();
d3.event.on("drag", function () {
const px = rn(((x + d3.event.x + bbox.width) / svgWidth) * 100, 2);
const py = rn(((y + d3.event.y + bbox.height) / svgHeight) * 100, 2);
const transform = `translate(${x + d3.event.x},${y + d3.event.y})`;
picker.attr("transform", transform).attr("data-x", px).attr("data-y", py);
});
}
function pickerFillClicked() {
const fill = this.getAttribute("fill");
updateSelectedRect(fill);
openPicker.updateFill();
const hsl = d3.hsl(fill);
if (isNaN(hsl.h)) return; // not a color
setPickerControl(pickerH, hsl.h, 360);
updateSpaces();
}
function clickPickerControl() {
const min = this.getScreenCTM().e;
this.nextSibling.setAttribute("cx", d3.event.x - min);
updateSpaces();
updatePickerColors();
openPicker.updateFill();
}
function dragPickerControl() {
const min = +this.previousSibling.getAttribute("x1");
const max = +this.previousSibling.getAttribute("x2");
d3.event.on("drag", function () {
const x = Math.max(Math.min(d3.event.x, max), min);
this.setAttribute("cx", x);
updateSpaces();
updatePickerColors();
openPicker.updateFill();
});
}
function changePickerSpace() {
const valid = this.checkValidity();
if (!valid) {
tip("You must provide a correct value", false, "error");
return;
}
const space = this.dataset.space;
const i = Array.from(this.parentNode.querySelectorAll("input")).map(input => input.value); // inputs
const fill =
space === "hex"
? d3.rgb(this.value)
: space === "rgb"
? d3.rgb(i[0], i[1], i[2])
: d3.hsl(i[0], i[1] / 100, i[2] / 100);
const hsl = d3.hsl(fill);
if (isNaN(hsl.l)) {
tip("You must provide a correct value", false, "error");
return;
}
if (!isNaN(hsl.h)) setPickerControl(pickerH, hsl.h, 360);
if (!isNaN(hsl.s)) setPickerControl(pickerS, hsl.s, 1);
if (!isNaN(hsl.l)) setPickerControl(pickerL, hsl.l, 1);
updateSpaces();
updatePickerColors();
openPicker.updateFill();
}
// add fogging
function fog(id, path) {
if (defs.select("#fog #" + id).size()) return;
const fadeIn = d3.transition().duration(2000).ease(d3.easeSinInOut);
if (defs.select("#fog path").size()) {
defs
.select("#fog")
.append("path")
.attr("d", path)
.attr("id", id)
.attr("opacity", 0)
.transition(fadeIn)
.attr("opacity", 1);
} else {
defs.select("#fog").append("path").attr("d", path).attr("id", id).attr("opacity", 1);
const opacity = fogging.attr("opacity");
fogging.style("display", "block").attr("opacity", 0).transition(fadeIn).attr("opacity", opacity);
}
}
// remove fogging
function unfog(id) {
let el = defs.select("#fog #" + id);
if (!id || !el.size()) el = defs.select("#fog").selectAll("path");
el.remove();
if (!defs.selectAll("#fog path").size()) fogging.style("display", "none");
}
function getFileName(dataType) {
const formatTime = time => (time < 10 ? "0" + time : time);
const name = mapName.value;
const type = dataType ? dataType + " " : "";
const date = new Date();
const year = date.getFullYear();
const month = formatTime(date.getMonth() + 1);
const day = formatTime(date.getDate());
const hour = formatTime(date.getHours());
const minutes = formatTime(date.getMinutes());
const dateString = [year, month, day, hour, minutes].join("-");
return name + " " + type + dateString;
}
function downloadFile(data, name, type = "text/plain") {
const dataBlob = new Blob([data], {type});
const url = window.URL.createObjectURL(dataBlob);
const link = document.createElement("a");
link.download = name;
link.href = url;
link.click();
window.setTimeout(() => window.URL.revokeObjectURL(url), 2000);
}
function uploadFile(el, callback) {
const fileReader = new FileReader();
fileReader.readAsText(el.files[0], "UTF-8");
el.value = "";
fileReader.onload = loaded => callback(loaded.target.result);
}
function getBBox(element) {
const x = +element.getAttribute("x");
const y = +element.getAttribute("y");
const width = +element.getAttribute("width");
const height = +element.getAttribute("height");
return {x, y, width, height};
}
function highlightElement(element, zoom) {
if (debug.select(".highlighted").size()) return; // allow only 1 highlight element simultaneously
const box = element.tagName === "svg" ? getBBox(element) : element.getBBox();
const transform = element.getAttribute("transform") || null;
const enter = d3.transition().duration(1000).ease(d3.easeBounceOut);
const exit = d3.transition().duration(500).ease(d3.easeLinear);
const highlight = debug
.append("rect")
.attr("x", box.x)
.attr("y", box.y)
.attr("width", box.width)
.attr("height", box.height);
highlight.classed("highlighted", 1).attr("transform", transform);
highlight
.transition(enter)
.style("outline-offset", "0px")
.transition(exit)
.style("outline-color", "transparent")
.delay(1000)
.remove();
if (zoom) {
const tr = parseTransform(transform);
let x = box.x + box.width / 2;
if (tr[0]) x += tr[0];
let y = box.y + box.height / 2;
if (tr[1]) y += tr[1];
zoomTo(x, y, scale > 2 ? scale : zoom, 1600);
}
}
function selectIcon(initial, callback) {
if (!callback) return;
$("#iconSelector").dialog();
const table = byId("iconTable");
const input = byId("iconInput");
input.value = initial;
if (!table.innerHTML) {
const icons = [
"⚔️",
"🏹",
"🐴",
"💣",
"🌊",
"🎯",
"⚓",
"🔮",
"📯",
"⚒️",
"🛡️",
"👑",
"⚜️",
"☠️",
"🎆",
"🗡️",
"🔪",
"⛏️",
"🔥",
"🩸",
"💧",
"🐾",
"🎪",
"🏰",
"🏯",
"⛓️",
"❤️",
"💘",
"💜",
"📜",
"🔔",
"🔱",
"💎",
"🌈",
"🌠",
"✨",
"💥",
"☀️",
"🌙",
"⚡",
"❄️",
"♨️",
"🎲",
"🚨",
"🌉",
"🗻",
"🌋",
"🧱",
"⚖️",
"✂️",
"🎵",
"👗",
"🎻",
"🎨",
"🎭",
"⛲",
"💉",
"📖",
"📕",
"🎁",
"💍",
"⏳",
"🕸️",
"⚗️",
"☣️",
"☢️",
"🔰",
"🎖️",
"🚩",
"🏳️",
"🏴",
"💪",
"✊",
"👊",
"🤜",
"🤝",
"🙏",
"🧙",
"🧙‍♀️",
"💂",
"🤴",
"🧛",
"🧟",
"🧞",
"🧝",
"👼",
"👻",
"👺",
"👹",
"🦄",
"🐲",
"🐉",
"🐎",
"🦓",
"🐺",
"🦊",
"🐱",
"🐈",
"🦁",
"🐯",
"🐅",
"🐆",
"🐕",
"🦌",
"🐵",
"🐒",
"🦍",
"🦅",
"🕊️",
"🐓",
"🦇",
"🦜",
"🐦",
"🦉",
"🐮",
"🐄",
"🐂",
"🐃",
"🐷",
"🐖",
"🐗",
"🐏",
"🐑",
"🐐",
"🐫",
"🦒",
"🐘",
"🦏",
"🐭",
"🐁",
"🐀",
"🐹",
"🐰",
"🐇",
"🦔",
"🐸",
"🐊",
"🐢",
"🦎",
"🐍",
"🐳",
"🐬",
"🦈",
"🐠",
"🐙",
"🦑",
"🐌",
"🦋",
"🐜",
"🐝",
"🐞",
"🦗",
"🕷️",
"🦂",
"🦀",
"🌳",
"🌲",
"🎄",
"🌴",
"🍂",
"🍁",
"🌵",
"☘️",
"🍀",
"🌿",
"🌱",
"🌾",
"🍄",
"🌽",
"🌸",
"🌹",
"🌻",
"🍒",
"🍏",
"🍇",
"🍉",
"🍅",
"🍓",
"🥔",
"🥕",
"🥩",
"🍗",
"🍞",
"🍻",
"🍺",
"🍲",
"🍷"
];
let row = "";
for (let i = 0; i < icons.length; i++) {
if (i % 17 === 0) row = table.insertRow((i / 17) | 0);
const cell = row.insertCell(i % 17);
cell.innerHTML = icons[i];
}
// find external images used as icons and show them
const externalResources = new Set();
const isExternal = url => url.startsWith("http") || url.startsWith("data:image");
options.military.forEach(unit => {
if (isExternal(unit.icon)) externalResources.add(unit.icon);
});
pack.states.forEach(state => {
state?.military?.forEach(regiment => {
if (isExternal(regiment.icon)) externalResources.add(regiment.icon);
});
});
externalResources.forEach(addExternalImage);
}
input.oninput = () => callback(input.value);
table.onclick = e => {
if (e.target.tagName === "TD") {
input.value = e.target.textContent;
callback(input.value);
}
};
table.onmouseover = e => {
if (e.target.tagName === "TD") tip(`Click to select ${e.target.textContent} icon`);
};
function addExternalImage(url) {
const addedIcons = byId("addedIcons");
const image = document.createElement("div");
image.style.cssText = `width: 2.2em; height: 2.2em; background-size: cover; background-image: url(${url})`;
addedIcons.appendChild(image);
image.onclick = () => callback(url);
}
byId("addImage").onclick = function () {
const input = this.previousElementSibling;
const ulr = input.value;
if (!ulr) return tip("Enter image URL to add", false, "error", 4000);
if (!ulr.match(/^((http|https):\/\/)|data\:image\//)) return tip("Enter valid URL", false, "error", 4000);
addExternalImage(ulr);
callback(ulr);
input.value = "";
};
byId("addedIcons")
.querySelectorAll("div")
.forEach(div => {
div.onclick = () => callback(div.style.backgroundImage.slice(5, -2));
});
$("#iconSelector").dialog({
width: fitContent(),
title: "Select Icon",
buttons: {
Apply: function () {
$(this).dialog("close");
},
Close: function () {
callback(initial);
$(this).dialog("close");
}
}
});
}
function getAreaUnit(squareMark = "²") {
return byId("areaUnit").value === "square" ? byId("distanceUnitInput").value + squareMark : byId("areaUnit").value;
}
function getArea(rawArea) {
return rawArea * distanceScale ** 2;
}
function confirmationDialog(options) {
const {
title = "Confirm action",
message = "Are you sure you want to continue? <br>The action cannot be reverted",
cancel = "Cancel",
confirm = "Continue",
onCancel,
onConfirm
} = options;
const buttons = {
[confirm]: function () {
if (onConfirm) onConfirm();
$(this).dialog("close");
},
[cancel]: function () {
if (onCancel) onCancel();
$(this).dialog("close");
}
};
byId("alertMessage").innerHTML = message;
$("#alert").dialog({resizable: false, title, buttons});
}
// add and register event listeners to clean up on editor closure
function listen(element, event, handler) {
element.on(event, handler);
return () => element.off(event, handler);
}
// Calls the refresh functionality on all editors currently open.
function refreshAllEditors() {
TIME && console.time("refreshAllEditors");
if (byId("culturesEditorRefresh")?.offsetParent) culturesEditorRefresh.click();
if (byId("biomesEditorRefresh")?.offsetParent) biomesEditorRefresh.click();
if (byId("diplomacyEditorRefresh")?.offsetParent) diplomacyEditorRefresh.click();
if (byId("provincesEditorRefresh")?.offsetParent) provincesEditorRefresh.click();
if (byId("religionsEditorRefresh")?.offsetParent) religionsEditorRefresh.click();
if (byId("statesEditorRefresh")?.offsetParent) statesEditorRefresh.click();
if (byId("zonesEditorRefresh")?.offsetParent) zonesEditorRefresh.click();
TIME && console.timeEnd("refreshAllEditors");
}
// dynamically loaded editors
async function editStates() {
if (customization) return;
const Editor = await import("../dynamic/editors/states-editor.js?v=1.108.1");
Editor.open();
}
async function editCultures() {
if (customization) return;
const Editor = await import("../dynamic/editors/cultures-editor.js?v=1.105.23");
Editor.open();
}
async function editReligions() {
if (customization) return;
const Editor = await import("../dynamic/editors/religions-editor.js?v=1.104.0");
Editor.open();
}