Urquhart routes (#1072)

* feat: routes generation

* feat: routes rendering

* feat: searoutes fix, changing reGraph

* feat: searoute - change pathfinding algo

* feat: routes - cleanup code

* feat: routes - change data format

* feat: routes - add routes to json export

* feat: edit routes - start

* feat: edit routes - main

* feat: edit routes - EP

* feat: edit routes - remove route

* feat: route - generate names

* feat: route - continue

* Refactor route merging logic for improved performance

* feat: routes - show name in tooltip

* feat: routes - create route dialog

* feat: update data on control point remove

* feat: routes editor - split route

* feat: add join route functionality to routes editor

* feat: Add join route functionality to routes editor

* feat: Update join route tooltip in routes editor

* feat: routes overview - sort by length

* feat: routes overview - fix distanceScale value

* feat: routes overview - create route

* Refactor getMiddlePoint function to getCloseToEdgePoint

* feat: routes - change data format, fix issues

* feat: routes - regenerateRoutes

* feat: routes - add route on burg creation

* chore - remove merge conflict markers

* chore - remove merge conflict markers

* feat: routes name - no unnamed burg names

* feat: routes - lock routes

* fix: routes - split routes

* feat: routes - tip correction

* feat: routes - auto-update part 1

* feat: routes - return old rePacj logic to not break auto-update

* feat: routes - auto-update - add connections

---------

Co-authored-by: Azgaar <azgaar.fmg@yandex.com>
This commit is contained in:
Azgaar 2024-08-15 15:46:55 +02:00 committed by GitHub
parent c6dd331eb6
commit f19b891421
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 2462 additions and 1032 deletions

View file

@ -258,7 +258,6 @@ i.icon-lock {
cursor: pointer;
}
#routeEditor > *,
#labelEditor div {
display: inline-block;
}
@ -1618,6 +1617,7 @@ div.states > .riverType {
#burgBody > div > div,
#riverBody > div,
#routeBody > div,
#lakeBody > div {
padding: 0.1em;
}
@ -1625,6 +1625,7 @@ div.states > .riverType {
#riverBody div.label,
#riverBody input,
#riverBody select,
#routeBody div.label,
#lakeBody div.label,
#lakeBody input,
#lakeBody select {
@ -1632,6 +1633,12 @@ div.states > .riverType {
width: 7em;
}
#routeBody input,
#routeBody select {
display: inline-block;
width: 10em;
}
#stateNameEditor div.label,
#provinceNameEditor div.label,
#regimentBody div.label,

View file

@ -138,7 +138,7 @@
}
</style>
<link rel="preload" href="index.css?v=1.98.01" as="style" onload="this.onload=null; this.rel='stylesheet'" />
<link rel="preload" href="index.css?v=1.99.00" as="style" onload="this.onload=null; this.rel='stylesheet'" />
<link rel="preload" href="icons.css" as="style" onload="this.onload=null; this.rel='stylesheet'" />
<link rel="preload" href="libs/jquery-ui.css" as="style" onload="this.onload=null; this.rel='stylesheet'" />
</head>
@ -610,6 +610,7 @@
id="toggleRoutes"
data-tip="Trade routes: click to toggle, drag to raise or lower the layer. Ctrl + click to edit layer style"
data-shortcut="U"
class="buttonoff"
onclick="toggleRoutes(event)"
>
Ro<u>u</u>tes
@ -2119,6 +2120,9 @@
<button id="overviewRiversButton" data-tip="Click to open Rivers Overview" data-shortcut="Shift + V">
Rivers
</button>
<button id="overviewRoutesButton" data-tip="Click to open Routes Overview" data-shortcut="Shift + U">
Routes
</button>
<button id="editStatesButton" data-tip="Click to open States Editor" data-shortcut="Shift + S">
States
</button>
@ -2171,7 +2175,7 @@
<button id="regenerateRivers" data-tip="Click to regenerate all rivers (restore default state)">
Rivers
</button>
<button id="regenerateRoutes" data-tip="Click to regenerate all routes">Routes</button>
<button id="regenerateRoutes" data-tip="Click to regenerate all unlocked routes">Routes</button>
<button
id="regenerateStates"
data-tip="Click to select new capitals and regenerate non-locked states. Emblems and military forces will be regenerated as well, burgs will remain as they are"
@ -2216,7 +2220,7 @@
>
River
</button>
<button id="addRoute" data-tip="Click on map to place a route" data-shortcut="Shift + 4">Route</button>
<button id="addRoute" data-tip="Open route creation dialog" data-shortcut="Shift + 4">Route</button>
</div>
<div class="separator">Show</div>
@ -2700,7 +2704,7 @@
data-tip="Provide a name for the new group"
style="display: none; width: 10em"
/>
<span id="labelGroupNew" data-tip="Create new group for this label" class="icon-plus pointer"></span>
<span id="labelGroupNew" data-tip="Create a new group for this label" class="icon-plus pointer"></span>
<span
id="labelGroupRemove"
data-tip="Remove the Group with all labels"
@ -2813,7 +2817,7 @@
<div id="riverBottom">
<button
id="riverCreateSelectingCells"
data-tip="Create new river selecting river cells"
data-tip="Create a new river selecting river cells"
class="icon-map-pin"
></button>
<button id="riverEditStyle" data-tip="Edit style for all rivers in Style Editor" class="icon-brush"></button>
@ -2857,7 +2861,7 @@
<div data-tip="Type to change lake type (group)">
<div class="label" style="width: 4.8em">Type:</div>
<span id="lakeGroupRemove" data-tip="Remove the group" class="icon-trash-empty pointer"></span>
<span id="lakeGroupAdd" data-tip="Create new type (group) for the lake" class="icon-plus pointer"></span>
<span id="lakeGroupAdd" data-tip="Create a new type (group) for the lake" class="icon-plus pointer"></span>
<select id="lakeGroup" data-tip="Select lake type (group)"></select>
<input
id="lakeGroupName"
@ -2949,35 +2953,78 @@
</div>
<div id="routeEditor" class="dialog" style="display: none">
<button id="routeGroupsShow" data-tip="Show the group selection" class="icon-tags"></button>
<div id="routeGroupsSelection" style="display: none">
<button id="routeGroupsHide" data-tip="Hide the group section" class="icon-tags"></button>
<select id="routeGroup" data-tip="Select a group for this route" style="width: 12em"></select>
<input
id="routeGroupName"
placeholder="new group name"
data-tip="Provide a name for the new group"
style="display: none; width: 12em"
/>
<span id="routeGroupAdd" data-tip="Create new group for this route" class="icon-plus pointer"></span>
<span
id="routeGroupRemove"
data-tip="Remove all routes of this group"
class="icon-trash-empty pointer"
></span>
<div id="routeBody" style="padding-bottom: 0.3em">
<div>
<div class="label">Name:</div>
<input id="routeName" data-tip="Type to rename the route" autocorrect="off" spellcheck="false" />
<span data-tip="Speak the name. You can change voice and language in options" class="speaker">🔊</span>
<span id="routeGenerateName" data-tip="Generate route name" class="icon-globe pointer"></span>
</div>
<div data-tip="Select route group">
<div class="label">Group:</div>
<select id="routeGroup"></select>
<span id="routeGroupEdit" data-tip="Edit route groups" class="icon-pencil pointer"></span>
<span id="routeEditStyle" data-tip="Edit style for the route group" class="icon-brush pointer"></span>
</div>
<div data-tip="Route length in selected units">
<div class="label">Length:</div>
<input id="routeLength" disabled />
</div>
</div>
<button id="routeEditStyle" data-tip="Edit route group style in Style Editor" class="icon-brush"></button>
<button id="routeLength" data-tip="Route length in selected units">0</button>
<button
id="routeElevationProfile"
data-tip="Show the elevation profile for the route"
class="icon-chart-area"
></button>
<button id="routeSplit" data-tip="Click on a control point to split the route" class="icon-unlink"></button>
<button id="routeLegend" data-tip="Edit free text notes (legend) for the route" class="icon-edit"></button>
<button id="routeNew" data-tip="Create new route clicking on map" class="icon-map-pin"></button>
<button id="routeRemove" data-tip="Remove route" data-shortcut="Delete" class="icon-trash fastDelete"></button>
<div id="routeBottom">
<button
id="routeCreateSelectingCells"
data-tip="Create a new route selecting route cells"
class="icon-map-pin"
></button>
<button
id="routeJoin"
data-tip="Click to join the route to another route that starts or ends at the same cell"
class="icon-link"
></button>
<button
id="routeSplit"
data-tip="Click on a control point to split the route there"
class="icon-unlink"
></button>
<button
id="routeElevationProfile"
data-tip="Show the elevation profile for the route"
class="icon-chart-area"
></button>
<button id="routeLegend" data-tip="Edit free text notes (legend) for the route" class="icon-edit"></button>
<button id="routeLock" class="icon-lock-open" onmouseover="showElementLockTip(event)"></button>
<button
id="routeRemove"
data-tip="Remove route"
data-shortcut="Delete"
class="icon-trash fastDelete"
></button>
</div>
</div>
<div id="routeCreator" class="dialog" style="display: none">
<div>Click on map to add/remove route points</div>
<div id="routeCreatorBody" class="table" style="margin: 0.3em 0"></div>
<div id="routeCreatorBottom">
<button id="routeCreatorComplete" data-tip="Complete route creation" class="icon-check"></button>
<button id="routeCreatorCancel" data-tip="Cancel the creation" class="icon-cancel"></button>
<div style="display: inline-block">
Group:
<select id="routeCreatorGroupSelect"></select>
<span id="routeCreatorGroupEdit" data-tip="Edit route groups" class="icon-pencil pointer"></span>
</div>
</div>
</div>
<div id="routeGroupsEditor" class="dialog" style="display: none">
<div id="routeGroupsEditorBody" class="table" style="padding: 0.3em 0; width: 100%"></div>
<div id="routeGroupsEditorBottom">
<button id="routeGroupsEditorAdd" data-tip="Add route group" class="icon-plus"></button>
</div>
</div>
<div id="iceEditor" class="dialog" style="display: none">
@ -3004,7 +3051,11 @@
data-tip="Provide a name for the new group"
style="display: none; width: 9em"
/>
<span id="coastlineGroupAdd" data-tip="Create new group for this coastline" class="icon-plus pointer"></span>
<span
id="coastlineGroupAdd"
data-tip="Create a new group for this coastline"
class="icon-plus pointer"
></span>
<span id="coastlineGroupRemove" data-tip="Remove the group" class="icon-trash-empty pointer"></span>
</div>
@ -3475,10 +3526,10 @@
<input
id="burgInputGroup"
placeholder="new group name"
data-tip="Create new Group for the Burg"
data-tip="Create a new Group for the Burg"
style="display: none; width: 10em"
/>
<i id="burgAddGroup" data-tip="Create new group for the burg" class="icon-plus pointer"></i>
<i id="burgAddGroup" data-tip="Create a new group for the burg" class="icon-plus pointer"></i>
<i id="burgRemoveGroup" data-tip="Remove selected burg group" class="icon-trash pointer"></i>
</div>
@ -3627,7 +3678,7 @@
<div id="regimentBottom">
<button id="regimentAttack" data-tip="Attack foreign regiment" class="icon-target"></button>
<button id="regimentAdd" data-tip="Create new regiment or fleet" class="icon-user-plus"></button>
<button id="regimentAdd" data-tip="Create a new regiment or fleet" class="icon-user-plus"></button>
<button id="regimentSplit" data-tip="Split regiment into 2 separate ones" class="icon-half"></button>
<button
id="regimentAttach"
@ -5408,12 +5459,6 @@
Population
</div>
<div data-tip="Click to sort by burg type" class="sortable alphabetically" data-sortby="type">Type&nbsp;</div>
<div
id="burgsInvertLock"
style="color: #6e5e66"
data-tip="Click to invert lock for all burgs"
class="icon-lock pointer"
></div>
</div>
<div id="burgsBody" class="table"></div>
@ -5460,6 +5505,47 @@
</div>
</div>
<div id="routesOverview" class="dialog stable" style="display: none">
<div id="routesHeader" class="header" style="grid-template-columns: 17em 8em 8em">
<div data-tip="Click to sort by route name" class="sortable alphabetically" data-sortby="name">
Route&nbsp;
</div>
<div data-tip="Click to sort by route group" class="sortable alphabetically" data-sortby="group">
Group&nbsp;
</div>
<div data-tip="Click to sort by route length" class="sortable icon-sort-number-down" data-sortby="length">
Length&nbsp;
</div>
</div>
<div id="routesBody" class="table"></div>
<div id="routesFooter" class="totalLine">
<div data-tip="Routes number" style="margin-left: 4px">
Total routes:&nbsp;<span id="routesFooterNumber">0</span>
</div>
<div data-tip="Average length" style="margin-left: 12px">
Average length:&nbsp;<span id="routesFooterLength">0</span>
</div>
</div>
<div id="routesBottom">
<button id="routesOverviewRefresh" data-tip="Refresh the Editor" class="icon-cw"></button>
<button
id="routesCreateNew"
data-tip="Create a new route selecting route cells"
class="icon-map-pin"
></button>
<button
id="routesExport"
data-tip="Save routes-related data as a text file (.csv)"
class="icon-download"
></button>
<button id="routesLockAll" data-tip="Lock or unlock all routes" class="icon-lock"></button>
<button id="routesRemoveAll" data-tip="Remove all routes" class="icon-trash"></button>
</div>
</div>
<div id="riversOverview" class="dialog stable" style="display: none">
<div id="riversHeader" class="header" style="grid-template-columns: 9em 4em 6em 6em 5em 9em">
<div data-tip="Click to sort by river name" class="sortable alphabetically" data-sortby="name">
@ -5506,7 +5592,7 @@
data-tip="Automatically add river starting from clicked cell. Hold Shift to add multiple"
class="icon-plus"
></button>
<button id="riverCreateNew" data-tip="Create new river selecting river cells" class="icon-map-pin"></button>
<button id="riverCreateNew" data-tip="Create a new river selecting river cells" class="icon-map-pin"></button>
<button id="riversBasinHighlight" data-tip="Toggle basin highlight mode" class="icon-sitemap"></button>
<button
id="riversExport"
@ -6211,7 +6297,7 @@
Data to be copied: heightmap, biomes, religions, population, precipitation, cultures, states, provinces,
military regiments
</p>
<p>Data to be regenerated: zones, roads, rivers</p>
<p>Data to be regenerated: zones, routes, rivers</p>
<p>Burgs may be remapped incorrectly, manual change is required</p>
<p>Keep data for:</p>
@ -8073,95 +8159,101 @@
<script src="versioning.js"></script>
<script src="libs/d3.min.js"></script>
<script src="libs/priority-queue.min.js"></script>
<script src="libs/flatqueue.js"></script>
<script src="libs/delaunator.min.js"></script>
<script src="libs/indexedDB.js?v=1.91.01"></script>
<script src="libs/indexedDB.js?v=1.99.00"></script>
<script src="utils/shorthands.js"></script>
<script src="utils/commonUtils.js?v=1.89.29"></script>
<script src="utils/arrayUtils.js"></script>
<script src="utils/colorUtils.js"></script>
<script src="utils/graphUtils.js?v=1.96.00"></script>
<script src="utils/nodeUtils.js"></script>
<script src="utils/numberUtils.js?v=1.89.08"></script>
<script src="utils/polyfills.js?v=1.95.03"></script>
<script src="utils/probabilityUtils.js?v=1.88.00"></script>
<script src="utils/stringUtils.js"></script>
<script src="utils/languageUtils.js"></script>
<script src="utils/unitUtils.js?v=1.87.00"></script>
<script src="utils/shorthands.js?v=1.99.00"></script>
<script src="utils/commonUtils.js?v=1.99.00"></script>
<script src="utils/arrayUtils.js?v=1.99.00"></script>
<script src="utils/functionUtils.js?v=1.99.00"></script>
<script src="utils/colorUtils.js?v=1.99.00"></script>
<script src="utils/graphUtils.js?v=1.99.00"></script>
<script src="utils/nodeUtils.js?v=1.99.00"></script>
<script src="utils/numberUtils.js?v=1.99.00"></script>
<script src="utils/polyfills.js?v=1.99.00"></script>
<script src="utils/probabilityUtils.js?v=1.99.00"></script>
<script src="utils/stringUtils.js?v=1.99.00"></script>
<script src="utils/languageUtils.js?v=1.99.00"></script>
<script src="utils/unitUtils.js?v=1.99.00"></script>
<script defer src="utils/debugUtils.js?v=1.99.00"></script>
<script src="modules/voronoi.js"></script>
<script src="config/heightmap-templates.js"></script>
<script src="config/precreated-heightmaps.js"></script>
<script src="modules/heightmap-generator.js?v=1.88.00"></script>
<script src="modules/ocean-layers.js?v=1.98.04"></script>
<script src="modules/river-generator.js?v=1.89.13"></script>
<script src="modules/lakes.js?v=1.98.06"></script>
<script src="modules/biomes.js"></script>
<script src="modules/heightmap-generator.js?v=1.99.00"></script>
<script src="modules/ocean-layers.js?v=1.99.00"></script>
<script src="modules/river-generator.js?v=1.99.00"></script>
<script src="modules/lakes.js?v=1.99.00"></script>
<script src="modules/biomes.js?v=1.99.00"></script>
<script src="modules/names-generator.js?v=1.87.14"></script>
<script src="modules/cultures-generator.js?v=1.96.05"></script>
<script src="modules/cultures-generator.js?v=1.99.00"></script>
<script src="modules/renderers/state-labels.js?v=1.96.04"></script>
<script src="modules/burgs-and-states.js?v=1.97.07"></script>
<script src="modules/routes-generator.js"></script>
<script src="modules/religions-generator.js?v=1.93.08"></script>
<script src="modules/military-generator.js?v=1.97.14"></script>
<script src="modules/markers-generator.js?v=1.93.04"></script>
<script src="modules/coa-generator.js?v=1.91.05"></script>
<script src="modules/submap.js?v=1.96.00"></script>
<script src="modules/burgs-and-states.js?v=1.99.00"></script>
<script src="modules/routes-generator.js?v=1.99.00"></script>
<script src="modules/religions-generator.js?v=1.99.00"></script>
<script src="modules/military-generator.js?v=1.99.00"></script>
<script src="modules/markers-generator.js?v=1.99.00"></script>
<script src="modules/coa-generator.js?v=1.99.00"></script>
<script src="modules/submap.js?v=1.99.00"></script>
<script src="libs/polylabel.min.js"></script>
<script src="libs/lineclip.min.js"></script>
<script src="libs/alea.min.js"></script>
<script src="modules/fonts.js?v=1.89.18"></script>
<script src="modules/ui/layers.js?v=1.96.00"></script>
<script src="modules/ui/measurers.js?v=1.96.00"></script>
<script src="modules/ui/stylePresets.js?v=1.96.00"></script>
<script src="modules/fonts.js?v=1.99.00"></script>
<script src="modules/ui/layers.js?v=1.99.00"></script>
<script src="modules/ui/measurers.js?v=1.99.00"></script>
<script src="modules/ui/stylePresets.js?v=1.99.00"></script>
<script src="modules/ui/general.js?v=1.98.01"></script>
<script src="modules/ui/options.js?v=1.98.04"></script>
<script src="main.js?v=1.98.07"></script>
<script src="modules/ui/general.js?v=1.99.00"></script>
<script src="modules/ui/options.js?v=1.99.00"></script>
<script src="main.js?v=1.99.00"></script>
<script defer src="modules/relief-icons.js"></script>
<script defer src="modules/ui/style.js?v=1.96.00"></script>
<script defer src="modules/ui/editors.js?v=1.97.12"></script>
<script defer src="modules/ui/tools.js?v=1.97.12"></script>
<script defer src="modules/ui/world-configurator.js?v=1.98.01"></script>
<script defer src="modules/ui/heightmap-editor.js?v=1.96.00"></script>
<script defer src="modules/ui/provinces-editor.js?v=1.96.00"></script>
<script defer src="modules/ui/biomes-editor.js?v=1.91.05"></script>
<script defer src="modules/ui/namesbase-editor.js?v=1.95.02"></script>
<script defer src="modules/ui/elevation-profile.js?v=1.97.10"></script>
<script defer src="modules/ui/temperature-graph.js?v=1.90.03"></script>
<script defer src="modules/ui/routes-editor.js?v=1.89.04"></script>
<script defer src="modules/ui/ice-editor.js?v=1.89.08"></script>
<script defer src="modules/ui/lakes-editor.js?v=1.87.10"></script>
<script defer src="modules/ui/coastline-editor.js"></script>
<script defer src="modules/ui/labels-editor.js?v=1.92.00"></script>
<script defer src="modules/ui/rivers-editor.js"></script>
<script defer src="modules/ui/rivers-creator.js?v=1.89.13"></script>
<script defer src="modules/ui/relief-editor.js"></script>
<script defer src="modules/ui/burg-editor.js?v=1.97.00"></script>
<script defer src="modules/ui/units-editor.js?v=1.96.00"></script>
<script defer src="modules/ui/notes-editor.js?v=1.97.09"></script>
<script defer src="modules/ui/diplomacy-editor.js?v=1.88.04"></script>
<script defer src="modules/ui/zones-editor.js?v=1.97.13"></script>
<script defer src="modules/ui/burgs-overview.js?v=1.97.00"></script>
<script defer src="modules/ui/rivers-overview.js"></script>
<script defer src="modules/ui/military-overview.js?v=1.97.15"></script>
<script defer src="modules/ui/regiments-overview.js?v=1.89.20"></script>
<script defer src="modules/ui/markers-overview.js?v=1.89.38"></script>
<script defer src="modules/ui/regiment-editor.js?v=1.97.14"></script>
<script defer src="modules/ui/battle-screen.js"></script>
<script defer src="modules/ui/emblems-editor.js?v=1.91.00"></script>
<script defer src="modules/ui/markers-editor.js"></script>
<script defer src="modules/ui/3d.js?v=1.94.03"></script>
<script defer src="modules/ui/submap.js?v=1.96.00"></script>
<script defer src="modules/ui/hotkeys.js?v=1.95.00"></script>
<script defer src="modules/coa-renderer.js?v=1.94.00"></script>
<script defer src="modules/relief-icons.js?v=1.99.00"></script>
<script defer src="modules/ui/style.js?v=1.99.00"></script>
<script defer src="modules/ui/editors.js?v=1.99.00"></script>
<script defer src="modules/ui/tools.js?v=1.99.00"></script>
<script defer src="modules/ui/world-configurator.js?v=1.99.00"></script>
<script defer src="modules/ui/heightmap-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/provinces-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/biomes-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/namesbase-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/elevation-profile.js?v=1.99.00"></script>
<script defer src="modules/ui/temperature-graph.js?v=1.99.00"></script>
<script defer src="modules/ui/routes-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/routes-creator.js?v=1.99.00"></script>
<script defer src="modules/ui/route-group-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/ice-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/lakes-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/coastline-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/labels-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/rivers-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/rivers-creator.js?v=1.99.00"></script>
<script defer src="modules/ui/relief-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/burg-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/units-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/notes-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/diplomacy-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/zones-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/burgs-overview.js?v=1.99.00"></script>
<script defer src="modules/ui/routes-overview.js?v=1.99.00"></script>
<script defer src="modules/ui/rivers-overview.js?v=1.99.00"></script>
<script defer src="modules/ui/military-overview.js?v=1.99.00"></script>
<script defer src="modules/ui/regiments-overview.js?v=1.99.00"></script>
<script defer src="modules/ui/markers-overview.js?v=1.99.00"></script>
<script defer src="modules/ui/regiment-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/battle-screen.js?v=1.99.00"></script>
<script defer src="modules/ui/emblems-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/markers-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/3d.js?v=1.99.00"></script>
<script defer src="modules/ui/submap.js?v=1.99.00"></script>
<script defer src="modules/ui/hotkeys.js?v=1.99.00"></script>
<script defer src="modules/coa-renderer.js?v=1.99.00"></script>
<script defer src="libs/rgbquant.min.js"></script>
<script defer src="libs/jquery.ui.touch-punch.min.js"></script>
<script defer src="modules/io/save.js?v=1.98.01"></script>
<script defer src="modules/io/load.js?v=1.98.06"></script>
<script defer src="modules/io/cloud.js?v=1.96.00"></script>
<script defer src="modules/io/export.js?v=1.98.05"></script>
<script defer src="modules/io/save.js?v=1.99.00"></script>
<script defer src="modules/io/load.js?v=1.99.00"></script>
<script defer src="modules/io/cloud.js?v=1.99.00"></script>
<script defer src="modules/io/export.js?v=1.99.00"></script>
<!-- Web Components -->
<script defer src="components/fill-box.js"></script>

57
libs/flatqueue.js Normal file
View file

@ -0,0 +1,57 @@
!(function (t, s) {
"object" == typeof exports && "undefined" != typeof module
? (module.exports = s())
: "function" == typeof define && define.amd
? define(s)
: ((t = "undefined" != typeof globalThis ? globalThis : t || self).FlatQueue = s());
})(this, function () {
"use strict";
return class {
constructor() {
(this.ids = []), (this.values = []), (this.length = 0);
}
clear() {
this.length = 0;
}
push(t, s) {
let i = this.length++;
for (; i > 0; ) {
const t = (i - 1) >> 1,
e = this.values[t];
if (s >= e) break;
(this.ids[i] = this.ids[t]), (this.values[i] = e), (i = t);
}
(this.ids[i] = t), (this.values[i] = s);
}
pop() {
if (0 === this.length) return;
const t = this.ids[0];
if ((this.length--, this.length > 0)) {
const t = (this.ids[0] = this.ids[this.length]),
s = (this.values[0] = this.values[this.length]),
i = this.length >> 1;
let e = 0;
for (; e < i; ) {
let t = 1 + (e << 1);
const i = t + 1;
let h = this.ids[t],
l = this.values[t];
const n = this.values[i];
if ((i < this.length && n < l && ((t = i), (h = this.ids[i]), (l = n)), l >= s)) break;
(this.ids[e] = h), (this.values[e] = l), (e = t);
}
(this.ids[e] = t), (this.values[e] = s);
}
return t;
}
peek() {
if (0 !== this.length) return this.ids[0];
}
peekValue() {
if (0 !== this.length) return this.values[0];
}
shrink() {
this.ids.length = this.values.length = this.length;
}
};
});

32
main.js
View file

@ -647,6 +647,7 @@ async function generate(options) {
Cultures.generate();
Cultures.expand();
BurgsAndStates.generate();
Routes.generate();
Religions.generate();
BurgsAndStates.defineStateForms();
BurgsAndStates.generateProvinces();
@ -1175,10 +1176,11 @@ function reGraph() {
for (const i of gridCells.i) {
const height = gridCells.h[i];
const type = gridCells.t[i];
if (height < 20 && type !== -1 && type !== -2) continue; // exclude all deep ocean points
if (type === -2 && (i % 4 === 0 || features[gridCells.f[i]].type === "lake")) continue; // exclude non-coastal lake points
const [x, y] = points[i];
const [x, y] = points[i];
addNewPoint(i, x, y, height);
// add additional points for cells along coast
@ -1406,8 +1408,8 @@ function reMarkFeatures() {
queue[0] = cells.f.findIndex(f => !f); // find unmarked cell
}
// markupPackLand
markup(pack.cells, 3, 1, 0);
markup(pack.cells, 3, 1, 0); // markupPackLand
markup(pack.cells, -2, -1, -10); // markupPackWater
function defineHaven(i) {
const water = cells.c[i].filter(c => cells.h[c] < 20);
@ -1645,9 +1647,10 @@ function addZones(number = 1) {
const burg = ra(burgs.filter(b => !used[b.cell] && b.i && !b.removed)); // random burg
if (!burg) return;
const cellsArray = [],
cost = [],
power = rand(20, 37);
const cellsArray = [];
const cost = [];
const power = rand(20, 37);
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
queue.queue({e: burg.cell, p: 0});
@ -1656,15 +1659,14 @@ function addZones(number = 1) {
if (cells.burg[next.e] || cells.pop[next.e]) cellsArray.push(next.e);
used[next.e] = 1;
cells.c[next.e].forEach(function (e) {
const r = cells.road[next.e];
const c = r ? Math.max(10 - r, 1) : 100;
cells.c[next.e].forEach(nextCellId => {
const c = Routes.getRoute(next.e, nextCellId) ? 5 : 100;
const p = next.p + c;
if (p > power) return;
if (!cost[e] || p < cost[e]) {
cost[e] = p;
queue.queue({e, p});
if (!cost[nextCellId] || p < cost[nextCellId]) {
cost[nextCellId] = p;
queue.queue({e: nextCellId, p});
}
});
}
@ -1785,10 +1787,10 @@ function addZones(number = 1) {
}
function addAvalanche() {
const roads = cells.i.filter(i => !used[i] && cells.road[i] && cells.h[i] >= 70);
if (!roads.length) return;
const routes = cells.i.filter(i => !used[i] && Routes.isConnected(i) && cells.h[i] >= 70);
if (!routes.length) return;
const cell = +ra(roads);
const cell = +ra(routes);
const cellsArray = [],
queue = [cell],
power = rand(3, 15);

View file

@ -1,24 +1,8 @@
"use strict";
const MIN_LAND_HEIGHT = 20;
const names = [
"Marine",
"Hot desert",
"Cold desert",
"Savanna",
"Grassland",
"Tropical seasonal forest",
"Temperate deciduous forest",
"Tropical rainforest",
"Temperate rainforest",
"Taiga",
"Tundra",
"Glacier",
"Wetland"
];
window.Biomes = (function () {
const MIN_LAND_HEIGHT = 20;
const getDefault = () => {
const name = [
"Marine",
@ -52,7 +36,7 @@ window.Biomes = (function () {
"#0b9131"
];
const habitability = [0, 4, 10, 22, 30, 50, 100, 80, 90, 12, 4, 0, 12];
const iconsDensity = [0, 3, 2, 120, 120, 120, 120, 150, 150, 100, 5, 0, 150];
const iconsDensity = [0, 3, 2, 120, 120, 120, 120, 150, 150, 100, 5, 0, 250];
const icons = [
{},
{dune: 3, cactus: 6, deadTree: 1},

View file

@ -6,27 +6,20 @@ window.BurgsAndStates = (() => {
const n = cells.i.length;
cells.burg = new Uint16Array(n); // cell burg
cells.road = new Uint16Array(n); // cell road power
cells.crossroad = new Uint16Array(n); // cell crossroad power
const burgs = (pack.burgs = placeCapitals());
pack.states = createStates();
const capitalRoutes = Routes.getRoads();
placeTowns();
expandStates();
normalizeStates();
const townRoutes = Routes.getTrails();
specifyBurgs();
const oceanRoutes = Routes.getSearoutes();
collectStatistics();
assignColors();
generateCampaigns();
generateDiplomacy();
Routes.draw(capitalRoutes, townRoutes, oceanRoutes);
drawBurgs();
function placeCapitals() {
@ -138,9 +131,8 @@ window.BurgsAndStates = (() => {
while (burgsAdded < burgsNumber && spacing > 1) {
for (let i = 0; burgsAdded < burgsNumber && i < sorted.length; i++) {
if (cells.burg[sorted[i]]) continue;
const cell = sorted[i],
x = cells.p[cell][0],
y = cells.p[cell][1];
const cell = sorted[i];
const [x, y] = cells.p[cell];
const s = spacing * gauss(1, 0.3, 0.2, 2, 2); // randomize to make placement not uniform
if (burgsTree.find(x, y, s) !== undefined) continue; // to close to existing burg
const burg = burgs.length;
@ -183,12 +175,12 @@ window.BurgsAndStates = (() => {
} else b.port = 0;
// define burg population (keep urbanization at about 10% rate)
b.population = rn(Math.max((cells.s[i] + cells.road[i] / 2) / 8 + b.i / 1000 + (i % 100) / 1000, 0.1), 3);
b.population = rn(Math.max(cells.s[i] / 8 + b.i / 1000 + (i % 100) / 1000, 0.1), 3);
if (b.capital) b.population = rn(b.population * 1.3, 3); // increase capital population
if (b.port) {
b.population = b.population * 1.3; // increase port population
const [x, y] = getMiddlePoint(i, haven);
const [x, y] = getCloseToEdgePoint(i, haven);
b.x = x;
b.y = y;
}
@ -229,6 +221,23 @@ window.BurgsAndStates = (() => {
TIME && console.timeEnd("specifyBurgs");
};
function getCloseToEdgePoint(cell1, cell2) {
const {cells, vertices} = pack;
const [x0, y0] = cells.p[cell1];
const commonVertices = cells.v[cell1].filter(vertex => vertices.c[vertex].some(cell => cell === cell2));
const [x1, y1] = vertices.p[commonVertices[0]];
const [x2, y2] = vertices.p[commonVertices[1]];
const xEdge = (x1 + x2) / 2;
const yEdge = (y1 + y2) / 2;
const x = rn(x0 + 0.95 * (xEdge - x0), 2);
const y = rn(y0 + 0.95 * (yEdge - y0), 2);
return [x, y];
}
const getType = (i, port) => {
const cells = pack.cells;
if (port) return "Naval";
@ -244,11 +253,11 @@ window.BurgsAndStates = (() => {
return "Generic";
};
const defineBurgFeatures = newburg => {
const defineBurgFeatures = burg => {
const {cells} = pack;
pack.burgs
.filter(b => (newburg ? b.i == newburg.i : b.i && !b.removed && !b.lock))
.filter(b => (burg ? b.i == burg.i : b.i && !b.removed && !b.lock))
.forEach(b => {
const pop = b.population;
b.citadel = Number(b.capital || (pop > 50 && P(0.75)) || (pop > 15 && P(0.5)) || P(0.1));

View file

@ -860,4 +860,59 @@ export function resolveVersionConflicts(version) {
shiftCompass();
}
}
if (version < 1.99) {
// v1.99.00 changed routes generation algorithm and data format
delete cells.road;
delete cells.crossroad;
pack.routes = [];
const POINT_DISTANCE = grid.spacing * 0.75;
routes.selectAll("g").each(function () {
const group = this.id;
if (!group) return;
for (const node of this.querySelectorAll("path")) {
const totalLength = node.getTotalLength();
if (!totalLength) debugger;
const increment = totalLength / Math.ceil(totalLength / POINT_DISTANCE);
const points = [];
for (let i = 0; i <= totalLength + 0.1; i += increment) {
const point = node.getPointAtLength(i);
const x = rn(point.x, 2);
const y = rn(point.y, 2);
const cellId = findCell(x, y);
points.push([x, y, cellId]);
}
if (points.length < 2) return;
const secondCellId = points[1][2];
const feature = pack.cells.f[secondCellId];
pack.routes.push({i: pack.routes.length, group, feature, points});
}
});
routes.selectAll("path").remove();
if (layerIsOn("toggleRoutes")) drawRoutes();
const links = (pack.cells.routes = {});
for (const route of pack.routes) {
for (let i = 0; i < route.points.length - 1; i++) {
const cellId = route.points[i][2];
const nextCellId = route.points[i + 1][2];
if (cellId !== nextCellId) {
if (!links[cellId]) links[cellId] = {};
links[cellId][nextCellId] = route.i;
if (!links[nextCellId]) links[nextCellId] = {};
links[nextCellId][cellId] = route.i;
}
}
}
}
}

View file

@ -966,7 +966,7 @@ function dragStateBrush() {
const p = d3.mouse(this);
moveCircle(p[0], p[1], r);
const found = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1], r)];
const found = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1])];
const selection = found.filter(isLand);
if (selection) changeStateForSelection(selection);
});

View file

@ -52,7 +52,8 @@ function getMinimalDataJson() {
provinces: pack.provinces,
religions: pack.religions,
rivers: pack.rivers,
markers: pack.markers
markers: pack.markers,
routes: pack.routes
};
return JSON.stringify({info, settings, mapCoordinates, pack: packData, biomesData, notes, nameBases});
}
@ -85,7 +86,7 @@ function getMapInfo() {
function getSettings() {
return {
distanceUnit: distanceUnitInput.value,
distanceScale: distanceScaleInput.value,
distanceScale,
areaUnit: areaUnit.value,
heightUnit: heightUnit.value,
heightExponent: heightExponentInput.value,
@ -106,7 +107,7 @@ function getSettings() {
}
function getPackCellsData() {
const dataArrays = {
const data = {
v: pack.cells.v,
c: pack.cells.c,
p: pack.cells.p,
@ -125,8 +126,7 @@ function getPackCellsData() {
pop: Array.from(pack.cells.pop),
culture: Array.from(pack.cells.culture),
burg: Array.from(pack.cells.burg),
road: Array.from(pack.cells.road),
crossroad: Array.from(pack.cells.crossroad),
routes: pack.cells.routes,
state: Array.from(pack.cells.state),
religion: Array.from(pack.cells.religion),
province: Array.from(pack.cells.province)
@ -135,29 +135,28 @@ function getPackCellsData() {
return {
cells: Array.from(pack.cells.i).map(cellId => ({
i: cellId,
v: dataArrays.v[cellId],
c: dataArrays.c[cellId],
p: dataArrays.p[cellId],
g: dataArrays.g[cellId],
h: dataArrays.h[cellId],
area: dataArrays.area[cellId],
f: dataArrays.f[cellId],
t: dataArrays.t[cellId],
haven: dataArrays.haven[cellId],
harbor: dataArrays.harbor[cellId],
fl: dataArrays.fl[cellId],
r: dataArrays.r[cellId],
conf: dataArrays.conf[cellId],
biome: dataArrays.biome[cellId],
s: dataArrays.s[cellId],
pop: dataArrays.pop[cellId],
culture: dataArrays.culture[cellId],
burg: dataArrays.burg[cellId],
road: dataArrays.road[cellId],
crossroad: dataArrays.crossroad[cellId],
state: dataArrays.state[cellId],
religion: dataArrays.religion[cellId],
province: dataArrays.province[cellId]
v: data.v[cellId],
c: data.c[cellId],
p: data.p[cellId],
g: data.g[cellId],
h: data.h[cellId],
area: data.area[cellId],
f: data.f[cellId],
t: data.t[cellId],
haven: data.haven[cellId],
harbor: data.harbor[cellId],
fl: data.fl[cellId],
r: data.r[cellId],
conf: data.conf[cellId],
biome: data.biome[cellId],
s: data.s[cellId],
pop: data.pop[cellId],
culture: data.culture[cellId],
burg: data.burg[cellId],
routes: data.routes[cellId],
state: data.state[cellId],
religion: data.religion[cellId],
province: data.province[cellId]
})),
vertices: Array.from(pack.vertices.p).map((_, vertexId) => ({
i: vertexId,
@ -172,7 +171,8 @@ function getPackCellsData() {
provinces: pack.provinces,
religions: pack.religions,
rivers: pack.rivers,
markers: pack.markers
markers: pack.markers,
routes: pack.routes
};
}

View file

@ -1,5 +1,3 @@
import {rollups} from "../../../utils/functionUtils.js";
const entitiesMap = {
states: {
label: "State",

View file

@ -471,17 +471,23 @@ function saveGeoJSON_Cells() {
}
function saveGeoJSON_Routes() {
const json = {type: "FeatureCollection", features: []};
routes.selectAll("g > path").each(function () {
const coordinates = getRoutePoints(this);
const id = this.id;
const type = this.parentElement.id;
const feature = {type: "Feature", geometry: {type: "LineString", coordinates}, properties: {id, type}};
json.features.push(feature);
const {cells, burgs} = pack;
let points = cells.p.map(([x, y], cellId) => {
const burgId = cells.burg[cellId];
if (burgId) return [burgs[burgId].x, burgs[burgId].y];
return [x, y];
});
const features = pack.routes.map(route => {
const coordinates = route.points || getRoutePoints(route, points);
return {
type: "Feature",
geometry: {type: "LineString", coordinates},
properties: {id: route.id, group: route.group}
};
});
const json = {type: "FeatureCollection", features};
const fileName = getFileName("Routes") + ".geojson";
downloadFile(JSON.stringify(json), fileName, "application/json");
}
@ -525,17 +531,6 @@ function getCellCoordinates(vertices) {
return [coordinates.concat([coordinates[0]])];
}
function getRoutePoints(node) {
let points = [];
const l = node.getTotalLength();
const increment = l / Math.ceil(l / 2);
for (let i = 0; i <= l; i += increment) {
const p = node.getPointAtLength(i);
points.push(getCoordinates(p.x, p.y, 4));
}
return points;
}
function getRiverPoints(node) {
let points = [];
const l = node.getTotalLength() / 2; // half-length

View file

@ -379,6 +379,7 @@ async function parseLoadedData(data, mapVersion) {
pack.provinces = data[30] ? JSON.parse(data[30]) : [0];
pack.rivers = data[32] ? JSON.parse(data[32]) : [];
pack.markers = data[35] ? JSON.parse(data[35]) : [];
pack.routes = data[37] ? JSON.parse(data[37]) : [];
const cells = pack.cells;
cells.biome = Uint8Array.from(data[16].split(","));
@ -388,12 +389,13 @@ async function parseLoadedData(data, mapVersion) {
cells.fl = Uint16Array.from(data[20].split(","));
cells.pop = Float32Array.from(data[21].split(","));
cells.r = Uint16Array.from(data[22].split(","));
cells.road = Uint16Array.from(data[23].split(","));
// data[23] for deprecated cells.road
cells.s = Uint16Array.from(data[24].split(","));
cells.state = Uint16Array.from(data[25].split(","));
cells.religion = data[26] ? Uint16Array.from(data[26].split(",")) : new Uint16Array(cells.i.length);
cells.province = data[27] ? Uint16Array.from(data[27].split(",")) : new Uint16Array(cells.i.length);
cells.crossroad = data[28] ? Uint16Array.from(data[28].split(",")) : new Uint16Array(cells.i.length);
// data[28] for deprecated cells.crossroad
cells.routes = data[36] ? JSON.parse(data[36]) : {};
if (data[31]) {
const namesDL = data[31].split("/");
@ -461,7 +463,7 @@ async function parseLoadedData(data, mapVersion) {
{
// dynamically import and run auto-update script
const versionNumber = parseFloat(params[0]);
const {resolveVersionConflicts} = await import("../dynamic/auto-update.js?v=1.98.06");
const {resolveVersionConflicts} = await import("../dynamic/auto-update.js?v=1.99.00");
resolveVersionConflicts(versionNumber);
}

View file

@ -44,7 +44,7 @@ function prepareMapData() {
const params = [version, license, dateString, seed, graphWidth, graphHeight, mapId].join("|");
const settings = [
distanceUnitInput.value,
distanceScaleInput.value,
distanceScale,
areaUnit.value,
heightUnit.value,
heightExponentInput.value,
@ -98,6 +98,8 @@ function prepareMapData() {
const provinces = JSON.stringify(pack.provinces);
const rivers = JSON.stringify(pack.rivers);
const markers = JSON.stringify(pack.markers);
const cellRoutes = JSON.stringify(pack.cells.routes);
const routes = JSON.stringify(pack.routes);
// store name array only if not the same as default
const defaultNB = Names.getNameBases();
@ -136,19 +138,21 @@ function prepareMapData() {
pack.cells.fl,
pop,
pack.cells.r,
pack.cells.road,
[], // deprecated pack.cells.road
pack.cells.s,
pack.cells.state,
pack.cells.religion,
pack.cells.province,
pack.cells.crossroad,
[], // deprecated pack.cells.crossroad
religions,
provinces,
namesData,
rivers,
rulersString,
fonts,
markers
markers,
cellRoutes,
routes
].join("\r\n");
return mapData;
}

View file

@ -27,7 +27,7 @@ window.Markers = (function () {
{type: "water-sources", icon: "💧", min: 1, each: 1000, multiplier: 1, list: listWaterSources, add: addWaterSource},
{type: "mines", icon: "⛏️", dx: 48, px: 13, min: 1, each: 15, multiplier: 1, list: listMines, add: addMine},
{type: "bridges", icon: "🌉", px: 14, min: 1, each: 5, multiplier: 1, list: listBridges, add: addBridge},
{type: "inns", icon: "🍻", px: 14, min: 1, each: 100, multiplier: 1, list: listInns, add: addInn},
{type: "inns", icon: "🍻", px: 14, min: 1, each: 10, multiplier: 1, list: listInns, add: addInn},
{type: "lighthouses", icon: "🚨", px: 14, min: 1, each: 2, multiplier: 1, list: listLighthouses, add: addLighthouse},
{type: "waterfalls", icon: "⟱", dy: 54, px: 16, min: 1, each: 5, multiplier: 1, list: listWaterfalls, add: addWaterfall},
{type: "battlefields", icon: "⚔️", dy: 52, min: 50, each: 700, multiplier: 1, list: listBattlefields, add: addBattlefield},
@ -279,7 +279,8 @@ window.Markers = (function () {
}
function listInns({cells}) {
return cells.i.filter(i => !occupied[i] && cells.h[i] >= 20 && cells.road[i] > 4 && cells.pop[i] > 10);
const crossRoads = cells.i.filter(i => !occupied[i] && cells.pop[i] > 5 && Routes.isCrossroad(i));
return crossRoads;
}
function addInn(id, cell) {
@ -542,7 +543,7 @@ window.Markers = (function () {
function listLighthouses({cells}) {
return cells.i.filter(
i => !occupied[i] && cells.harbor[i] > 6 && cells.c[i].some(c => cells.h[c] < 20 && cells.road[c])
i => !occupied[i] && cells.harbor[i] > 6 && cells.c[i].some(c => cells.h[c] < 20 && Routes.isConnected(c))
);
}
@ -642,7 +643,7 @@ window.Markers = (function () {
function listSeaMonsters({cells, features}) {
return cells.i.filter(
i => !occupied[i] && cells.h[i] < 20 && cells.road[i] && features[cells.f[i]].type === "ocean"
i => !occupied[i] && cells.h[i] < 20 && Routes.isConnected(i) && features[cells.f[i]].type === "ocean"
);
}
@ -792,7 +793,7 @@ window.Markers = (function () {
cells.religion[i] &&
cells.biome[i] === 1 &&
cells.pop[i] > 1 &&
cells.road[i]
Routes.isConnected(i)
);
}
@ -807,7 +808,7 @@ window.Markers = (function () {
}
function listBrigands({cells}) {
return cells.i.filter(i => !occupied[i] && cells.culture[i] && cells.road[i] > 4);
return cells.i.filter(i => !occupied[i] && cells.culture[i] && Routes.hasRoad(i));
}
function addBrigands(id, cell) {
@ -867,7 +868,7 @@ window.Markers = (function () {
// Pirates spawn on sea routes
function listPirates({cells}) {
return cells.i.filter(i => !occupied[i] && cells.h[i] < 20 && cells.road[i]);
return cells.i.filter(i => !occupied[i] && cells.h[i] < 20 && Routes.isConnected(i));
}
function addPirates(id, cell) {
@ -961,7 +962,7 @@ window.Markers = (function () {
}
function listCircuses({cells}) {
return cells.i.filter(i => !occupied[i] && cells.culture[i] && cells.h[i] >= 20 && pack.cells.road[i]);
return cells.i.filter(i => !occupied[i] && cells.culture[i] && cells.h[i] >= 20 && Routes.isConnected(i));
}
function addCircuse(id, cell) {
@ -1254,16 +1255,16 @@ window.Markers = (function () {
const name = `${toponym} ${type}`;
const legend = ra([
"A foreboding necropolis shrouded in perpetual darkness, where eerie whispers echo through the winding corridors and spectral guardians stand watch over the tombs of long-forgotten souls",
"A towering necropolis adorned with macabre sculptures and guarded by formidable undead sentinels. Its ancient halls house the remains of fallen heroes, entombed alongside their cherished relics",
"This ethereal necropolis seems suspended between the realms of the living and the dead. Wisps of mist dance around the tombstones, while haunting melodies linger in the air, commemorating the departed",
"Rising from the desolate landscape, this sinister necropolis is a testament to necromantic power. Its skeletal spires cast ominous shadows, concealing forbidden knowledge and arcane secrets",
"An eerie necropolis where nature intertwines with death. Overgrown tombstones are entwined by thorny vines, and mournful spirits wander among the fading petals of once-vibrant flowers",
"A labyrinthine necropolis where each step echoes with haunting murmurs. The walls are adorned with ancient runes, and restless spirits guide or hinder those who dare to delve into its depths",
"This cursed necropolis is veiled in perpetual twilight, perpetuating a sense of impending doom. Dark enchantments shroud the tombs, and the moans of anguished souls resound through its crumbling halls",
"A sprawling necropolis built within a labyrinthine network of catacombs. Its halls are lined with countless alcoves, each housing the remains of the departed, while the distant sound of rattling bones fills the air",
"A desolate necropolis where an eerie stillness reigns. Time seems frozen amidst the decaying mausoleums, and the silence is broken only by the whispers of the wind and the rustle of tattered banners",
"A foreboding necropolis perched atop a jagged cliff, overlooking a desolate wasteland. Its towering walls harbor restless spirits, and the imposing gates bear the marks of countless battles and ancient curses"
"A foreboding necropolis shrouded in perpetual darkness, where eerie whispers echo through the winding corridors and spectral guardians stand watch over the tombs of long-forgotten souls.",
"A towering necropolis adorned with macabre sculptures and guarded by formidable undead sentinels. Its ancient halls house the remains of fallen heroes, entombed alongside their cherished relics.",
"This ethereal necropolis seems suspended between the realms of the living and the dead. Wisps of mist dance around the tombstones, while haunting melodies linger in the air, commemorating the departed.",
"Rising from the desolate landscape, this sinister necropolis is a testament to necromantic power. Its skeletal spires cast ominous shadows, concealing forbidden knowledge and arcane secrets.",
"An eerie necropolis where nature intertwines with death. Overgrown tombstones are entwined by thorny vines, and mournful spirits wander among the fading petals of once-vibrant flowers.",
"A labyrinthine necropolis where each step echoes with haunting murmurs. The walls are adorned with ancient runes, and restless spirits guide or hinder those who dare to delve into its depths.",
"This cursed necropolis is veiled in perpetual twilight, perpetuating a sense of impending doom. Dark enchantments shroud the tombs, and the moans of anguished souls resound through its crumbling halls.",
"A sprawling necropolis built within a labyrinthine network of catacombs. Its halls are lined with countless alcoves, each housing the remains of the departed, while the distant sound of rattling bones fills the air.",
"A desolate necropolis where an eerie stillness reigns. Time seems frozen amidst the decaying mausoleums, and the silence is broken only by the whispers of the wind and the rustle of tattered banners.",
"A foreboding necropolis perched atop a jagged cliff, overlooking a desolate wasteland. Its towering walls harbor restless spirits, and the imposing gates bear the marks of countless battles and ancient curses."
]);
notes.push({id, name, legend});

View file

@ -692,7 +692,7 @@ window.Religions = (function () {
// growth algorithm to assign cells to religions
function expandReligions(religions) {
const cells = pack.cells;
const {cells, routes} = pack;
const religionIds = spreadFolkReligions(religions);
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
@ -700,8 +700,6 @@ window.Religions = (function () {
const maxExpansionCost = (cells.i.length / 20) * neutralInput.value; // limit cost for organized religions growth
const biomePassageCost = cellId => biomesData.cost[cells.biome[cellId]];
religions
.filter(r => r.i && !r.lock && r.type !== "Folk" && !r.removed)
.forEach(r => {
@ -712,11 +710,6 @@ window.Religions = (function () {
const religionsMap = new Map(religions.map(r => [r.i, r]));
const isMainRoad = cellId => cells.road[cellId] - cells.crossroad[cellId] > 4;
const isTrail = cellId => cells.h[cellId] > 19 && cells.road[cellId] - cells.crossroad[cellId] === 1;
const isSeaRoute = cellId => cells.h[cellId] < 20 && cells.road[cellId];
const isWater = cellId => cells.h[cellId] < 20;
while (queue.length) {
const {e: cellId, p, r, s: state} = queue.dequeue();
const {culture, expansion, expansionism} = religionsMap.get(r);
@ -728,7 +721,7 @@ window.Religions = (function () {
const cultureCost = culture !== cells.culture[nextCell] ? 10 : 0;
const stateCost = state !== cells.state[nextCell] ? 10 : 0;
const passageCost = getPassageCost(nextCell);
const passageCost = getPassageCost(cellId, nextCell);
const cellCost = cultureCost + stateCost + passageCost;
const totalCost = p + 10 + cellCost / expansionism;
@ -745,11 +738,18 @@ window.Religions = (function () {
return religionIds;
function getPassageCost(cellId) {
if (isWater(cellId)) return isSeaRoute ? 50 : 500;
if (isMainRoad(cellId)) return 1;
const biomeCost = biomePassageCost(cellId);
return isTrail(cellId) ? biomeCost / 1.5 : biomeCost;
function getPassageCost(cellId, nextCellId) {
const route = Routes.getRoute(cellId, nextCellId);
if (isWater(cellId)) return route ? 50 : 500;
const biomePassageCost = biomesData.cost[cells.biome[nextCellId]];
if (route) {
if (route.group === "roads") return 1;
return biomePassageCost / 3; // trails and other routes
}
return biomePassageCost;
}
}

View file

@ -1,269 +1,743 @@
const ROUTES_SHARP_ANGLE = 135;
const ROUTES_VERY_SHARP_ANGLE = 115;
window.Routes = (function () {
const getRoads = function () {
TIME && console.time("generateMainRoads");
const cells = pack.cells;
const burgs = pack.burgs.filter(b => b.i && !b.removed);
const capitals = burgs.filter(b => b.capital).sort((a, b) => a.population - b.population);
function generate(lockedRoutes = []) {
const {capitalsByFeature, burgsByFeature, portsByFeature} = sortBurgsByFeature(pack.burgs);
if (capitals.length < 2) return []; // not enough capitals to build main roads
const paths = []; // array to store path segments
const connections = new Map();
lockedRoutes.forEach(route => addConnections(route.points.map(p => p[2])));
for (const b of capitals) {
const connect = capitals.filter(c => c.feature === b.feature && c !== b);
for (const t of connect) {
const [from, exit] = findLandPath(b.cell, t.cell, true);
const segments = restorePath(b.cell, exit, "main", from);
segments.forEach(s => paths.push(s));
}
}
const mainRoads = generateMainRoads();
const trails = generateTrails();
const seaRoutes = generateSeaRoutes();
cells.i.forEach(i => (cells.s[i] += cells.road[i] / 2)); // add roads to suitability score
TIME && console.timeEnd("generateMainRoads");
return paths;
};
pack.routes = createRoutesData(lockedRoutes);
pack.cells.routes = buildLinks(pack.routes);
const getTrails = function () {
TIME && console.time("generateTrails");
const cells = pack.cells;
const burgs = pack.burgs.filter(b => b.i && !b.removed);
function sortBurgsByFeature(burgs) {
const burgsByFeature = {};
const capitalsByFeature = {};
const portsByFeature = {};
if (burgs.length < 2) return []; // not enough burgs to build trails
const addBurg = (object, feature, burg) => {
if (!object[feature]) object[feature] = [];
object[feature].push(burg);
};
let paths = []; // array to store path segments
for (const f of pack.features.filter(f => f.land)) {
const isle = burgs.filter(b => b.feature === f.i); // burgs on island
if (isle.length < 2) continue;
isle.forEach(function (b, i) {
let path = [];
if (!i) {
// build trail from the first burg on island
// to the farthest one on the same island or the closest road
const farthest = d3.scan(isle, (a, c) => (c.y - b.y) ** 2 + (c.x - b.x) ** 2 - ((a.y - b.y) ** 2 + (a.x - b.x) ** 2));
const to = isle[farthest].cell;
if (cells.road[to]) return;
const [from, exit] = findLandPath(b.cell, to, true);
path = restorePath(b.cell, exit, "small", from);
} else {
// build trail from all other burgs to the closest road on the same island
if (cells.road[b.cell]) return;
const [from, exit] = findLandPath(b.cell, null, true);
if (exit === null) return;
path = restorePath(b.cell, exit, "small", from);
}
if (path) paths = paths.concat(path);
});
}
TIME && console.timeEnd("generateTrails");
return paths;
};
const getSearoutes = function () {
TIME && console.time("generateSearoutes");
const {cells, burgs, features} = pack;
const allPorts = burgs.filter(b => b.port > 0 && !b.removed);
if (!allPorts.length) return [];
const bodies = new Set(allPorts.map(b => b.port)); // water features with ports
let paths = []; // array to store path segments
const connected = []; // store cell id of connected burgs
bodies.forEach(f => {
const ports = allPorts.filter(b => b.port === f); // all ports on the same feature
if (!ports.length) return;
if (features[f]?.border) addOverseaRoute(f, ports[0]);
// get inner-map routes
for (let s = 0; s < ports.length; s++) {
const source = ports[s].cell;
if (connected[source]) continue;
for (let t = s + 1; t < ports.length; t++) {
const target = ports[t].cell;
if (connected[target]) continue;
const [from, exit, passable] = findOceanPath(target, source, true);
if (!passable) continue;
const path = restorePath(target, exit, "ocean", from);
paths = paths.concat(path);
connected[source] = 1;
connected[target] = 1;
for (const burg of burgs) {
if (burg.i && !burg.removed) {
const {feature, capital, port} = burg;
addBurg(burgsByFeature, feature, burg);
if (capital) addBurg(capitalsByFeature, feature, burg);
if (port) addBurg(portsByFeature, port, burg);
}
}
});
function addOverseaRoute(f, port) {
const {x, y, cell: source} = port;
const dist = p => Math.abs(p[0] - x) + Math.abs(p[1] - y);
const [x1, y1] = [
[0, y],
[x, 0],
[graphWidth, y],
[x, graphHeight]
].sort((a, b) => dist(a) - dist(b))[0];
const target = findCell(x1, y1);
return {burgsByFeature, capitalsByFeature, portsByFeature};
}
if (cells.f[target] === f && cells.h[target] < 20) {
const [from, exit, passable] = findOceanPath(target, source, true);
function generateMainRoads() {
TIME && console.time("generateMainRoads");
const mainRoads = [];
if (passable) {
const path = restorePath(target, exit, "ocean", from);
paths = paths.concat(path);
last(path).push([x1, y1]);
for (const [key, featureCapitals] of Object.entries(capitalsByFeature)) {
const points = featureCapitals.map(burg => [burg.x, burg.y]);
const urquhartEdges = calculateUrquhartEdges(points);
urquhartEdges.forEach(([fromId, toId]) => {
const start = featureCapitals[fromId].cell;
const exit = featureCapitals[toId].cell;
const segments = findPathSegments({isWater: false, connections, start, exit});
for (const segment of segments) {
addConnections(segment);
mainRoads.push({feature: Number(key), cells: segment});
}
});
}
TIME && console.timeEnd("generateMainRoads");
return mainRoads;
}
function generateTrails() {
TIME && console.time("generateTrails");
const trails = [];
for (const [key, featureBurgs] of Object.entries(burgsByFeature)) {
const points = featureBurgs.map(burg => [burg.x, burg.y]);
const urquhartEdges = calculateUrquhartEdges(points);
urquhartEdges.forEach(([fromId, toId]) => {
const start = featureBurgs[fromId].cell;
const exit = featureBurgs[toId].cell;
const segments = findPathSegments({isWater: false, connections, start, exit});
for (const segment of segments) {
addConnections(segment);
trails.push({feature: Number(key), cells: segment});
}
});
}
TIME && console.timeEnd("generateTrails");
return trails;
}
function generateSeaRoutes() {
TIME && console.time("generateSeaRoutes");
const seaRoutes = [];
for (const [featureId, featurePorts] of Object.entries(portsByFeature)) {
const points = featurePorts.map(burg => [burg.x, burg.y]);
const urquhartEdges = calculateUrquhartEdges(points);
urquhartEdges.forEach(([fromId, toId]) => {
const start = featurePorts[fromId].cell;
const exit = featurePorts[toId].cell;
const segments = findPathSegments({isWater: true, connections, start, exit});
for (const segment of segments) {
addConnections(segment);
seaRoutes.push({feature: Number(featureId), cells: segment});
}
});
}
TIME && console.timeEnd("generateSeaRoutes");
return seaRoutes;
}
function addConnections(segment) {
for (let i = 0; i < segment.length; i++) {
const cellId = segment[i];
const nextCellId = segment[i + 1];
if (nextCellId) {
connections.set(`${cellId}-${nextCellId}`, true);
connections.set(`${nextCellId}-${cellId}`, true);
}
}
}
TIME && console.timeEnd("generateSearoutes");
return paths;
};
function findPathSegments({isWater, connections, start, exit}) {
const from = findPath(isWater, start, exit, connections);
if (!from) return [];
const draw = function (main, small, water) {
TIME && console.time("drawRoutes");
const {cells, burgs} = pack;
const {burg, p} = cells;
const getBurgCoords = b => [burgs[b].x, burgs[b].y];
const getPathPoints = cells => cells.map(i => (Array.isArray(i) ? i : burg[i] ? getBurgCoords(burg[i]) : p[i]));
const getPath = segment => round(lineGen(getPathPoints(segment)), 1);
const getPathsHTML = (paths, type) => paths.map((path, i) => `<path id="${type}${i}" d="${getPath(path)}" />`).join("");
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
roads.html(getPathsHTML(main, "road"));
trails.html(getPathsHTML(small, "trail"));
lineGen.curve(d3.curveBundle.beta(1));
searoutes.html(getPathsHTML(water, "searoute"));
TIME && console.timeEnd("drawRoutes");
};
const regenerate = function () {
routes.selectAll("path").remove();
pack.cells.road = new Uint16Array(pack.cells.i.length);
pack.cells.crossroad = new Uint16Array(pack.cells.i.length);
const main = getRoads();
const small = getTrails();
const water = getSearoutes();
draw(main, small, water);
};
return {getRoads, getTrails, getSearoutes, draw, regenerate};
// Find a land path to a specific cell (exit), to a closest road (toRoad), or to all reachable cells (null, null)
function findLandPath(start, exit = null, toRoad = null) {
const cells = pack.cells;
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
const cost = [],
from = [];
queue.queue({e: start, p: 0});
while (queue.length) {
const next = queue.dequeue(),
n = next.e,
p = next.p;
if (toRoad && cells.road[n]) return [from, n];
for (const c of cells.c[n]) {
if (cells.h[c] < 20) continue; // ignore water cells
const stateChangeCost = cells.state && cells.state[c] !== cells.state[n] ? 400 : 0; // trails tend to lay within the same state
const habitability = biomesData.habitability[cells.biome[c]];
if (!habitability) continue; // avoid inhabitable cells (eg. lava, glacier)
const habitedCost = habitability ? Math.max(100 - habitability, 0) : 400; // routes tend to lay within populated areas
const heightChangeCost = Math.abs(cells.h[c] - cells.h[n]) * 10; // routes tend to avoid elevation changes
const heightCost = cells.h[c] > 80 ? cells.h[c] : 0; // routes tend to avoid mountainous areas
const cellCoast = 10 + stateChangeCost + habitedCost + heightChangeCost + heightCost;
const totalCost = p + (cells.road[c] || cells.burg[c] ? cellCoast / 3 : cellCoast);
if (from[c] || totalCost >= cost[c]) continue;
from[c] = n;
if (c === exit) return [from, exit];
cost[c] = totalCost;
queue.queue({e: c, p: totalCost});
}
const pathCells = restorePath(start, exit, from);
const segments = getRouteSegments(pathCells, connections);
return segments;
}
function createRoutesData(routes) {
const pointsArray = preparePointsArray();
for (const {feature, cells, merged} of mergeRoutes(mainRoads)) {
if (merged) continue;
const points = getPoints("roads", cells, pointsArray);
routes.push({i: routes.length, group: "roads", feature, points});
}
for (const {feature, cells, merged} of mergeRoutes(trails)) {
if (merged) continue;
const points = getPoints("trails", cells, pointsArray);
routes.push({i: routes.length, group: "trails", feature, points});
}
for (const {feature, cells, merged} of mergeRoutes(seaRoutes)) {
if (merged) continue;
const points = getPoints("searoutes", cells, pointsArray);
routes.push({i: routes.length, group: "searoutes", feature, points});
}
return routes;
}
// merge routes so that the last cell of one route is the first cell of the next route
function mergeRoutes(routes) {
let routesMerged = 0;
for (let i = 0; i < routes.length; i++) {
const thisRoute = routes[i];
if (thisRoute.merged) continue;
for (let j = i + 1; j < routes.length; j++) {
const nextRoute = routes[j];
if (nextRoute.merged) continue;
if (nextRoute.cells.at(0) === thisRoute.cells.at(-1)) {
routesMerged++;
thisRoute.cells = thisRoute.cells.concat(nextRoute.cells.slice(1));
nextRoute.merged = true;
}
}
}
return routesMerged > 1 ? mergeRoutes(routes) : routes;
}
function buildLinks(routes) {
const links = {};
for (const {points, i: routeId} of routes) {
const cells = points.map(p => p[2]);
for (let i = 0; i < cells.length - 1; i++) {
const cellId = cells[i];
const nextCellId = cells[i + 1];
if (cellId !== nextCellId) {
if (!links[cellId]) links[cellId] = {};
links[cellId][nextCellId] = routeId;
if (!links[nextCellId]) links[nextCellId] = {};
links[nextCellId][cellId] = routeId;
}
}
}
return links;
}
return [from, exit];
}
function restorePath(start, end, type, from) {
const cells = pack.cells;
const path = []; // to store all segments;
let segment = [],
current = end,
prev = end;
const score = type === "main" ? 5 : 1; // to increase road score at cell
function preparePointsArray() {
const {cells, burgs} = pack;
return cells.p.map(([x, y], cellId) => {
const burgId = cells.burg[cellId];
if (burgId) return [burgs[burgId].x, burgs[burgId].y];
return [x, y];
});
}
if (type === "ocean" || !cells.road[prev]) segment.push(end);
if (!cells.road[prev]) cells.road[prev] = score;
function getPoints(group, cells, points) {
const data = cells.map(cellId => [...points[cellId], cellId]);
for (let i = 0, limit = 1000; i < limit; i++) {
if (!from[current]) break;
current = from[current];
// resolve sharp angles
if (group !== "searoutes") {
for (let i = 1; i < cells.length - 1; i++) {
const cellId = cells[i];
if (pack.cells.burg[cellId]) continue;
if (cells.road[current]) {
if (segment.length) {
segment.push(current);
path.push(segment);
if (segment[0] !== end) {
cells.road[segment[0]] += score;
cells.crossroad[segment[0]] += score;
const [prevX, prevY] = data[i - 1];
const [currX, currY] = data[i];
const [nextX, nextY] = data[i + 1];
const dAx = prevX - currX;
const dAy = prevY - currY;
const dBx = nextX - currX;
const dBy = nextY - currY;
const angle = Math.abs((Math.atan2(dAx * dBy - dAy * dBx, dAx * dBx + dAy * dBy) * 180) / Math.PI);
if (angle < ROUTES_SHARP_ANGLE) {
const middleX = (prevX + nextX) / 2;
const middleY = (prevY + nextY) / 2;
let newX, newY;
if (angle < ROUTES_VERY_SHARP_ANGLE) {
newX = rn((currX + middleX * 2) / 3, 2);
newY = rn((currY + middleY * 2) / 3, 2);
} else {
newX = rn((currX + middleX) / 2, 2);
newY = rn((currY + middleY) / 2, 2);
}
if (current !== start) {
cells.road[current] += score;
cells.crossroad[current] += score;
if (findCell(newX, newY) === cellId) {
data[i] = [newX, newY, cellId];
points[cellId] = [data[i][0], data[i][1]]; // change cell coordinate for all routes
}
}
segment = [];
prev = current;
} else {
if (prev) segment.push(prev);
prev = null;
segment.push(current);
}
cells.road[current] += score;
if (current === start) break;
}
if (segment.length > 1) path.push(segment);
return data; // [[x, y, cell], [x, y, cell]];
}
const MIN_PASSABLE_SEA_TEMP = -4;
const TYPE_MODIFIERS = {
"-1": 1, // coastline
"-2": 1.8, // sea
"-3": 4, // open sea
"-4": 6, // ocean
default: 8 // far ocean
};
function findPath(isWater, start, exit, connections) {
const {temp} = grid.cells;
const {cells} = pack;
const from = [];
const cost = [];
const queue = new FlatQueue();
queue.push(start, 0);
return isWater ? findWaterPath() : findLandPath();
function findLandPath() {
while (queue.length) {
const priority = queue.peekValue();
const next = queue.pop();
for (const neibCellId of cells.c[next]) {
if (neibCellId === exit) {
from[neibCellId] = next;
return from;
}
if (cells.h[neibCellId] < 20) continue; // ignore water cells
const habitability = biomesData.habitability[cells.biome[neibCellId]];
if (!habitability) continue; // inhabitable cells are not passable (eg. lava, glacier)
const distanceCost = dist2(cells.p[next], cells.p[neibCellId]);
const habitabilityModifier = 1 + Math.max(100 - habitability, 0) / 1000; // [1, 1.1];
const heightModifier = 1 + Math.max(cells.h[neibCellId] - 25, 25) / 25; // [1, 3];
const connectionModifier = connections.has(`${next}-${neibCellId}`) ? 1 : 2;
const burgModifier = cells.burg[neibCellId] ? 1 : 3;
const cellsCost = distanceCost * habitabilityModifier * heightModifier * connectionModifier * burgModifier;
const totalCost = priority + cellsCost;
if (totalCost >= cost[neibCellId]) continue;
from[neibCellId] = next;
cost[neibCellId] = totalCost;
queue.push(neibCellId, totalCost);
}
}
return null; // path is not found
}
function findWaterPath() {
while (queue.length) {
const priority = queue.peekValue();
const next = queue.pop();
for (const neibCellId of cells.c[next]) {
if (neibCellId === exit) {
from[neibCellId] = next;
return from;
}
if (cells.h[neibCellId] >= 20) continue; // ignore land cells
if (temp[cells.g[neibCellId]] < MIN_PASSABLE_SEA_TEMP) continue; // ignore too cold cells
const distanceCost = dist2(cells.p[next], cells.p[neibCellId]);
const typeModifier = TYPE_MODIFIERS[cells.t[neibCellId]] || TYPE_MODIFIERS.default;
const connectionModifier = connections.has(`${next}-${neibCellId}`) ? 1 : 2;
const cellsCost = distanceCost * typeModifier * connectionModifier;
const totalCost = priority + cellsCost;
if (totalCost >= cost[neibCellId]) continue;
from[neibCellId] = next;
cost[neibCellId] = totalCost;
queue.push(neibCellId, totalCost);
}
}
return null; // path is not found
}
}
function restorePath(start, end, from) {
const cells = [];
let current = end;
let prev = end;
while (current !== start) {
cells.push(current);
prev = from[current];
current = prev;
}
cells.push(current);
return cells;
}
function getRouteSegments(pathCells, connections) {
const segments = [];
let segment = [];
for (let i = 0; i < pathCells.length; i++) {
const cellId = pathCells[i];
const nextCellId = pathCells[i + 1];
const isConnected = connections.has(`${cellId}-${nextCellId}`) || connections.has(`${nextCellId}-${cellId}`);
if (isConnected) {
if (segment.length) {
// segment stepped into existing segment
segment.push(pathCells[i]);
segments.push(segment);
segment = [];
}
continue;
}
segment.push(pathCells[i]);
}
if (segment.length > 1) segments.push(segment);
return segments;
}
// Urquhart graph is obtained by removing the longest edge from each triangle in the Delaunay triangulation
// this gives us an aproximation of a desired road network, i.e. connections between burgs
// code from https://observablehq.com/@mbostock/urquhart-graph
function calculateUrquhartEdges(points) {
const score = (p0, p1) => dist2(points[p0], points[p1]);
const {halfedges, triangles} = Delaunator.from(points);
const n = triangles.length;
const removed = new Uint8Array(n);
const edges = [];
for (let e = 0; e < n; e += 3) {
const p0 = triangles[e],
p1 = triangles[e + 1],
p2 = triangles[e + 2];
const p01 = score(p0, p1),
p12 = score(p1, p2),
p20 = score(p2, p0);
removed[
p20 > p01 && p20 > p12
? Math.max(e + 2, halfedges[e + 2])
: p12 > p01 && p12 > p20
? Math.max(e + 1, halfedges[e + 1])
: Math.max(e, halfedges[e])
] = 1;
}
for (let e = 0; e < n; ++e) {
if (e > halfedges[e] && !removed[e]) {
const t0 = triangles[e];
const t1 = triangles[e % 3 === 2 ? e - 2 : e + 1];
edges.push([t0, t1]);
}
}
return edges;
}
// connect cell with routes system by land
function connect(cellId) {
if (isConnected(cellId)) return;
const {cells, routes} = pack;
const path = findConnectionPath(cellId);
if (!path) return;
const pathCells = restorePath(...path);
const pointsArray = preparePointsArray();
const points = getPoints("trails", pathCells, pointsArray);
const feature = cells.f[cellId];
const routeId = Math.max(...routes.map(route => route.i)) + 1;
const newRoute = {i: routeId, group: "trails", feature, points};
routes.push(newRoute);
for (let i = 0; i < pathCells.length; i++) {
const cellId = pathCells[i];
const nextCellId = pathCells[i + 1];
if (nextCellId) addConnection(cellId, nextCellId, routeId);
}
return newRoute;
function findConnectionPath(start) {
const from = [];
const cost = [];
const queue = new FlatQueue();
queue.push(start, 0);
while (queue.length) {
const priority = queue.peekValue();
const next = queue.pop();
for (const neibCellId of cells.c[next]) {
if (isConnected(neibCellId)) {
from[neibCellId] = next;
return [start, neibCellId, from];
}
if (cells.h[neibCellId] < 20) continue; // ignore water cells
const habitability = biomesData.habitability[cells.biome[neibCellId]];
if (!habitability) continue; // inhabitable cells are not passable (eg. lava, glacier)
const distanceCost = dist2(cells.p[next], cells.p[neibCellId]);
const habitabilityModifier = 1 + Math.max(100 - habitability, 0) / 1000; // [1, 1.1];
const heightModifier = 1 + Math.max(cells.h[neibCellId] - 25, 25) / 25; // [1, 3];
const cellsCost = distanceCost * habitabilityModifier * heightModifier;
const totalCost = priority + cellsCost;
if (totalCost >= cost[neibCellId]) continue;
from[neibCellId] = next;
cost[neibCellId] = totalCost;
queue.push(neibCellId, totalCost);
}
}
return null; // path is not found
}
function addConnection(from, to, routeId) {
const routes = pack.cells.routes;
if (!routes[from]) routes[from] = {};
routes[from][to] = routeId;
if (!routes[to]) routes[to] = {};
routes[to][from] = routeId;
}
}
// utility functions
function isConnected(cellId) {
const {routes} = pack.cells;
return routes[cellId] && Object.keys(routes[cellId]).length > 0;
}
function areConnected(from, to) {
const routeId = pack.cells.routes[from]?.[to];
return routeId !== undefined;
}
function getRoute(from, to) {
const routeId = pack.cells.routes[from]?.[to];
return routeId === undefined ? null : pack.routes[routeId];
}
function hasRoad(cellId) {
const connections = pack.cells.routes[cellId];
if (!connections) return false;
return Object.values(connections).some(routeId => pack.routes[routeId].group === "roads");
}
function isCrossroad(cellId) {
const connections = pack.cells.routes[cellId];
if (!connections) return false;
return (
Object.keys(connections).length > 3 ||
Object.values(connections).filter(routeId => pack.routes[routeId].group === "roads").length > 2
);
}
// name generator data
const models = {
roads: {burg_suffix: 3, prefix_suffix: 6, the_descriptor_prefix_suffix: 2, the_descriptor_burg_suffix: 1},
trails: {burg_suffix: 8, prefix_suffix: 1, the_descriptor_burg_suffix: 1},
searoutes: {burg_suffix: 4, prefix_suffix: 2, the_descriptor_prefix_suffix: 1}
};
const prefixes = [
"King",
"Queen",
"Military",
"Old",
"New",
"Ancient",
"Royal",
"Imperial",
"Great",
"Grand",
"High",
"Silver",
"Dragon",
"Shadow",
"Star",
"Mystic",
"Whisper",
"Eagle",
"Golden",
"Crystal",
"Enchanted",
"Frost",
"Moon",
"Sun",
"Thunder",
"Phoenix",
"Sapphire",
"Celestial",
"Wandering",
"Echo",
"Twilight",
"Crimson",
"Serpent",
"Iron",
"Forest",
"Flower",
"Whispering",
"Eternal",
"Frozen",
"Rain",
"Luminous",
"Stardust",
"Arcane",
"Glimmering",
"Jade",
"Ember",
"Azure",
"Gilded",
"Divine",
"Shadowed",
"Cursed",
"Moonlit",
"Sable",
"Everlasting",
"Amber",
"Nightshade",
"Wraith",
"Scarlet",
"Platinum",
"Whirlwind",
"Obsidian",
"Ethereal",
"Ghost",
"Spike",
"Dusk",
"Raven",
"Spectral",
"Burning",
"Verdant",
"Copper",
"Velvet",
"Falcon",
"Enigma",
"Glowing",
"Silvered",
"Molten",
"Radiant",
"Astral",
"Wild",
"Flame",
"Amethyst",
"Aurora",
"Shadowy",
"Solar",
"Lunar",
"Whisperwind",
"Fading",
"Titan",
"Dawn",
"Crystalline",
"Jeweled",
"Sylvan",
"Twisted",
"Ebon",
"Thorn",
"Cerulean",
"Halcyon",
"Infernal",
"Storm",
"Eldritch",
"Sapphire",
"Crimson",
"Tranquil",
"Paved"
];
const descriptors = [
"Great",
"Shrouded",
"Sacred",
"Fabled",
"Frosty",
"Winding",
"Echoing",
"Serpentine",
"Breezy",
"Misty",
"Rustic",
"Silent",
"Cobbled",
"Cracked",
"Shaky",
"Obscure"
];
const suffixes = {
roads: {road: 7, route: 3, way: 2, highway: 1},
trails: {trail: 4, path: 1, track: 1, pass: 1},
searoutes: {"sea route": 5, lane: 2, passage: 1, seaway: 1}
};
function generateName({group, points}) {
if (points.length < 4) return "Unnamed route segment";
const model = rw(models[group]);
const suffix = rw(suffixes[group]);
const burgName = getBurgName();
if (model === "burg_suffix" && burgName) return `${burgName} ${suffix}`;
if (model === "prefix_suffix") return `${ra(prefixes)} ${suffix}`;
if (model === "the_descriptor_prefix_suffix") return `The ${ra(descriptors)} ${ra(prefixes)} ${suffix}`;
if (model === "the_descriptor_burg_suffix" && burgName) return `The ${ra(descriptors)} ${burgName} ${suffix}`;
return "Unnamed route";
function getBurgName() {
const priority = [points.at(-1), points.at(0), points.slice(1, -1).reverse()];
for (const [_x, _y, cellId] of priority) {
const burgId = pack.cells.burg[cellId];
if (burgId) return getAdjective(pack.burgs[burgId].name);
}
return null;
}
}
const ROUTE_CURVES = {
roads: d3.curveCatmullRom.alpha(0.1),
trails: d3.curveCatmullRom.alpha(0.1),
searoutes: d3.curveCatmullRom.alpha(0.5),
default: d3.curveCatmullRom.alpha(0.1)
};
function getPath({group, points}) {
const lineGen = d3.line();
lineGen.curve(ROUTE_CURVES[group] || ROUTE_CURVES.default);
const path = round(lineGen(points.map(p => [p[0], p[1]])), 1);
return path;
}
// find water paths
function findOceanPath(start, exit = null, toRoute = null) {
const cells = pack.cells,
temp = grid.cells.temp;
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
const cost = [],
from = [];
queue.queue({e: start, p: 0});
function getLength(routeId) {
const path = routes.select("#route" + routeId).node();
return path.getTotalLength();
}
while (queue.length) {
const next = queue.dequeue(),
n = next.e,
p = next.p;
if (toRoute && n !== start && cells.road[n]) return [from, n, true];
function remove(route) {
const routes = pack.cells.routes;
for (const c of cells.c[n]) {
if (c === exit) {
from[c] = n;
return [from, exit, true];
for (const point of route.points) {
const from = point[2];
for (const [to, routeId] of Object.entries(routes[from])) {
if (routeId === route.i) {
delete routes[from][to];
delete routes[to][from];
}
if (cells.h[c] >= 20) continue; // ignore land cells
if (temp[cells.g[c]] <= -5) continue; // ignore cells with term <= -5
const dist2 = (cells.p[c][1] - cells.p[n][1]) ** 2 + (cells.p[c][0] - cells.p[n][0]) ** 2;
const totalCost = p + (cells.road[c] ? 1 + dist2 / 2 : dist2 + (cells.t[c] ? 1 : 100));
if (from[c] || totalCost >= cost[c]) continue;
(from[c] = n), (cost[c] = totalCost);
queue.queue({e: c, p: totalCost});
}
}
return [from, exit, false];
pack.routes = pack.routes.filter(r => r.i !== route.i);
viewbox
.select("#routes")
.select("#route" + route.i)
.remove();
}
return {
generate,
connect,
isConnected,
areConnected,
getRoute,
hasRoad,
isCrossroad,
generateName,
getPath,
getLength,
remove
};
})();

View file

@ -145,8 +145,6 @@ window.Submap = (function () {
cells.state = new Uint16Array(pn);
cells.burg = new Uint16Array(pn);
cells.religion = new Uint16Array(pn);
cells.road = new Uint16Array(pn);
cells.crossroad = new Uint16Array(pn);
cells.province = new Uint16Array(pn);
stage("Resampling culture, state and religion map.");
@ -272,8 +270,8 @@ window.Submap = (function () {
BurgsAndStates.drawBurgs();
stage("Regenerating road network.");
Routes.regenerate();
stage("Regenerating routes network.");
regenerateRoutes();
drawStates();
drawBorders();
@ -397,7 +395,7 @@ window.Submap = (function () {
return;
}
DEBUG && console.info(`Moving ${b.name} from ${cityCell} to ${newCell} near ${neighbor}.`);
[b.x, b.y] = b.port ? getMiddlePoint(newCell, neighbor) : cells.p[newCell];
[b.x, b.y] = b.port ? getCloseToEdgePoint(newCell, neighbor) : cells.p[newCell];
if (b.port) b.port = cells.f[neighbor]; // copy feature number
b.cell = newCell;
if (b.port && !isWater(pack, neighbor)) console.error("betrayal! negihbor must be water!", b);
@ -409,6 +407,23 @@ window.Submap = (function () {
});
}
function getCloseToEdgePoint(cell1, cell2) {
const {cells, vertices} = pack;
const [x0, y0] = cells.p[cell1];
const commonVertices = cells.v[cell1].filter(vertex => vertices.c[vertex].some(cell => cell === cell2));
const [x1, y1] = vertices.p[commonVertices[0]];
const [x2, y2] = vertices.p[commonVertices[1]];
const xEdge = (x1 + x2) / 2;
const yEdge = (y1 + y2) / 2;
const x = rn(x0 + 0.95 * (xEdge - x0), 2);
const y = rn(y0 + 0.95 * (yEdge - y0), 2);
return [x, y];
}
// export
return {resample, findNearest};
})();

View file

@ -37,12 +37,22 @@ class Battle {
// add listeners
document.getElementById("battleType").addEventListener("click", ev => this.toggleChange(ev));
document.getElementById("battleType").nextElementSibling.addEventListener("click", ev => Battle.prototype.context.changeType(ev));
document.getElementById("battleNameShow").addEventListener("click", () => Battle.prototype.context.showNameSection());
document.getElementById("battleNamePlace").addEventListener("change", ev => (Battle.prototype.context.place = ev.target.value));
document
.getElementById("battleType")
.nextElementSibling.addEventListener("click", ev => Battle.prototype.context.changeType(ev));
document
.getElementById("battleNameShow")
.addEventListener("click", () => Battle.prototype.context.showNameSection());
document
.getElementById("battleNamePlace")
.addEventListener("change", ev => (Battle.prototype.context.place = ev.target.value));
document.getElementById("battleNameFull").addEventListener("change", ev => Battle.prototype.context.changeName(ev));
document.getElementById("battleNameCulture").addEventListener("click", () => Battle.prototype.context.generateName("culture"));
document.getElementById("battleNameRandom").addEventListener("click", () => Battle.prototype.context.generateName("random"));
document
.getElementById("battleNameCulture")
.addEventListener("click", () => Battle.prototype.context.generateName("culture"));
document
.getElementById("battleNameRandom")
.addEventListener("click", () => Battle.prototype.context.generateName("random"));
document.getElementById("battleNameHide").addEventListener("click", this.hideNameSection);
document.getElementById("battleAddRegiment").addEventListener("click", this.addSide);
document.getElementById("battleRoll").addEventListener("click", () => Battle.prototype.context.randomize());
@ -52,11 +62,19 @@ class Battle {
document.getElementById("battleWiki").addEventListener("click", () => wiki("Battle-Simulator"));
document.getElementById("battlePhase_attackers").addEventListener("click", ev => this.toggleChange(ev));
document.getElementById("battlePhase_attackers").nextElementSibling.addEventListener("click", ev => Battle.prototype.context.changePhase(ev, "attackers"));
document
.getElementById("battlePhase_attackers")
.nextElementSibling.addEventListener("click", ev => Battle.prototype.context.changePhase(ev, "attackers"));
document.getElementById("battlePhase_defenders").addEventListener("click", ev => this.toggleChange(ev));
document.getElementById("battlePhase_defenders").nextElementSibling.addEventListener("click", ev => Battle.prototype.context.changePhase(ev, "defenders"));
document.getElementById("battleDie_attackers").addEventListener("click", () => Battle.prototype.context.rollDie("attackers"));
document.getElementById("battleDie_defenders").addEventListener("click", () => Battle.prototype.context.rollDie("defenders"));
document
.getElementById("battlePhase_defenders")
.nextElementSibling.addEventListener("click", ev => Battle.prototype.context.changePhase(ev, "defenders"));
document
.getElementById("battleDie_attackers")
.addEventListener("click", () => Battle.prototype.context.rollDie("attackers"));
document
.getElementById("battleDie_defenders")
.addEventListener("click", () => Battle.prototype.context.rollDie("defenders"));
}
defineType() {
@ -82,8 +100,12 @@ class Battle {
document.getElementById("battleType").className = "icon-button-" + this.type;
const sideSpecific = document.getElementById("battlePhases_" + this.type + "_attackers");
const attackers = sideSpecific ? sideSpecific.content : document.getElementById("battlePhases_" + this.type).content;
const defenders = sideSpecific ? document.getElementById("battlePhases_" + this.type + "_defenders").content : attackers;
const attackers = sideSpecific
? sideSpecific.content
: document.getElementById("battlePhases_" + this.type).content;
const defenders = sideSpecific
? document.getElementById("battlePhases_" + this.type + "_defenders").content
: attackers;
document.getElementById("battlePhase_attackers").nextElementSibling.innerHTML = "";
document.getElementById("battlePhase_defenders").nextElementSibling.innerHTML = "";
@ -139,26 +161,37 @@ class Battle {
regiment.survivors = Object.assign({}, regiment.u);
const state = pack.states[regiment.state];
const distance = (Math.hypot(this.y - regiment.by, this.x - regiment.bx) * distanceScaleInput.value) | 0; // distance between regiment and its base
const distance = (Math.hypot(this.y - regiment.by, this.x - regiment.bx) * distanceScale) | 0; // distance between regiment and its base
const color = state.color[0] === "#" ? state.color : "#999";
const icon = `<svg width="1.4em" height="1.4em" style="margin-bottom: -.6em; stroke: #333">
<rect x="0" y="0" width="100%" height="100%" fill="${color}"></rect>
<text x="0" y="1.04em" style="">${regiment.icon}</text></svg>`;
const body = `<tbody id="battle${state.i}-${regiment.i}">`;
let initial = `<tr class="battleInitial"><td>${icon}</td><td class="regiment" data-tip="${regiment.name}">${regiment.name.slice(0, 24)}</td>`;
let casualties = `<tr class="battleCasualties"><td></td><td data-tip="${state.fullName}">${state.fullName.slice(0, 26)}</td>`;
let initial = `<tr class="battleInitial"><td>${icon}</td><td class="regiment" data-tip="${
regiment.name
}">${regiment.name.slice(0, 24)}</td>`;
let casualties = `<tr class="battleCasualties"><td></td><td data-tip="${state.fullName}">${state.fullName.slice(
0,
26
)}</td>`;
let survivors = `<tr class="battleSurvivors"><td></td><td data-tip="Supply line length, affects morale">Distance to base: ${distance} ${distanceUnitInput.value}</td>`;
for (const u of options.military) {
initial += `<td data-tip="Initial forces" style="width: 2.5em; text-align: center">${regiment.u[u.name] || 0}</td>`;
initial += `<td data-tip="Initial forces" style="width: 2.5em; text-align: center">${
regiment.u[u.name] || 0
}</td>`;
casualties += `<td data-tip="Casualties" style="width: 2.5em; text-align: center; color: red">0</td>`;
survivors += `<td data-tip="Survivors" style="width: 2.5em; text-align: center; color: green">${regiment.u[u.name] || 0}</td>`;
survivors += `<td data-tip="Survivors" style="width: 2.5em; text-align: center; color: green">${
regiment.u[u.name] || 0
}</td>`;
}
initial += `<td data-tip="Initial forces" style="width: 2.5em; text-align: center">${regiment.a || 0}</td></tr>`;
casualties += `<td data-tip="Casualties" style="width: 2.5em; text-align: center; color: red">0</td></tr>`;
survivors += `<td data-tip="Survivors" style="width: 2.5em; text-align: center; color: green">${regiment.a || 0}</td></tr>`;
survivors += `<td data-tip="Survivors" style="width: 2.5em; text-align: center; color: green">${
regiment.a || 0
}</td></tr>`;
const div = side === "attackers" ? battleAttackers : battleDefenders;
div.innerHTML += body + initial + casualties + survivors + "</tbody>";
@ -173,17 +206,23 @@ class Battle {
.filter(s => s.military && !s.removed)
.map(s => s.military)
.flat();
const distance = reg => rn(Math.hypot(context.y - reg.y, context.x - reg.x) * distanceScaleInput.value) + " " + distanceUnitInput.value;
const isAdded = reg => context.defenders.regiments.some(r => r === reg) || context.attackers.regiments.some(r => r === reg);
const distance = reg =>
rn(Math.hypot(context.y - reg.y, context.x - reg.x) * distanceScale) + " " + distanceUnitInput.value;
const isAdded = reg =>
context.defenders.regiments.some(r => r === reg) || context.attackers.regiments.some(r => r === reg);
body.innerHTML = regiments
.map(r => {
const s = pack.states[r.state],
added = isAdded(r),
dist = added ? "0 " + distanceUnitInput.value : distance(r);
return `<div ${added ? "class='inactive'" : ""} data-s=${s.i} data-i=${r.i} data-state=${s.name} data-regiment=${r.name}
return `<div ${added ? "class='inactive'" : ""} data-s=${s.i} data-i=${r.i} data-state=${
s.name
} data-regiment=${r.name}
data-total=${r.a} data-distance=${dist} data-tip="Click to select regiment">
<svg width=".9em" height=".9em" style="margin-bottom:-1px; stroke: #333"><rect x="0" y="0" width="100%" height="100%" fill="${s.color}" ></svg>
<svg width=".9em" height=".9em" style="margin-bottom:-1px; stroke: #333"><rect x="0" y="0" width="100%" height="100%" fill="${
s.color
}" ></svg>
<div style="width:6em">${s.name.slice(0, 11)}</div>
<div style="width:1.2em">${r.icon}</div>
<div style="width:13em">${r.name.slice(0, 24)}</div>
@ -267,7 +306,10 @@ class Battle {
}
generateName(type) {
const place = type === "culture" ? Names.getCulture(pack.cells.culture[this.cell], null, null, "") : Names.getBase(rand(nameBases.length - 1));
const place =
type === "culture"
? Names.getCulture(pack.cells.culture[this.cell], null, null, "")
: Names.getBase(rand(nameBases.length - 1));
document.getElementById("battleNamePlace").value = this.place = place;
document.getElementById("battleNameFull").value = this.name = this.defineName();
$("#battleScreen").dialog({title: this.name});
@ -286,35 +328,161 @@ class Battle {
calculateStrength(side) {
const scheme = {
// field battle phases
skirmish: {melee: 0.2, ranged: 2.4, mounted: 0.1, machinery: 3, naval: 1, armored: 0.2, aviation: 1.8, magical: 1.8}, // ranged excel
skirmish: {
melee: 0.2,
ranged: 2.4,
mounted: 0.1,
machinery: 3,
naval: 1,
armored: 0.2,
aviation: 1.8,
magical: 1.8
}, // ranged excel
melee: {melee: 2, ranged: 1.2, mounted: 1.5, machinery: 0.5, naval: 0.2, armored: 2, aviation: 0.8, magical: 0.8}, // melee excel
pursue: {melee: 1, ranged: 1, mounted: 4, machinery: 0.05, naval: 1, armored: 1, aviation: 1.5, magical: 0.6}, // mounted excel
retreat: {melee: 0.1, ranged: 0.01, mounted: 0.5, machinery: 0.01, naval: 0.2, armored: 0.1, aviation: 0.8, magical: 0.05}, // reduced
retreat: {
melee: 0.1,
ranged: 0.01,
mounted: 0.5,
machinery: 0.01,
naval: 0.2,
armored: 0.1,
aviation: 0.8,
magical: 0.05
}, // reduced
// naval battle phases
shelling: {melee: 0, ranged: 0.2, mounted: 0, machinery: 2, naval: 2, armored: 0, aviation: 0.1, magical: 0.5}, // naval and machinery excel
boarding: {melee: 1, ranged: 0.5, mounted: 0.5, machinery: 0, naval: 0.5, armored: 0.4, aviation: 0, magical: 0.2}, // melee excel
boarding: {
melee: 1,
ranged: 0.5,
mounted: 0.5,
machinery: 0,
naval: 0.5,
armored: 0.4,
aviation: 0,
magical: 0.2
}, // melee excel
chase: {melee: 0, ranged: 0.15, mounted: 0, machinery: 1, naval: 1, armored: 0, aviation: 0.15, magical: 0.5}, // reduced
withdrawal: {melee: 0, ranged: 0.02, mounted: 0, machinery: 0.5, naval: 0.1, armored: 0, aviation: 0.1, magical: 0.3}, // reduced
withdrawal: {
melee: 0,
ranged: 0.02,
mounted: 0,
machinery: 0.5,
naval: 0.1,
armored: 0,
aviation: 0.1,
magical: 0.3
}, // reduced
// siege phases
blockade: {melee: 0.25, ranged: 0.25, mounted: 0.2, machinery: 0.5, naval: 0.2, armored: 0.1, aviation: 0.25, magical: 0.25}, // no active actions
sheltering: {melee: 0.3, ranged: 0.5, mounted: 0.2, machinery: 0.5, naval: 0.2, armored: 0.1, aviation: 0.25, magical: 0.25}, // no active actions
blockade: {
melee: 0.25,
ranged: 0.25,
mounted: 0.2,
machinery: 0.5,
naval: 0.2,
armored: 0.1,
aviation: 0.25,
magical: 0.25
}, // no active actions
sheltering: {
melee: 0.3,
ranged: 0.5,
mounted: 0.2,
machinery: 0.5,
naval: 0.2,
armored: 0.1,
aviation: 0.25,
magical: 0.25
}, // no active actions
sortie: {melee: 2, ranged: 0.5, mounted: 1.2, machinery: 0.2, naval: 0.1, armored: 0.5, aviation: 1, magical: 1}, // melee excel
bombardment: {melee: 0.2, ranged: 0.5, mounted: 0.2, machinery: 3, naval: 1, armored: 0.5, aviation: 1, magical: 1}, // machinery excel
storming: {melee: 1, ranged: 0.6, mounted: 0.5, machinery: 1, naval: 0.1, armored: 0.1, aviation: 0.5, magical: 0.5}, // melee excel
bombardment: {
melee: 0.2,
ranged: 0.5,
mounted: 0.2,
machinery: 3,
naval: 1,
armored: 0.5,
aviation: 1,
magical: 1
}, // machinery excel
storming: {
melee: 1,
ranged: 0.6,
mounted: 0.5,
machinery: 1,
naval: 0.1,
armored: 0.1,
aviation: 0.5,
magical: 0.5
}, // melee excel
defense: {melee: 2, ranged: 3, mounted: 1, machinery: 1, naval: 0.1, armored: 1, aviation: 0.5, magical: 1}, // ranged excel
looting: {melee: 1.6, ranged: 1.6, mounted: 0.5, machinery: 0.2, naval: 0.02, armored: 0.2, aviation: 0.1, magical: 0.3}, // melee excel
surrendering: {melee: 0.1, ranged: 0.1, mounted: 0.05, machinery: 0.01, naval: 0.01, armored: 0.02, aviation: 0.01, magical: 0.03}, // reduced
looting: {
melee: 1.6,
ranged: 1.6,
mounted: 0.5,
machinery: 0.2,
naval: 0.02,
armored: 0.2,
aviation: 0.1,
magical: 0.3
}, // melee excel
surrendering: {
melee: 0.1,
ranged: 0.1,
mounted: 0.05,
machinery: 0.01,
naval: 0.01,
armored: 0.02,
aviation: 0.01,
magical: 0.03
}, // reduced
// ambush phases
surprise: {melee: 2, ranged: 2.4, mounted: 1, machinery: 1, naval: 1, armored: 1, aviation: 0.8, magical: 1.2}, // increased
shock: {melee: 0.5, ranged: 0.5, mounted: 0.5, machinery: 0.4, naval: 0.3, armored: 0.1, aviation: 0.4, magical: 0.5}, // reduced
shock: {
melee: 0.5,
ranged: 0.5,
mounted: 0.5,
machinery: 0.4,
naval: 0.3,
armored: 0.1,
aviation: 0.4,
magical: 0.5
}, // reduced
// langing phases
landing: {melee: 0.8, ranged: 0.6, mounted: 0.6, machinery: 0.5, naval: 0.5, armored: 0.5, aviation: 0.5, magical: 0.6}, // reduced
flee: {melee: 0.1, ranged: 0.01, mounted: 0.5, machinery: 0.01, naval: 0.5, armored: 0.1, aviation: 0.2, magical: 0.05}, // reduced
waiting: {melee: 0.05, ranged: 0.5, mounted: 0.05, machinery: 0.5, naval: 2, armored: 0.05, aviation: 0.5, magical: 0.5}, // reduced
landing: {
melee: 0.8,
ranged: 0.6,
mounted: 0.6,
machinery: 0.5,
naval: 0.5,
armored: 0.5,
aviation: 0.5,
magical: 0.6
}, // reduced
flee: {
melee: 0.1,
ranged: 0.01,
mounted: 0.5,
machinery: 0.01,
naval: 0.5,
armored: 0.1,
aviation: 0.2,
magical: 0.05
}, // reduced
waiting: {
melee: 0.05,
ranged: 0.5,
mounted: 0.05,
machinery: 0.5,
naval: 2,
armored: 0.05,
aviation: 0.5,
magical: 0.5
}, // reduced
// air battle phases
maneuvering: {melee: 0, ranged: 0.1, mounted: 0, machinery: 0.2, naval: 0, armored: 0, aviation: 1, magical: 0.2}, // aviation
@ -324,7 +492,8 @@ class Battle {
const forces = this.getJoinedForces(this[side].regiments);
const phase = this[side].phase;
const adjuster = Math.max(populationRate / 10, 10); // population adjuster, by default 100
this[side].power = d3.sum(options.military.map(u => (forces[u.name] || 0) * u.power * scheme[phase][u.type])) / adjuster;
this[side].power =
d3.sum(options.military.map(u => (forces[u.name] || 0) * u.power * scheme[phase][u.type])) / adjuster;
const UIvalue = this[side].power ? Math.max(this[side].power | 0, 1) : 0;
document.getElementById("battlePower_" + side).innerHTML = UIvalue;
}
@ -723,11 +892,13 @@ class Battle {
const status = battleStatus[+P(0.7)];
const result = `The ${this.getTypeName(this.type)} ended in ${status}`;
const legend = `${this.name} took place in ${options.year} ${options.eraShort}. It was fought between ${getSide(this.attackers.regiments, 1)} and ${getSide(
this.defenders.regiments,
0
)}. ${result}.
\r\nAttackers losses: ${getLosses(this.attackers.casualties)}%, defenders losses: ${getLosses(this.defenders.casualties)}%`;
const legend = `${this.name} took place in ${options.year} ${options.eraShort}. It was fought between ${getSide(
this.attackers.regiments,
1
)} and ${getSide(this.defenders.regiments, 0)}. ${result}.
\r\nAttackers losses: ${getLosses(this.attackers.casualties)}%, defenders losses: ${getLosses(
this.defenders.casualties
)}%`;
notes.push({id: `marker${i}`, name: this.name, legend});
tip(`${this.name} is over. ${result}`, true, "success", 4000);

View file

@ -390,7 +390,7 @@ function editBiomes() {
const p = d3.mouse(this);
moveCircle(p[0], p[1], r);
const found = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1], r)];
const found = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1])];
const selection = found.filter(isLand);
if (selection) changeBiomeForSelection(selection);
});

View file

@ -36,7 +36,6 @@ function overviewBurgs(settings = {stateId: null, cultureId: null}) {
});
byId("burgsLockAll").addEventListener("click", toggleLockAll);
byId("burgsRemoveAll").addEventListener("click", triggerAllBurgsRemove);
byId("burgsInvertLock").addEventListener("click", invertLock);
function refreshBurgsEditor() {
updateFilter();
@ -279,7 +278,8 @@ function overviewBurgs(settings = {stateId: null, cultureId: null}) {
function addBurgOnClick() {
const point = d3.mouse(this);
const cell = findCell(point[0], point[1]);
const cell = findCell(...point);
if (pack.cells.h[cell] < 20)
return tip("You cannot place state into the water. Please click on a land cell", false, "error");
if (pack.cells.burg[cell])
@ -603,11 +603,6 @@ function overviewBurgs(settings = {stateId: null, cultureId: null}) {
burgsOverviewAddLines();
}
function invertLock() {
pack.burgs = pack.burgs.map(burg => ({...burg, lock: !burg.lock}));
burgsOverviewAddLines();
}
function toggleLockAll() {
const activeBurgs = pack.burgs.filter(b => b.i && !b.removed);
const allLocked = activeBurgs.every(burg => burg.lock);

View file

@ -22,7 +22,7 @@ function clicked() {
if (grand.id === "emblems") editEmblem();
else if (parent.id === "rivers") editRiver(el.id);
else if (grand.id === "routes") editRoute();
else if (grand.id === "routes") editRoute(el.id);
else if (el.tagName === "tspan" && grand.parentNode.parentNode.id === "labels") editLabel();
else if (grand.id === "burgLabels") editBurg();
else if (grand.id === "burgIcons") editBurg();
@ -132,27 +132,43 @@ function applySorting(headers) {
}
function addBurg(point) {
const cells = pack.cells;
const x = rn(point[0], 2),
y = rn(point[1], 2);
const cell = findCell(x, point[1]);
const i = pack.burgs.length;
const culture = cells.culture[cell];
const name = Names.getCulture(culture);
const state = cells.state[cell];
const feature = cells.f[cell];
const {cells, states} = pack;
const x = rn(point[0], 2);
const y = rn(point[1], 2);
const temple = pack.states[state].form === "Theocracy";
const population = Math.max((cells.s[cell] + cells.road[cell]) / 3 + i / 1000 + (cell % 100) / 1000, 0.1);
const type = BurgsAndStates.getType(cell, false);
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(pack.states[state].coa, 0.25, null, type);
const coa = COA.generate(states[state].coa, 0.25, null, type);
coa.shield = COA.getShield(culture, state);
COArenderer.add("burg", i, coa, x, y);
pack.burgs.push({name, cell, x, y, state, i, culture, feature, capital: 0, port: 0, temple, population, coa, type});
cells.burg[cell] = i;
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
@ -173,7 +189,17 @@ function addBurg(point) {
.attr("dy", `${townSize * -1.5}px`)
.text(name);
BurgsAndStates.defineBurgFeatures(pack.burgs[i]);
BurgsAndStates.defineBurgFeatures(burg);
const newRoute = 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;
}
@ -327,8 +353,7 @@ function createMfcgLink(burg) {
const citadel = +burg.citadel;
const urban_castle = +(citadel && each(2)(i));
const hub = +cells.road[cell] > 50;
const hub = Routes.isCrossroad(cell);
const walls = +burg.walls;
const plaza = +burg.plaza;
const temple = +burg.temple;
@ -372,10 +397,12 @@ function createVillageGeneratorLink(burg) {
else if (cells.r[cell]) tags.push("river");
else if (pop < 200 && each(4)(cell)) tags.push("pond");
const roadsAround = cells.c[cell].filter(c => cells.h[c] >= 20 && cells.road[c]).length;
if (roadsAround > 1) tags.push("highway");
else if (roadsAround === 1) tags.push("dead end");
else tags.push("isolated");
const connections = pack.cells.routes[cell] || {};
const roads = Object.values(connections).filter(routeId => {
const route = pack.routes[routeId];
return route.group === "roads" || route.group === "trails";
}).length;
tags.push(roads > 1 ? "highway" : roads === 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];
@ -1174,7 +1201,6 @@ function getAreaUnit(squareMark = "²") {
}
function getArea(rawArea) {
const distanceScale = byId("distanceScaleInput")?.value;
return rawArea * distanceScale ** 2;
}
@ -1225,7 +1251,7 @@ function refreshAllEditors() {
// dynamically loaded editors
async function editStates() {
if (customization) return;
const Editor = await import("../dynamic/editors/states-editor.js?v=1.97.06");
const Editor = await import("../dynamic/editors/states-editor.js?v=1.99.00");
Editor.open();
}

View file

@ -1,43 +1,14 @@
"use strict";
function showEPForRoute(node) {
const points = [];
debug
.select("#controlPoints")
.selectAll("circle")
.each(function () {
const i = findCell(this.getAttribute("cx"), this.getAttribute("cy"));
points.push(i);
});
const routeLen = node.getTotalLength() * distanceScaleInput.value;
showElevationProfile(points, routeLen, false);
}
function showEPForRiver(node) {
const points = [];
debug
.select("#controlPoints")
.selectAll("circle")
.each(function () {
const i = findCell(this.getAttribute("cx"), this.getAttribute("cy"));
points.push(i);
});
const riverLen = (node.getTotalLength() / 2) * distanceScaleInput.value;
showElevationProfile(points, riverLen, true);
}
// data is an array of cell indexes, routeLen is the distance (in actual metres/feet), isRiver should be true for rivers, false otherwise
function showElevationProfile(data, routeLen, isRiver) {
// data is an array of cell indexes, routeLen is the distance (in actual metres/feet), isRiver should be true for rivers, false otherwise
document.getElementById("epScaleRange").addEventListener("change", draw);
document.getElementById("epCurve").addEventListener("change", draw);
document.getElementById("epSave").addEventListener("click", downloadCSV);
byId("epScaleRange").on("change", draw);
byId("epCurve").on("change", draw);
byId("epSave").on("click", downloadCSV);
$("#elevationProfile").dialog({
title: "Elevation profile",
resizable: false,
width: window.width,
close: closeElevationProfile,
position: {my: "left top", at: "left+20 bottom-500", of: window, collision: "fit"}
});
@ -45,18 +16,20 @@ function showElevationProfile(data, routeLen, isRiver) {
// prevent river graphs from showing rivers as flowing uphill - remember the general slope
let slope = 0;
if (isRiver) {
if (pack.cells.h[data[0]] < pack.cells.h[data[data.length - 1]]) {
const firstCellHeight = pack.cells.h[data.at(0)];
const lastCellHeight = pack.cells.h[data.at(-1)];
if (firstCellHeight < lastCellHeight) {
slope = 1; // up-hill
} else if (pack.cells.h[data[0]] > pack.cells.h[data[data.length - 1]]) {
} else if (firstCellHeight > lastCellHeight) {
slope = -1; // down-hill
}
}
const chartWidth = window.innerWidth - 180,
chartHeight = 300; // height of our land/sea profile, excluding the biomes data below
const xOffset = 80,
yOffset = 80; // this is our drawing starting point from top-left (y = 0) of SVG
const biomesHeight = 40;
const chartWidth = window.innerWidth - 200;
const chartHeight = 300;
const xOffset = 80;
const yOffset = 80;
const biomesHeight = 10;
let lastBurgIndex = 0;
let lastBurgCell = 0;
@ -174,7 +147,7 @@ function showElevationProfile(data, routeLen, isRiver) {
chartData.points.push([xscale(i) + xOffset, yscale(chartData.height[i]) + yOffset]);
}
document.getElementById("elevationGraph").innerHTML = "";
byId("elevationGraph").innerHTML = "";
const chart = d3
.select("#elevationGraph")
@ -313,7 +286,7 @@ function showElevationProfile(data, routeLen, isRiver) {
.attr("x", x)
.attr("y", y)
.attr("width", xscale(1))
.attr("height", 15)
.attr("height", biomesHeight)
.attr("data-tip", dataTip);
}
@ -388,7 +361,7 @@ function showElevationProfile(data, routeLen, isRiver) {
.attr("x", x1)
.attr("y", y1)
.attr("text-anchor", "middle");
document.getElementById("ep" + b).innerHTML = pack.burgs[b].name;
byId("ep" + b).innerHTML = pack.burgs[b].name;
// arrow from burg name to graph line
g.append("path")
@ -413,10 +386,10 @@ function showElevationProfile(data, routeLen, isRiver) {
}
function closeElevationProfile() {
document.getElementById("epScaleRange").removeEventListener("change", draw);
document.getElementById("epCurve").removeEventListener("change", draw);
document.getElementById("epSave").removeEventListener("click", downloadCSV);
document.getElementById("elevationGraph").innerHTML = "";
byId("epScaleRange").removeEventListener("change", draw);
byId("epCurve").removeEventListener("change", draw);
byId("epSave").removeEventListener("click", downloadCSV);
byId("elevationGraph").innerHTML = "";
modules.elevation = false;
}
}

View file

@ -67,9 +67,9 @@ function showDataTip(event) {
function showElementLockTip(event) {
const locked = event?.target?.classList?.contains("icon-lock");
if (locked) {
tip("Click to unlock the element and allow it to be changed by regeneration tools");
tip("Locked. Click to unlock the element and allow it to be changed by regeneration tools");
} else {
tip("Click to lock the element and prevent changes to it by regeneration tools");
tip("Unlocked. Click to lock the element and prevent changes to it by regeneration tools");
}
}
@ -151,7 +151,12 @@ function showMapTooltip(point, e, i, g) {
return;
}
if (group === "routes") return tip("Click to edit the Route");
if (group === "routes") {
const routeId = +e.target.id.slice(5);
const name = pack.routes[routeId]?.name;
if (name) return tip(`${name}. Click to edit the Route`);
return tip("Click to edit the Route");
}
if (group === "terrain") return tip("Click to edit the Relief Icon");

View file

@ -246,6 +246,7 @@ function editHeightmap(options) {
Cultures.expand();
BurgsAndStates.generate();
Routes.generate();
Religions.generate();
BurgsAndStates.defineStateForms();
BurgsAndStates.generateProvinces();
@ -281,8 +282,7 @@ function editHeightmap(options) {
const l = grid.cells.i.length;
const biome = new Uint8Array(l);
const pop = new Uint16Array(l);
const road = new Uint16Array(l);
const crossroad = new Uint16Array(l);
const routes = {};
const s = new Uint16Array(l);
const burg = new Uint16Array(l);
const state = new Uint16Array(l);
@ -300,8 +300,7 @@ function editHeightmap(options) {
biome[g] = pack.cells.biome[i];
culture[g] = pack.cells.culture[i];
pop[g] = pack.cells.pop[i];
road[g] = pack.cells.road[i];
crossroad[g] = pack.cells.crossroad[i];
routes[g] = pack.cells.routes[i];
s[g] = pack.cells.s[i];
state[g] = pack.cells.state[i];
province[g] = pack.cells.province[i];
@ -353,8 +352,7 @@ function editHeightmap(options) {
// assign saved pack data from grid back to pack
const n = pack.cells.i.length;
pack.cells.pop = new Float32Array(n);
pack.cells.road = new Uint16Array(n);
pack.cells.crossroad = new Uint16Array(n);
pack.cells.routes = {};
pack.cells.s = new Uint16Array(n);
pack.cells.burg = new Uint16Array(n);
pack.cells.state = new Uint16Array(n);
@ -389,8 +387,7 @@ function editHeightmap(options) {
if (!isLand) continue;
pack.cells.culture[i] = culture[g];
pack.cells.pop[i] = pop[g];
pack.cells.road[i] = road[g];
pack.cells.crossroad[i] = crossroad[g];
pack.cells.routes[i] = routes[g];
pack.cells.s[i] = s[g];
pack.cells.state[i] = state[g];
pack.cells.province[i] = province[g];

View file

@ -50,6 +50,7 @@ function handleKeyup(event) {
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();
@ -57,7 +58,7 @@ function handleKeyup(event) {
else if (key === "!") toggleAddBurg();
else if (key === "@") toggleAddLabel();
else if (key === "#") toggleAddRiver();
else if (key === "$") toggleAddRoute();
else if (key === "$") createRoute();
else if (key === "%") toggleAddMarker();
else if (alt && code === "KeyB") console.table(pack.burgs);
else if (alt && code === "KeyS") console.table(pack.states);

View file

@ -48,8 +48,7 @@ function editLake() {
document.getElementById("lakeArea").value = si(getArea(l.area)) + " " + getAreaUnit();
const length = d3.polygonLength(l.vertices.map(v => pack.vertices.p[v]));
document.getElementById("lakeShoreLength").value =
si(length * distanceScaleInput.value) + " " + distanceUnitInput.value;
document.getElementById("lakeShoreLength").value = si(length * distanceScale) + " " + distanceUnitInput.value;
const lakeCells = Array.from(cells.i.filter(i => cells.f[i] === l.i));
const heights = lakeCells.map(i => cells.h[i]);

View file

@ -169,6 +169,7 @@ function restoreLayers() {
if (layerIsOn("toggleGrid")) drawGrid();
if (layerIsOn("toggleCoordinates")) drawCoordinates();
if (layerIsOn("toggleCompass")) compass.style("display", "block");
if (layerIsOn("toggleRoutes")) drawRoutes();
if (layerIsOn("toggleTemp")) drawTemp();
if (layerIsOn("togglePrec")) drawPrec();
if (layerIsOn("togglePopulation")) drawPopulation();
@ -392,7 +393,6 @@ function drawTemp() {
const start = findStart(i, t);
if (!start) continue;
used[i] = 1;
//debug.append("circle").attr("r", 3).attr("cx", vertices.p[start][0]).attr("cy", vertices.p[start][1]).attr("fill", "red").attr("stroke", "black").attr("stroke-width", .3);
const chain = connectVertices(start, t); // vertices chain to form a path
const relaxed = chain.filter((v, i) => i % 4 === 0 || vertices.c[v].some(c => c >= n));
@ -1622,18 +1622,33 @@ function drawRivers() {
function toggleRoutes(event) {
if (!layerIsOn("toggleRoutes")) {
turnButtonOn("toggleRoutes");
$("#routes").fadeIn();
drawRoutes();
if (event && isCtrlClick(event)) editStyle("routes");
} else {
if (event && isCtrlClick(event)) {
editStyle("routes");
return;
}
$("#routes").fadeOut();
if (event && isCtrlClick(event)) return editStyle("routes");
routes.selectAll("path").remove();
turnButtonOff("toggleRoutes");
}
}
function drawRoutes() {
TIME && console.time("drawRoutes");
const routePaths = {};
for (const route of pack.routes) {
const {i, group} = route;
if (!routePaths[group]) routePaths[group] = [];
routePaths[group].push(`<path id="route${i}" d="${Routes.getPath(route)}"/>`);
}
routes.selectAll("path").remove();
for (const group in routePaths) {
routes.select("#" + group).html(routePaths[group].join(""));
}
TIME && console.timeEnd("drawRoutes");
}
function toggleMilitary() {
if (!layerIsOn("toggleMilitary")) {
turnButtonOn("toggleMilitary");
@ -1758,7 +1773,6 @@ function toggleScaleBar(event) {
function drawScaleBar(scaleBar, scaleLevel) {
if (!scaleBar.size() || scaleBar.style("display") === "none") return;
const distanceScale = +distanceScaleInput.value;
const unit = distanceUnitInput.value;
const size = +scaleBar.attr("data-bar-size");

View file

@ -66,7 +66,7 @@ class Measurer {
}
getDash() {
return rn(30 / distanceScaleInput.value, 2);
return rn(30 / distanceScale, 2);
}
drag() {
@ -205,7 +205,7 @@ class Ruler extends Measurer {
updateLabel() {
const length = this.getLength();
const text = rn(length * distanceScaleInput.value) + " " + distanceUnitInput.value;
const text = rn(length * distanceScale) + " " + distanceUnitInput.value;
const [x, y] = last(this.points);
this.el.select("text").attr("x", x).attr("y", y).text(text);
}
@ -337,7 +337,7 @@ class Opisometer extends Measurer {
updateLabel() {
const length = this.el.select("path").node().getTotalLength();
const text = rn(length * distanceScaleInput.value) + " " + distanceUnitInput.value;
const text = rn(length * distanceScale) + " " + distanceUnitInput.value;
const [x, y] = last(this.points);
this.el.select("text").attr("x", x).attr("y", y).text(text);
}
@ -475,7 +475,7 @@ class RouteOpisometer extends Measurer {
updateLabel() {
const length = this.el.select("path").node().getTotalLength();
const text = rn(length * distanceScaleInput.value) + " " + distanceUnitInput.value;
const text = rn(length * distanceScale) + " " + distanceUnitInput.value;
const [x, y] = last(this.points);
this.el.select("text").attr("x", x).attr("y", y).text(text);
}
@ -486,9 +486,7 @@ class RouteOpisometer extends Measurer {
const cells = pack.cells;
const c = findCell(mousePoint[0], mousePoint[1]);
if (!cells.road[c] && !d3.event.sourceEvent.shiftKey) {
return;
}
if (!Routes.isConnected(c) && !d3.event.sourceEvent.shiftKey) return;
context.trackCell(c, rigth);
});

View file

@ -545,6 +545,7 @@ function applyStoredOptions() {
lock(key);
if (key === "points") changeCellsDensity(+value);
if (key === "distanceScale") distanceScale = +value;
// add saved style presets to options
if (key.slice(0, 5) === "style") applyOption(stylePreset, key, key.slice(5));
@ -605,7 +606,8 @@ function randomizeOptions() {
// 'Units Editor' settings
const US = navigator.language === "en-US";
if (randomize || !locked("distanceScale")) distanceScaleOutput.value = distanceScaleInput.value = gauss(3, 1, 1, 5);
if (randomize || !locked("distanceScale"))
distanceScale = distanceScaleOutput.value = distanceScaleInput.value = gauss(3, 1, 1, 5);
if (!stored("distanceUnit")) distanceUnitInput.value = US ? "mi" : "km";
if (!stored("heightUnit")) heightUnit.value = US ? "ft" : "m";
if (!stored("temperatureScale")) temperatureScale.value = US ? "°F" : "°C";

View file

@ -886,7 +886,7 @@ function editProvinces() {
const p = d3.mouse(this);
moveCircle(p[0], p[1], r);
const found = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1], r)];
const found = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1])];
const selection = found.filter(isLand);
if (selection) changeForSelection(selection);
});

View file

@ -5,12 +5,15 @@ function editRiver(id) {
closeDialogs(".stable");
if (!layerIsOn("toggleRivers")) toggleRivers();
document.getElementById("toggleCells").dataset.forced = +!layerIsOn("toggleCells");
byId("toggleCells").dataset.forced = +!layerIsOn("toggleCells");
if (!layerIsOn("toggleCells")) toggleCells();
elSelected = d3.select("#" + id).on("click", addControlPoint);
tip("Drag control points to change the river course. Click on point to remove it. Click on river to add additional control point. For major changes please create a new river instead", true);
tip(
"Drag control points to change the river course. Click on point to remove it. Click on river to add additional control point. For major changes please create a new river instead",
true
);
debug.append("g").attr("id", "controlCells");
debug.append("g").attr("id", "controlPoints");
@ -33,18 +36,18 @@ function editRiver(id) {
modules.editRiver = true;
// add listeners
document.getElementById("riverCreateSelectingCells").addEventListener("click", createRiver);
document.getElementById("riverEditStyle").addEventListener("click", () => editStyle("rivers"));
document.getElementById("riverElevationProfile").addEventListener("click", showElevationProfile);
document.getElementById("riverLegend").addEventListener("click", editRiverLegend);
document.getElementById("riverRemove").addEventListener("click", removeRiver);
document.getElementById("riverName").addEventListener("input", changeName);
document.getElementById("riverType").addEventListener("input", changeType);
document.getElementById("riverNameCulture").addEventListener("click", generateNameCulture);
document.getElementById("riverNameRandom").addEventListener("click", generateNameRandom);
document.getElementById("riverMainstem").addEventListener("change", changeParent);
document.getElementById("riverSourceWidth").addEventListener("input", changeSourceWidth);
document.getElementById("riverWidthFactor").addEventListener("input", changeWidthFactor);
byId("riverCreateSelectingCells").on("click", createRiver);
byId("riverEditStyle").on("click", () => editStyle("rivers"));
byId("riverElevationProfile").on("click", showRiverElevationProfile);
byId("riverLegend").on("click", editRiverLegend);
byId("riverRemove").on("click", removeRiver);
byId("riverName").on("input", changeName);
byId("riverType").on("input", changeType);
byId("riverNameCulture").on("click", generateNameCulture);
byId("riverNameRandom").on("click", generateNameRandom);
byId("riverMainstem").on("change", changeParent);
byId("riverSourceWidth").on("input", changeSourceWidth);
byId("riverWidthFactor").on("input", changeWidthFactor);
function getRiver() {
const riverId = +elSelected.attr("id").slice(5);
@ -55,10 +58,10 @@ function editRiver(id) {
function updateRiverData() {
const r = getRiver();
document.getElementById("riverName").value = r.name;
document.getElementById("riverType").value = r.type;
byId("riverName").value = r.name;
byId("riverType").value = r.type;
const parentSelect = document.getElementById("riverMainstem");
const parentSelect = byId("riverMainstem");
parentSelect.options.length = 0;
const parent = r.parent || r.i;
const sortedRivers = pack.rivers.slice().sort((a, b) => (a.name > b.name ? 1 : -1));
@ -66,11 +69,11 @@ function editRiver(id) {
const opt = new Option(river.name, river.i, false, river.i === parent);
parentSelect.options.add(opt);
});
document.getElementById("riverBasin").value = pack.rivers.find(river => river.i === r.basin).name;
byId("riverBasin").value = pack.rivers.find(river => river.i === r.basin).name;
document.getElementById("riverDischarge").value = r.discharge + " m³/s";
document.getElementById("riverSourceWidth").value = r.sourceWidth;
document.getElementById("riverWidthFactor").value = r.widthFactor;
byId("riverDischarge").value = r.discharge + " m³/s";
byId("riverSourceWidth").value = r.sourceWidth;
byId("riverWidthFactor").value = r.widthFactor;
updateRiverLength(r);
updateRiverWidth(r);
@ -78,8 +81,8 @@ function editRiver(id) {
function updateRiverLength(river) {
river.length = rn(elSelected.node().getTotalLength() / 2, 2);
const lengthUI = `${rn(river.length * distanceScaleInput.value)} ${distanceUnitInput.value}`;
document.getElementById("riverLength").value = lengthUI;
const lengthUI = `${rn(river.length * distanceScale)} ${distanceUnitInput.value}`;
byId("riverLength").value = lengthUI;
}
function updateRiverWidth(river) {
@ -88,8 +91,8 @@ function editRiver(id) {
const meanderedPoints = addMeandering(cells);
river.width = getWidth(getOffset(discharge, meanderedPoints.length, widthFactor, sourceWidth));
const width = `${rn(river.width * distanceScaleInput.value, 3)} ${distanceUnitInput.value}`;
document.getElementById("riverWidth").value = width;
const width = `${rn(river.width * distanceScale, 3)} ${distanceUnitInput.value}`;
byId("riverWidth").value = width;
}
function drawControlPoints(points) {
@ -163,7 +166,7 @@ function editRiver(id) {
elSelected.attr("d", path);
updateRiverLength(river);
if (modules.elevation) showEPForRiver(elSelected.node());
if (byId("elevationProfile").offsetParent) showRiverElevationProfile();
}
function addControlPoint() {
@ -209,7 +212,7 @@ function editRiver(id) {
const r = getRiver();
r.parent = +this.value;
r.basin = pack.rivers.find(river => river.i === r.parent).basin;
document.getElementById("riverBasin").value = pack.rivers.find(river => river.i === r.basin).name;
byId("riverBasin").value = pack.rivers.find(river => river.i === r.basin).name;
}
function changeSourceWidth() {
@ -226,9 +229,14 @@ function editRiver(id) {
redrawRiver();
}
function showElevationProfile() {
modules.elevation = true;
showEPForRiver(elSelected.node());
function showRiverElevationProfile() {
const points = debug
.selectAll("#controlPoints > *")
.data()
.map(([x, y]) => findCell(x, y));
const river = getRiver();
const riverLen = rn(river.length * distanceScale);
showElevationProfile(points, riverLen, true);
}
function editRiverLegend() {
@ -266,8 +274,8 @@ function editRiver(id) {
unselect();
clearMainTip();
const forced = +document.getElementById("toggleCells").dataset.forced;
document.getElementById("toggleCells").dataset.forced = 0;
const forced = +byId("toggleCells").dataset.forced;
byId("toggleCells").dataset.forced = 0;
if (forced && layerIsOn("toggleCells")) toggleCells();
}
}

View file

@ -1,4 +1,5 @@
"use strict";
function overviewRivers() {
if (customization) return;
closeDialogs("#riversOverview, .stable");
@ -34,8 +35,8 @@ function overviewRivers() {
for (const r of pack.rivers) {
const discharge = r.discharge + " m³/s";
const length = rn(r.length * distanceScaleInput.value) + " " + unit;
const width = rn(r.width * distanceScaleInput.value, 3) + " " + unit;
const length = rn(r.length * distanceScale) + " " + unit;
const width = rn(r.width * distanceScale, 3) + " " + unit;
const basin = pack.rivers.find(river => river.i === r.basin)?.name;
lines += /* html */ `<div
@ -49,7 +50,7 @@ function overviewRivers() {
data-basin="${basin}"
>
<span data-tip="Click to focus on river" class="icon-dot-circled pointer"></span>
<div data-tip="River name" class="riverName">${r.name}</div>
<div data-tip="River name" style="margin-left: 0.4em;" class="riverName">${r.name}</div>
<div data-tip="River type name" class="riverType">${r.type}</div>
<div data-tip="River discharge (flux power)" class="biomeArea">${discharge}</div>
<div data-tip="River length from source to mouth" class="biomeArea">${length}</div>
@ -66,16 +67,18 @@ function overviewRivers() {
const averageDischarge = rn(d3.mean(pack.rivers.map(r => r.discharge)));
riversFooterDischarge.innerHTML = averageDischarge + " m³/s";
const averageLength = rn(d3.mean(pack.rivers.map(r => r.length)));
riversFooterLength.innerHTML = averageLength * distanceScaleInput.value + " " + unit;
riversFooterLength.innerHTML = averageLength * distanceScale + " " + unit;
const averageWidth = rn(d3.mean(pack.rivers.map(r => r.width)), 3);
riversFooterWidth.innerHTML = rn(averageWidth * distanceScaleInput.value, 3) + " " + unit;
riversFooterWidth.innerHTML = rn(averageWidth * distanceScale, 3) + " " + unit;
// add listeners
body.querySelectorAll("div.states").forEach(el => el.addEventListener("mouseenter", ev => riverHighlightOn(ev)));
body.querySelectorAll("div.states").forEach(el => el.addEventListener("mouseleave", ev => riverHighlightOff(ev)));
body.querySelectorAll("div > span.icon-dot-circled").forEach(el => el.addEventListener("click", zoomToRiver));
body.querySelectorAll("div > span.icon-pencil").forEach(el => el.addEventListener("click", openRiverEditor));
body.querySelectorAll("div > span.icon-trash-empty").forEach(el => el.addEventListener("click", triggerRiverRemove));
body
.querySelectorAll("div > span.icon-trash-empty")
.forEach(el => el.addEventListener("click", triggerRiverRemove));
applySorting(riversHeader);
}
@ -110,7 +113,18 @@ function overviewRivers() {
} else {
rivers.attr("data-basin", "hightlighted");
const basins = [...new Set(pack.rivers.map(r => r.basin))];
const colors = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"];
const colors = [
"#1f77b4",
"#ff7f0e",
"#2ca02c",
"#d62728",
"#9467bd",
"#8c564b",
"#e377c2",
"#7f7f7f",
"#bcbd22",
"#17becf"
];
basins.forEach((b, i) => {
const color = colors[i % colors.length];
@ -129,8 +143,8 @@ function overviewRivers() {
body.querySelectorAll(":scope > div").forEach(function (el) {
const d = el.dataset;
const discharge = d.discharge + " m³/s";
const length = rn(d.length * distanceScaleInput.value) + " " + distanceUnitInput.value;
const width = rn(d.width * distanceScaleInput.value, 3) + " " + distanceUnitInput.value;
const length = rn(d.length * distanceScale) + " " + distanceUnitInput.value;
const width = rn(d.width * distanceScale, 3) + " " + distanceUnitInput.value;
data += [d.id, d.name, d.type, discharge, length, width, d.basin].join(",") + "\n";
});

View file

@ -0,0 +1,85 @@
"use strict";
function editRouteGroups() {
if (customization) return;
if (!layerIsOn("toggleRoutes")) toggleRoutes();
addLines();
$("#routeGroupsEditor").dialog({
title: "Edit Route groups",
resizable: false,
position: {my: "left top", at: "left+10 top+140", of: "#map"}
});
if (modules.editRouteGroups) return;
modules.editRouteGroups = true;
// add listeners
byId("routeGroupsEditorAdd").addEventListener("click", addGroup);
byId("routeGroupsEditorBody").on("click", ev => {
const group = ev.target.parentNode.dataset.id;
if (ev.target.classList.contains("editStyle")) editStyle("routes", group);
else if (ev.target.classList.contains("removeGroup")) removeGroup(group);
});
function addLines() {
byId("routeGroupsEditorBody").innerHTML = "";
const lines = Array.from(routes.selectAll("g")._groups[0]).map(el => {
const count = el.children.length;
return /* html */ `<div data-id="${el.id}" class="states" style="display: flex; justify-content: space-between;">
<span>${el.id} (${count})</span>
<div style="width: auto; display: flex; gap: 0.4em;">
<span data-tip="Edit style" class="editStyle icon-brush pointer" style="font-size: smaller;"></span>
<span data-tip="Remove group" class="removeGroup icon-trash pointer"></span>
</div>
</div>`;
});
byId("routeGroupsEditorBody").innerHTML = lines.join("");
}
const DEFAULT_GROUPS = ["roads", "trails", "searoutes"];
function addGroup() {
prompt("Type group name", {default: "route-group-new"}, v => {
let group = v
.toLowerCase()
.replace(/ /g, "_")
.replace(/[^\w\s]/gi, "");
if (!group) return tip("Invalid group name", false, "error");
if (!group.startsWith("route-")) group = "route-" + group;
if (byId(group)) return tip("Element with this name already exists. Provide a unique name", false, "error");
if (Number.isFinite(+group.charAt(0))) return tip("Group name should start with a letter", false, "error");
routes
.append("g")
.attr("id", group)
.attr("stroke", "#000000")
.attr("stroke-width", 0.5)
.attr("stroke-dasharray", "1 0.5")
.attr("stroke-linecap", "butt");
byId("routeGroup")?.options.add(new Option(group, group));
addLines();
byId("routeCreatorGroupSelect").options.add(new Option(group, group));
});
}
function removeGroup(group) {
confirmationDialog({
title: "Remove route group",
message:
"Are you sure you want to remove the entire route group? All routes in this group will be removed. This action can't be reverted.",
confirm: "Remove",
onConfirm: () => {
const routes = pack.routes.filter(r => r.group === group);
routes.forEach(r => Routes.remove(r));
if (DEFAULT_GROUPS.includes(group)) routes.select(`#${group}`).remove();
addLines();
}
});
}
}

View file

@ -0,0 +1,140 @@
"use strict";
function createRoute(defaultGroup) {
if (customization) return;
closeDialogs();
if (!layerIsOn("toggleRoutes")) toggleRoutes();
byId("toggleCells").dataset.forced = +!layerIsOn("toggleCells");
if (!layerIsOn("toggleCells")) toggleCells();
tip("Click to add route point, click again to remove", true);
debug.append("g").attr("id", "controlCells");
debug.append("g").attr("id", "controlPoints");
viewbox.style("cursor", "crosshair").on("click", onClick);
createRoute.points = [];
const body = byId("routeCreatorBody");
// update route groups
byId("routeCreatorGroupSelect").innerHTML = Array.from(routes.selectAll("g")._groups[0]).map(el => {
const selected = defaultGroup || "roads";
return `<option value="${el.id}" ${el.id === selected ? "selected" : ""}>${el.id}</option>`;
});
$("#routeCreator").dialog({
title: "Create Route",
resizable: false,
position: {my: "left top", at: "left+10 top+10", of: "#map"},
close: closeRouteCreator
});
if (modules.createRoute) return;
modules.createRoute = true;
// add listeners
byId("routeCreatorGroupSelect").on("change", () => drawRoute(createRoute.points));
byId("routeCreatorGroupEdit").on("click", editRouteGroups);
byId("routeCreatorComplete").on("click", completeCreation);
byId("routeCreatorCancel").on("click", () => $("#routeCreator").dialog("close"));
body.on("click", ev => {
if (ev.target.classList.contains("icon-trash-empty")) removePoint(ev.target.parentNode.dataset.point);
});
function onClick() {
const [x, y] = d3.mouse(this);
const cellId = findCell(x, y);
const point = [rn(x, 2), rn(y, 2), cellId];
createRoute.points.push(point);
drawRoute(createRoute.points);
body.innerHTML += `<div class="editorLine" style="display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 1em;" data-point="${point.join(
"-"
)}">
<span><b>Cell</b>: ${cellId}</span>
<span><b>X</b>: ${point[0]}</span>
<span><b>Y</b>: ${point[1]}</span>
<span data-tip="Remove the point" class="icon-trash-empty pointer"></span>
</div>`;
}
function removePoint(pointString) {
createRoute.points = createRoute.points.filter(p => p.join("-") !== pointString);
drawRoute(createRoute.points);
body.querySelector(`[data-point='${pointString}']`)?.remove();
}
function drawRoute(points) {
debug
.select("#controlCells")
.selectAll("polygon")
.data(points)
.join("polygon")
.attr("points", p => getPackPolygon(p[2]))
.attr("class", "current");
debug
.select("#controlPoints")
.selectAll("circle")
.data(points)
.join("circle")
.attr("cx", d => d[0])
.attr("cy", d => d[1])
.attr("r", 0.6);
const group = byId("routeCreatorGroupSelect").value;
routes.select("#routeTemp").remove();
routes
.select("#" + group)
.append("path")
.attr("d", Routes.getPath({group, points}))
.attr("id", "routeTemp");
}
function completeCreation() {
const points = createRoute.points;
if (points.length < 2) return tip("Add at least 2 points", false, "error");
const routeId = Math.max(...pack.routes.map(route => route.i)) + 1;
const group = byId("routeCreatorGroupSelect").value;
const feature = pack.cells.f[points[0][2]];
const route = {points, group, feature, i: routeId};
pack.routes.push(route);
const links = pack.cells.routes;
for (let i = 0; i < points.length; i++) {
const point = points[i];
const nextPoint = points[i + 1];
if (nextPoint) {
const cellId = point[2];
const nextId = nextPoint[2];
if (!links[cellId]) links[cellId] = {};
links[cellId][nextId] = routeId;
if (!links[nextId]) links[nextId] = {};
links[nextId][cellId] = routeId;
}
}
routes.select("#routeTemp").attr("id", "route" + routeId);
editRoute("route" + routeId);
}
function closeRouteCreator() {
body.innerHTML = "";
debug.select("#controlCells").remove();
debug.select("#controlPoints").remove();
routes.select("#routeTemp").remove();
restoreDefaultEvents();
clearMainTip();
const forced = +byId("toggleCells").dataset.forced;
byId("toggleCells").dataset.forced = 0;
if (forced && layerIsOn("toggleCells")) toggleCells();
}
}

View file

@ -1,308 +1,403 @@
"use strict";
const CONTROL_POINST_DISTANCE = 10;
function editRoute(onClick) {
function editRoute(id) {
if (customization) return;
if (!onClick && elSelected && d3.event.target.id === elSelected.attr("id")) return;
if (elSelected && id === elSelected.attr("id")) return;
closeDialogs(".stable");
if (!layerIsOn("toggleRoutes")) toggleRoutes();
byId("toggleCells").dataset.forced = +!layerIsOn("toggleCells");
if (!layerIsOn("toggleCells")) toggleCells();
elSelected = d3.select("#" + id).on("click", addControlPoint);
tip(
"Drag control points to change the route. Click on point to remove it. Click on the route to add additional control point. For major changes please create a new route instead",
true
);
debug.append("g").attr("id", "controlCells");
debug.append("g").attr("id", "controlPoints");
{
const route = getRoute();
updateRouteData(route);
drawControlPoints(route.points);
drawCells(route.points);
updateLockIcon();
}
$("#routeEditor").dialog({
title: "Edit Route",
resizable: false,
position: {my: "center top+60", at: "top", of: d3.event, collision: "fit"},
close: closeRoutesEditor
position: {my: "left top", at: "left+10 top+10", of: "#map"},
close: closeRouteEditor
});
debug.append("g").attr("id", "controlPoints");
const node = onClick ? elSelected.node() : d3.event.target;
elSelected = d3.select(node).on("click", addInterimControlPoint);
drawControlPoints(node);
selectRouteGroup(node);
viewbox.on("touchmove mousemove", showEditorTips);
if (onClick) toggleRouteCreationMode();
if (modules.editRoute) return;
modules.editRoute = true;
// add listeners
document.getElementById("routeGroupsShow").addEventListener("click", showGroupSection);
document.getElementById("routeGroup").addEventListener("change", changeRouteGroup);
document.getElementById("routeGroupAdd").addEventListener("click", toggleNewGroupInput);
document.getElementById("routeGroupName").addEventListener("change", createNewGroup);
document.getElementById("routeGroupRemove").addEventListener("click", removeRouteGroup);
document.getElementById("routeGroupsHide").addEventListener("click", hideGroupSection);
document.getElementById("routeElevationProfile").addEventListener("click", showElevationProfile);
byId("routeCreateSelectingCells").on("click", showCreationDialog);
byId("routeSplit").on("click", togglePressed);
byId("routeJoin").on("click", openJoinRoutesDialog);
byId("routeElevationProfile").on("click", showRouteElevationProfile);
byId("routeLegend").on("click", editRouteLegend);
byId("routeLock").on("click", toggleLockButton);
byId("routeRemove").on("click", removeRoute);
byId("routeName").on("input", changeName);
byId("routeGroup").on("input", changeGroup);
byId("routeGroupEdit").on("click", editRouteGroups);
byId("routeEditStyle").on("click", editRouteGroupStyle);
byId("routeGenerateName").on("click", generateName);
document.getElementById("routeEditStyle").addEventListener("click", editGroupStyle);
document.getElementById("routeSplit").addEventListener("click", toggleRouteSplitMode);
document.getElementById("routeLegend").addEventListener("click", editRouteLegend);
document.getElementById("routeNew").addEventListener("click", toggleRouteCreationMode);
document.getElementById("routeRemove").addEventListener("click", removeRoute);
function showEditorTips() {
showMainTip();
if (routeNew.classList.contains("pressed")) return;
if (d3.event.target.id === elSelected.attr("id")) tip("Click to add a control point");
else if (d3.event.target.parentNode.id === "controlPoints") tip("Drag to move, click to delete the control point");
function getRoute() {
const routeId = +elSelected.attr("id").slice(5);
return pack.routes.find(route => route.i === routeId);
}
function drawControlPoints(node) {
const totalLength = node.getTotalLength();
const increment = totalLength / Math.ceil(totalLength / CONTROL_POINST_DISTANCE);
for (let i = 0; i <= totalLength; i += increment) {
const point = node.getPointAtLength(i);
addControlPoint([point.x, point.y]);
}
routeLength.innerHTML = rn(totalLength * distanceScaleInput.value) + " " + distanceUnitInput.value;
function updateRouteData(route) {
route.name = route.name || Routes.generateName(route);
byId("routeName").value = route.name;
const routeGroup = byId("routeGroup");
routeGroup.options.length = 0;
routes.selectAll("g").each(function () {
routeGroup.options.add(new Option(this.id, this.id, false, this.id === route.group));
});
updateRouteLength(route);
const isWater = route.points.some(([x, y, cellId]) => pack.cells.h[cellId] < 20);
byId("routeElevationProfile").style.display = isWater ? "none" : "inline-block";
}
function addControlPoint(point, before = null) {
debug
.select("#controlPoints")
.insert("circle", before)
.attr("cx", point[0])
.attr("cy", point[1])
.attr("r", 0.6)
.call(d3.drag().on("drag", dragControlPoint))
.on("click", clickControlPoint);
function updateRouteLength(route) {
route.length = Routes.getLength(route.i);
byId("routeLength").value = rn(route.length * distanceScale) + " " + distanceUnitInput.value;
}
function addInterimControlPoint() {
const point = d3.mouse(this);
const controls = document.getElementById("controlPoints").querySelectorAll("circle");
const points = Array.from(controls).map(circle => [+circle.getAttribute("cx"), +circle.getAttribute("cy")]);
const index = getSegmentId(points, point, 2);
addControlPoint(point, ":nth-child(" + (index + 1) + ")");
redrawRoute();
}
function dragControlPoint() {
this.setAttribute("cx", d3.event.x);
this.setAttribute("cy", d3.event.y);
redrawRoute();
}
function redrawRoute() {
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
const points = [];
function drawControlPoints(points) {
debug
.select("#controlPoints")
.selectAll("circle")
.each(function () {
points.push([this.getAttribute("cx"), this.getAttribute("cy")]);
});
elSelected.attr("d", round(lineGen(points)));
const l = elSelected.node().getTotalLength();
routeLength.innerHTML = rn(l * distanceScaleInput.value) + " " + distanceUnitInput.value;
if (modules.elevation) showEPForRoute(elSelected.node());
.data(points)
.join("circle")
.attr("cx", d => d[0])
.attr("cy", d => d[1])
.attr("r", 0.6)
.call(d3.drag().on("start", dragControlPoint))
.on("click", handleControlPointClick);
}
function showElevationProfile() {
modules.elevation = true;
showEPForRoute(elSelected.node());
function drawCells(points) {
debug
.select("#controlCells")
.selectAll("polygon")
.data(points)
.join("polygon")
.attr("points", p => getPackPolygon(p[2]));
}
function showGroupSection() {
document.querySelectorAll("#routeEditor > button").forEach(el => (el.style.display = "none"));
document.getElementById("routeGroupsSelection").style.display = "inline-block";
}
function dragControlPoint() {
const route = getRoute();
const initCell = d3.event.subject[2];
const pointIndex = route.points.indexOf(d3.event.subject);
function hideGroupSection() {
document.querySelectorAll("#routeEditor > button").forEach(el => (el.style.display = "inline-block"));
document.getElementById("routeGroupsSelection").style.display = "none";
document.getElementById("routeGroupName").style.display = "none";
document.getElementById("routeGroupName").value = "";
document.getElementById("routeGroup").style.display = "inline-block";
}
d3.event.on("drag", function () {
this.setAttribute("cx", d3.event.x);
this.setAttribute("cy", d3.event.y);
function selectRouteGroup(node) {
const group = node.parentNode.id;
const select = document.getElementById("routeGroup");
select.options.length = 0; // remove all options
const x = rn(d3.event.x, 2);
const y = rn(d3.event.y, 2);
const cellId = findCell(x, y);
routes.selectAll("g").each(function () {
select.options.add(new Option(this.id, this.id, false, this.id === group));
this.__data__ = route.points[pointIndex] = [x, y, cellId];
redrawRoute(route);
drawCells(route.points);
});
}
function changeRouteGroup() {
document.getElementById(this.value).appendChild(elSelected.node());
}
d3.event.on("end", () => {
const movedToCell = findCell(d3.event.x, d3.event.y);
function toggleNewGroupInput() {
if (routeGroupName.style.display === "none") {
routeGroupName.style.display = "inline-block";
routeGroupName.focus();
routeGroup.style.display = "none";
} else {
routeGroupName.style.display = "none";
routeGroup.style.display = "inline-block";
}
}
if (movedToCell !== initCell) {
const prev = route.points[pointIndex - 1];
if (prev) {
removeConnection(initCell, prev[2]);
addConnection(movedToCell, prev[2], route.i);
}
function createNewGroup() {
if (!this.value) {
tip("Please provide a valid group name");
return;
}
const group = this.value
.toLowerCase()
.replace(/ /g, "_")
.replace(/[^\w\s]/gi, "");
if (document.getElementById(group)) {
tip("Element with this id already exists. Please provide a unique name", false, "error");
return;
}
if (Number.isFinite(+group.charAt(0))) {
tip("Group name should start with a letter", false, "error");
return;
}
// just rename if only 1 element left
const oldGroup = elSelected.node().parentNode;
const basic = ["roads", "trails", "searoutes"].includes(oldGroup.id);
if (!basic && oldGroup.childElementCount === 1) {
document.getElementById("routeGroup").selectedOptions[0].remove();
document.getElementById("routeGroup").options.add(new Option(group, group, false, true));
oldGroup.id = group;
toggleNewGroupInput();
document.getElementById("routeGroupName").value = "";
return;
}
const newGroup = elSelected.node().parentNode.cloneNode(false);
document.getElementById("routes").appendChild(newGroup);
newGroup.id = group;
document.getElementById("routeGroup").options.add(new Option(group, group, false, true));
document.getElementById(group).appendChild(elSelected.node());
toggleNewGroupInput();
document.getElementById("routeGroupName").value = "";
}
function removeRouteGroup() {
const group = elSelected.node().parentNode.id;
const basic = ["roads", "trails", "searoutes"].includes(group);
const count = elSelected.node().parentNode.childElementCount;
alertMessage.innerHTML = /* html */ `Are you sure you want to remove ${
basic ? "all elements in the group" : "the entire route group"
}? <br /><br />Routes to be
removed: ${count}`;
$("#alert").dialog({
resizable: false,
title: "Remove route group",
buttons: {
Remove: function () {
$(this).dialog("close");
$("#routeEditor").dialog("close");
hideGroupSection();
if (basic)
routes
.select("#" + group)
.selectAll("path")
.remove();
else routes.select("#" + group).remove();
},
Cancel: function () {
$(this).dialog("close");
const next = route.points[pointIndex + 1];
if (next) {
removeConnection(initCell, next[2]);
addConnection(movedToCell, next[2], route.i);
}
}
});
}
function editGroupStyle() {
const g = elSelected.node().parentNode.id;
editStyle("routes", g);
function redrawRoute(route) {
elSelected.attr("d", Routes.getPath(route));
updateRouteLength(route);
if (byId("elevationProfile").offsetParent) showRouteElevationProfile();
}
function toggleRouteSplitMode() {
document.getElementById("routeNew").classList.remove("pressed");
function addControlPoint() {
const route = getRoute();
const [x, y] = d3.mouse(this);
const cellId = findCell(x, y);
const point = [rn(x, 2), rn(y, 2), cellId];
const isNewCell = !route.points.some(p => p[2] === cellId);
const index = getSegmentId(route.points, point, 2);
route.points.splice(index, 0, point);
// check if added point is in new cell
if (isNewCell) {
const prev = route.points[index - 1];
const next = route.points[index + 1];
if (!prev) ERROR && console.error("Can't add control point to the start of the route");
if (!next) ERROR && console.error("Can't add control point to the end of the route");
if (!prev || !next) return;
removeConnection(prev[2], next[2]);
addConnection(prev[2], cellId, route.i);
addConnection(cellId, next[2], route.i);
drawCells(route.points);
}
drawControlPoints(route.points);
redrawRoute(route);
}
function handleControlPointClick() {
const controlPoint = d3.select(this);
const point = controlPoint.datum();
const route = getRoute();
const index = route.points.indexOf(point);
const isSplitMode = byId("routeSplit").classList.contains("pressed");
return isSplitMode ? splitRoute() : removeControlPoint(controlPoint);
function splitRoute() {
const oldRoutePoints = route.points.slice(0, index + 1);
const newRoutePoints = route.points.slice(index);
// update old route
route.points = oldRoutePoints;
drawControlPoints(route.points);
drawCells(route.points);
redrawRoute(route);
// create new route
const newRoute = {
i: Math.max(...pack.routes.map(route => route.i)) + 1,
group: route.group,
feature: route.feature,
name: route.name,
points: newRoutePoints
};
pack.routes.push(newRoute);
for (let i = 0; i < newRoute.points.length; i++) {
const cellId = newRoute.points[i][2];
const nextPoint = newRoute.points[i + 1];
if (nextPoint) addConnection(cellId, nextPoint[2], newRoute.i);
}
routes
.select("#" + newRoute.group)
.append("path")
.attr("d", Routes.getPath(newRoute))
.attr("id", "route" + newRoute.i);
byId("routeSplit").classList.remove("pressed");
}
function removeControlPoint(controlPoint) {
const isOnlyPointInCell = route.points.filter(p => p[2] === point[2]).length === 1;
if (isOnlyPointInCell) {
const prev = route.points[index - 1];
const next = route.points[index + 1];
if (prev) removeConnection(prev[2], point[2]);
if (next) removeConnection(point[2], next[2]);
if (prev && next) addConnection(prev[2], next[2], route.i);
}
controlPoint.remove();
route.points = route.points.filter(p => p !== point);
drawCells(route.points);
redrawRoute(route);
}
}
function openJoinRoutesDialog() {
const route = getRoute();
const firstCell = route.points.at(0)[2];
const lastCell = route.points.at(-1)[2];
const candidateRoutes = pack.routes.filter(r => {
if (r.i === route.i) return false;
if (r.group !== route.group) return false;
if (r.points.at(0)[2] === lastCell) return true;
if (r.points.at(-1)[2] === firstCell) return true;
if (r.points.at(0)[2] === firstCell) return true;
if (r.points.at(-1)[2] === lastCell) return true;
return false;
});
if (candidateRoutes.length) {
const options = candidateRoutes.map(r => {
r.name = r.name || Routes.generateName(r);
r.length = r.length || Routes.getLength(r.i);
const length = rn(r.length * distanceScale) + " " + distanceUnitInput.value;
return `<option value="${r.i}">${r.name} (${length})</option>`;
});
alertMessage.innerHTML = /* html */ `<div>Route to join with:
<select>${options.join("")}</select>
</div>`;
$("#alert").dialog({
title: "Join routes",
width: fitContent(),
position: {my: "left top", at: "left+10 top+150", of: "#map"},
buttons: {
Cancel: () => {
$("#alert").dialog("close");
},
Join: () => {
const selectedRouteId = +alertMessage.querySelector("select").value;
const selectedRoute = pack.routes.find(r => r.i === selectedRouteId);
joinRoutes(route, selectedRoute);
tip("Routes joined", false, "success", 5000);
$("#alert").dialog("close");
}
}
});
} else {
tip("No routes to join with. Route must start or end at current route's start or end cell", false, "error", 4000);
}
}
function joinRoutes(route, joinedRoute) {
if (route.points.at(-1)[2] === joinedRoute.points.at(0)[2]) {
// joinedRoute starts at the end of current route
route.points = [...route.points, ...joinedRoute.points.slice(1)];
} else if (route.points.at(0)[2] === joinedRoute.points.at(-1)[2]) {
// joinedRoute ends at the start of current route
route.points = [...joinedRoute.points, ...route.points.slice(1)];
} else if (route.points.at(0)[2] === joinedRoute.points.at(0)[2]) {
// joinedRoute and current route both start at the same cell
route.points = [...route.points.reverse(), ...joinedRoute.points.slice(1)];
} else if (route.points.at(-1)[2] === joinedRoute.points.at(-1)[2]) {
// joinedRoute and current route both end at the same cell
route.points = [...route.points, ...joinedRoute.points.reverse().slice(1)];
}
for (let i = 0; i < route.points.length; i++) {
const point = route.points[i];
const nextPoint = route.points[i + 1];
if (nextPoint) addConnection(point[2], nextPoint[2], route.i);
}
Routes.remove(joinedRoute);
drawControlPoints(route.points);
redrawRoute(route);
drawCells(route.points);
}
function showCreationDialog() {
const route = getRoute();
createRoute(route.group);
}
function togglePressed() {
this.classList.toggle("pressed");
}
function clickControlPoint() {
if (routeSplit.classList.contains("pressed")) splitRoute(this);
else {
this.remove();
redrawRoute();
}
function removeConnection(from, to) {
const routes = pack.cells.routes;
if (routes[from]) delete routes[from][to];
if (routes[to]) delete routes[to][from];
}
function splitRoute(clicked) {
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
const group = d3.select(elSelected.node().parentNode);
routeSplit.classList.remove("pressed");
function addConnection(from, to, routeId) {
const routes = pack.cells.routes;
const points1 = [],
points2 = [];
let points = points1;
debug
.select("#controlPoints")
.selectAll("circle")
.each(function () {
points.push([this.getAttribute("cx"), this.getAttribute("cy")]);
if (this === clicked) {
points = points2;
points.push([this.getAttribute("cx"), this.getAttribute("cy")]);
}
this.remove();
});
if (!routes[from]) routes[from] = {};
routes[from][to] = routeId;
elSelected.attr("d", round(lineGen(points1)));
const id = getNextId("route");
group.append("path").attr("id", id).attr("d", lineGen(points2));
debug.select("#controlPoints").selectAll("circle").remove();
drawControlPoints(elSelected.node());
if (!routes[to]) routes[to] = {};
routes[to][from] = routeId;
}
function toggleRouteCreationMode() {
document.getElementById("routeSplit").classList.remove("pressed");
document.getElementById("routeNew").classList.toggle("pressed");
if (document.getElementById("routeNew").classList.contains("pressed")) {
tip("Click on map to add control points", true);
viewbox.on("click", addPointOnClick).style("cursor", "crosshair");
elSelected.on("click", null);
} else {
clearMainTip();
viewbox.on("click", clicked).style("cursor", "default");
elSelected.on("click", addInterimControlPoint).attr("data-new", null);
}
function changeName() {
getRoute().name = this.value;
}
function addPointOnClick() {
// create new route
if (!elSelected.attr("data-new")) {
debug.select("#controlPoints").selectAll("circle").remove();
const parent = elSelected.node().parentNode;
const id = getNextId("route");
elSelected = d3.select(parent).append("path").attr("id", id).attr("data-new", 1);
}
function changeGroup() {
const group = this.value;
byId(group).appendChild(elSelected.node());
getRoute().group = group;
}
addControlPoint(d3.mouse(this));
redrawRoute();
function generateName() {
const route = getRoute();
route.name = routeName.value = Routes.generateName(route);
}
function showRouteElevationProfile() {
const route = getRoute();
const length = rn(route.length * distanceScale);
showElevationProfile(
route.points.map(p => p[2]),
length,
false
);
}
function editRouteLegend() {
const id = elSelected.attr("id");
editNotes(id, id);
const route = getRoute();
editNotes(id, route.name);
}
function editRouteGroupStyle() {
const {group} = getRoute();
editStyle("routes", group);
}
function toggleLockButton() {
const route = getRoute();
route.lock = !route.lock;
updateLockIcon();
}
function updateLockIcon() {
const route = getRoute();
if (route.lock) {
byId("routeLock").classList.remove("icon-lock-open");
byId("routeLock").classList.add("icon-lock");
} else {
byId("routeLock").classList.remove("icon-lock");
byId("routeLock").classList.add("icon-lock-open");
}
}
function removeRoute() {
alertMessage.innerHTML = "Are you sure you want to remove the route?";
alertMessage.innerHTML = "Are you sure you want to remove the route";
$("#alert").dialog({
resizable: false,
width: "22em",
title: "Remove route",
buttons: {
Remove: function () {
Routes.remove(getRoute());
$(this).dialog("close");
elSelected.remove();
$("#routeEditor").dialog("close");
},
Cancel: function () {
@ -312,12 +407,16 @@ function editRoute(onClick) {
});
}
function closeRoutesEditor() {
elSelected.attr("data-new", null).on("click", null);
clearMainTip();
routeSplit.classList.remove("pressed");
routeNew.classList.remove("pressed");
function closeRouteEditor() {
debug.select("#controlPoints").remove();
debug.select("#controlCells").remove();
elSelected.on("click", null);
unselect();
clearMainTip();
const forced = +byId("toggleCells").dataset.forced;
byId("toggleCells").dataset.forced = 0;
if (forced && layerIsOn("toggleCells")) toggleCells();
}
}

View file

@ -0,0 +1,187 @@
"use strict";
function overviewRoutes() {
if (customization) return;
closeDialogs("#routesOverview, .stable");
if (!layerIsOn("toggleRoutes")) toggleRoutes();
const body = byId("routesBody");
routesOverviewAddLines();
$("#routesOverview").dialog();
if (modules.overviewRoutes) return;
modules.overviewRoutes = true;
$("#routesOverview").dialog({
title: "Routes Overview",
resizable: false,
width: fitContent(),
position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}
});
// add listeners
byId("routesOverviewRefresh").on("click", routesOverviewAddLines);
byId("routesCreateNew").on("click", createRoute);
byId("routesExport").on("click", downloadRoutesData);
byId("routesLockAll").on("click", toggleLockAll);
byId("routesRemoveAll").on("click", triggerAllRoutesRemove);
// add line for each route
function routesOverviewAddLines() {
body.innerHTML = "";
let lines = "";
for (const route of pack.routes) {
route.name = route.name || Routes.generateName(route);
route.length = route.length || Routes.getLength(route.i);
const length = rn(route.length * distanceScale) + " " + distanceUnitInput.value;
lines += /* html */ `<div
class="states"
data-id="${route.i}"
data-name="${route.name}"
data-group="${route.group}"
data-length="${route.length}"
>
<span data-tip="Click to focus on route" class="icon-dot-circled pointer"></span>
<div data-tip="Route name" style="width: 15em; margin-left: 0.4em;">${route.name}</div>
<div data-tip="Route group" style="width: 8em;">${route.group}</div>
<div data-tip="Route length" style="width: 6em;">${length}</div>
<span data-tip="Edit route" class="icon-pencil"></span>
<span class="locks pointer ${
route.lock ? "icon-lock" : "icon-lock-open inactive"
}" onmouseover="showElementLockTip(event)"></span>
<span data-tip="Remove route" class="icon-trash-empty"></span>
</div>`;
}
body.insertAdjacentHTML("beforeend", lines);
// update footer
routesFooterNumber.innerHTML = pack.routes.length;
const averageLength = rn(d3.mean(pack.routes.map(r => r.length)));
routesFooterLength.innerHTML = averageLength * distanceScale + " " + distanceUnitInput.value;
// add listeners
body.querySelectorAll("div.states").forEach(el => el.on("mouseenter", routeHighlightOn));
body.querySelectorAll("div.states").forEach(el => el.on("mouseleave", routeHighlightOff));
body.querySelectorAll("div > span.icon-dot-circled").forEach(el => el.on("click", zoomToRoute));
body.querySelectorAll("div > span.icon-pencil").forEach(el => el.on("click", openRouteEditor));
body.querySelectorAll("div > span.locks").forEach(el => el.addEventListener("click", toggleLockStatus));
body.querySelectorAll("div > span.icon-trash-empty").forEach(el => el.on("click", triggerRouteRemove));
applySorting(routesHeader);
}
function routeHighlightOn(event) {
if (!layerIsOn("toggleRoutes")) toggleRoutes();
const routeId = +event.target.dataset.id;
routes
.select("#route" + routeId)
.attr("stroke", "red")
.attr("stroke-width", 2)
.attr("stroke-dasharray", "none");
}
function routeHighlightOff(e) {
const routeId = +e.target.dataset.id;
routes
.select("#route" + routeId)
.attr("stroke", null)
.attr("stroke-width", null)
.attr("stroke-dasharray", null);
}
function zoomToRoute() {
const r = +this.parentNode.dataset.id;
const route = routes.select("#route" + r).node();
highlightElement(route, 3);
}
function downloadRoutesData() {
let data = "Id,Route,Group,Length\n"; // headers
body.querySelectorAll(":scope > div").forEach(function (el) {
const d = el.dataset;
const length = rn(d.length * distanceScale) + " " + distanceUnitInput.value;
data += [d.id, d.name, d.group, length].join(",") + "\n";
});
const name = getFileName("Routes") + ".csv";
downloadFile(data, name);
}
function openRouteEditor() {
const id = "route" + this.parentNode.dataset.id;
editRoute(id);
}
function toggleLockStatus() {
const routeId = +this.parentNode.dataset.id;
const route = pack.routes[routeId];
route.lock = !route.lock;
if (this.classList.contains("icon-lock")) {
this.classList.remove("icon-lock");
this.classList.add("icon-lock-open");
this.classList.add("inactive");
} else {
this.classList.remove("icon-lock-open");
this.classList.add("icon-lock");
this.classList.remove("inactive");
}
}
function toggleLockAll() {
const allLocked = pack.routes.every(route => route.lock);
pack.routes.forEach(route => {
route.lock = !allLocked;
});
routesOverviewAddLines();
byId("routesLockAll").className = allLocked ? "icon-lock" : "icon-lock-open";
}
function triggerRouteRemove() {
const routeId = +this.parentNode.dataset.id;
alertMessage.innerHTML = `Are you sure you want to remove the route?`;
$("#alert").dialog({
resizable: false,
width: "22em",
title: "Remove route",
buttons: {
Remove: function () {
const route = pack.routes.find(r => r.i === routeId);
Routes.remove(route);
routesOverviewAddLines();
$(this).dialog("close");
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
function triggerAllRoutesRemove() {
alertMessage.innerHTML = /* html */ `Are you sure you want to remove all routes? This action can't be undone`;
$("#alert").dialog({
resizable: false,
title: "Remove all routes",
buttons: {
Remove: function () {
pack.cells.routes = {};
pack.routes = [];
routes.selectAll("path").remove();
$(this).dialog("close");
$("#routesOverview").dialog("close");
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
}

View file

@ -503,7 +503,7 @@ styleGridScale.addEventListener("input", function () {
function calculateFriendlyGridSize() {
const size = styleGridScale.value * 25;
const friendly = `${rn(size * distanceScaleInput.value, 2)} ${distanceUnitInput.value}`;
const friendly = `${rn(size * distanceScale, 2)} ${distanceUnitInput.value}`;
styleGridSizeFriendly.value = friendly;
}

View file

@ -258,11 +258,16 @@ window.UISubmap = (function () {
byId("latitudeInput").value = latitudeOutput.value;
// fix scale
distanceScaleInput.value = distanceScaleOutput.value = rn((distanceScale = distanceScaleOutput.value / scale), 2);
distanceScale =
distanceScaleInput.value =
distanceScaleOutput.value =
rn((distanceScale = distanceScaleOutput.value / scale), 2);
populationRateInput.value = populationRateOutput.value = rn(
(populationRate = populationRateOutput.value / scale),
2
);
customization = 0;
startResample(options);
}, 1000);

View file

@ -22,6 +22,7 @@ toolsContent.addEventListener("click", function (event) {
else if (button === "editZonesButton") editZones();
else if (button === "overviewChartsButton") overviewCharts();
else if (button === "overviewBurgsButton") overviewBurgs();
else if (button === "overviewRoutesButton") overviewRoutes();
else if (button === "overviewRiversButton") overviewRivers();
else if (button === "overviewMilitaryButton") overviewMilitary();
else if (button === "overviewMarkersButton") overviewMarkers();
@ -66,7 +67,7 @@ toolsContent.addEventListener("click", function (event) {
if (button === "addLabel") toggleAddLabel();
else if (button === "addBurgTool") toggleAddBurg();
else if (button === "addRiver") toggleAddRiver();
else if (button === "addRoute") toggleAddRoute();
else if (button === "addRoute") createRoute();
else if (button === "addMarker") toggleAddMarker();
// click to create a new map buttons
else if (button === "openSubmapMenu") UISubmap.openSubmapMenu();
@ -79,7 +80,7 @@ function processFeatureRegeneration(event, button) {
ReliefIcons();
if (!layerIsOn("toggleRelief")) toggleRelief();
} else if (button === "regenerateRoutes") {
Routes.regenerate();
regenerateRoutes();
if (!layerIsOn("toggleRoutes")) toggleRoutes();
} else if (button === "regenerateRivers") regenerateRivers();
else if (button === "regeneratePopulation") recalculatePopulation();
@ -115,6 +116,14 @@ async function openEmblemEditor() {
editEmblem(type, id, el);
}
function regenerateRoutes() {
const locked = pack.routes.filter(route => route.lock).map((route, index) => ({...route, i: index}));
Routes.generate(locked);
routes.selectAll("path").remove();
if (layerIsOn("toggleRoutes")) drawRoutes();
}
function regenerateRivers() {
Rivers.generate();
Lakes.defineGroup();
@ -129,7 +138,7 @@ function recalculatePopulation() {
if (!b.i || b.removed || b.lock) return;
const i = b.cell;
b.population = rn(Math.max((pack.cells.s[i] + pack.cells.road[i] / 2) / 8 + b.i / 1000 + (i % 100) / 1000, 0.1), 3);
b.population = rn(Math.max(pack.cells.s[i] / 8 + b.i / 1000 + (i % 100) / 1000, 0.1), 3);
if (b.capital) b.population = b.population * 1.3; // increase capital population
if (b.port) b.population = b.population * 1.3; // increase port population
b.population = rn(b.population * gauss(2, 3, 0.6, 20, 3), 3);
@ -429,7 +438,7 @@ function regenerateBurgs() {
BurgsAndStates.specifyBurgs();
BurgsAndStates.defineBurgFeatures();
BurgsAndStates.drawBurgs();
Routes.regenerate();
regenerateRoutes();
// remove emblems
document.querySelectorAll("[id^=burgCOA]").forEach(el => el.remove());
@ -792,34 +801,6 @@ function addRiverOnClick() {
}
}
function toggleAddRoute() {
const pressed = document.getElementById("addRoute").classList.contains("pressed");
if (pressed) {
unpressClickToAddButton();
return;
}
addFeature.querySelectorAll("button.pressed").forEach(b => b.classList.remove("pressed"));
addRoute.classList.add("pressed");
closeDialogs(".stable");
viewbox.style("cursor", "crosshair").on("click", addRouteOnClick);
tip("Click on map to add a first control point", true);
if (!layerIsOn("toggleRoutes")) toggleRoutes();
}
function addRouteOnClick() {
unpressClickToAddButton();
const point = d3.mouse(this);
const id = getNextId("route");
elSelected = routes
.select("g")
.append("path")
.attr("id", id)
.attr("data-new", 1)
.attr("d", `M${point[0]},${point[1]}`);
editRoute(true);
}
function toggleAddMarker() {
const pressed = document.getElementById("addMarker")?.classList.contains("pressed");
if (pressed) {
@ -960,6 +941,6 @@ function viewCellDetails() {
}
async function overviewCharts() {
const Overview = await import("../dynamic/overview/charts-overview.js?v=1.89.24");
const Overview = await import("../dynamic/overview/charts-overview.js?v=1.99.00");
Overview.open();
}

View file

@ -55,6 +55,7 @@ function editUnits() {
}
function changeDistanceScale() {
distanceScale = +this.value;
renderScaleBar();
calculateFriendlyGridSize();
}
@ -90,10 +91,9 @@ function editUnits() {
}
function restoreDefaultUnits() {
// distanceScale
distanceScale = 3;
byId("distanceScaleOutput").value = 3;
byId("distanceScaleInput").value = 3;
byId("distanceScaleOutput").value = distanceScale;
byId("distanceScaleInput").value = distanceScale;
unlock("distanceScale");
// units
@ -179,13 +179,15 @@ function editUnits() {
tip("Draw a curve along routes to measure length. Hold Shift to measure away from roads.", true);
unitsBottom.querySelectorAll(".pressed").forEach(button => button.classList.remove("pressed"));
this.classList.add("pressed");
viewbox.style("cursor", "crosshair").call(
d3.drag().on("start", function () {
const cells = pack.cells;
const burgs = pack.burgs;
const point = d3.mouse(this);
const c = findCell(point[0], point[1]);
if (cells.road[c] || d3.event.sourceEvent.shiftKey) {
if (Routes.isConnected(c) || d3.event.sourceEvent.shiftKey) {
const b = cells.burg[c];
const x = b ? burgs[b].x : cells.p[c][0];
const y = b ? burgs[b].y : cells.p[c][1];
@ -194,7 +196,7 @@ function editUnits() {
d3.event.on("drag", function () {
const point = d3.mouse(this);
const c = findCell(point[0], point[1]);
if (cells.road[c] || d3.event.sourceEvent.shiftKey) {
if (Routes.isConnected(c) || d3.event.sourceEvent.shiftKey) {
routeOpisometer.trackCell(c, true);
}
});

View file

@ -105,13 +105,12 @@ function editWorld() {
calculateMapCoordinates();
const mc = mapCoordinates;
const scale = +distanceScaleInput.value;
const unit = distanceUnitInput.value;
const meridian = toKilometer(eqD * 2 * scale);
const meridian = toKilometer(eqD * 2 * distanceScale);
byId("mapSize").innerHTML = `${graphWidth}x${graphHeight}`;
byId("mapSizeFriendly").innerHTML = `${rn(graphWidth * scale)}x${rn(graphHeight * scale)} ${unit}`;
byId("mapSizeFriendly").innerHTML = `${rn(graphWidth * distanceScale)}x${rn(graphHeight * distanceScale)} ${unit}`;
byId("meridianLength").innerHTML = rn(eqD * 2);
byId("meridianLengthFriendly").innerHTML = `${rn(eqD * 2 * scale)} ${unit}`;
byId("meridianLengthFriendly").innerHTML = `${rn(eqD * 2 * distanceScale)} ${unit}`;
byId("meridianLengthEarth").innerHTML = meridian ? " = " + rn(meridian / 200) + "%🌏" : "";
byId("mapCoordinates").innerHTML = `${lat(mc.latN)} ${Math.abs(rn(mc.lonW))}°W; ${lat(mc.latS)} ${rn(mc.lonE)}°E`;

View file

@ -14,11 +14,27 @@ function toHEX(rgb) {
: "";
}
const C_12 = [
"#dababf",
"#fb8072",
"#80b1d3",
"#fdb462",
"#b3de69",
"#fccde5",
"#c6b9c1",
"#bc80bd",
"#ccebc5",
"#ffed6f",
"#8dd3c7",
"#eb8de7"
];
const scaleRainbow = d3.scaleSequential(d3.interpolateRainbow);
// return array of standard shuffled colors
function getColors(number) {
const c12 = ["#dababf", "#fb8072", "#80b1d3", "#fdb462", "#b3de69", "#fccde5", "#c6b9c1", "#bc80bd", "#ccebc5", "#ffed6f", "#8dd3c7", "#eb8de7"];
const cRB = d3.scaleSequential(d3.interpolateRainbow);
const colors = d3.shuffle(d3.range(number).map(i => (i < 12 ? c12[i] : d3.color(cRB((i - 12) / (number - 12))).hex())));
const colors = d3.shuffle(
d3.range(number).map(i => (i < 12 ? C_12[i] : d3.color(scaleRainbow((i - 12) / (number - 12))).hex()))
);
return colors;
}

View file

@ -9,7 +9,6 @@ function clipPoly(points, secure = 0) {
// get segment of any point on polyline
function getSegmentId(points, point, step = 10) {
if (points.length === 2) return 1;
const d2 = (p1, p2) => (p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2;
let minSegment = 1;
let minDist = Infinity;
@ -18,7 +17,7 @@ function getSegmentId(points, point, step = 10) {
const p1 = points[i];
const p2 = points[i + 1];
const length = Math.sqrt(d2(p1, p2));
const length = Math.sqrt(dist2(p1, p2));
const segments = Math.ceil(length / step);
const dx = (p2[0] - p1[0]) / segments;
const dy = (p2[1] - p1[1]) / segments;
@ -26,10 +25,10 @@ function getSegmentId(points, point, step = 10) {
for (let s = 0; s < segments; s++) {
const x = p1[0] + s * dx;
const y = p1[1] + s * dy;
const dist2 = d2(point, [x, y]);
const dist = dist2(point, [x, y]);
if (dist2 >= minDist) continue;
minDist = dist2;
if (dist >= minDist) continue;
minDist = dist;
minSegment = i + 1;
}
}
@ -37,20 +36,6 @@ function getSegmentId(points, point, step = 10) {
return minSegment;
}
// return center point of common edge of 2 pack cells
function getMiddlePoint(cell1, cell2) {
const {cells, vertices} = pack;
const commonVertices = cells.v[cell1].filter(vertex => vertices.c[vertex].some(cell => cell === cell2));
const [x1, y1] = vertices.p[commonVertices[0]];
const [x2, y2] = vertices.p[commonVertices[1]];
const x = (x1 + x2) / 2;
const y = (y1 + y2) / 2;
return [x, y];
}
function debounce(func, ms) {
let isCooldown = false;

58
utils/debugUtils.js Normal file
View file

@ -0,0 +1,58 @@
"use strict";
// FMG utils used for debugging
function drawCellsValue(data) {
debug.selectAll("text").remove();
debug
.selectAll("text")
.data(data)
.enter()
.append("text")
.attr("x", (d, i) => pack.cells.p[i][0])
.attr("y", (d, i) => pack.cells.p[i][1])
.text(d => d);
}
function drawPolygons(data) {
const max = d3.max(data);
const min = d3.min(data);
const scheme = getColorScheme(terrs.select("#landHeights").attr("scheme"));
data = data.map(d => 1 - normalize(d, min, max));
debug.selectAll("polygon").remove();
debug
.selectAll("polygon")
.data(data)
.enter()
.append("polygon")
.attr("points", (d, i) => getGridPolygon(i))
.attr("fill", d => scheme(d))
.attr("stroke", d => scheme(d));
}
function drawRouteConnections() {
debug.select("#connections").remove();
const routes = debug.append("g").attr("id", "connections").attr("stroke-width", 0.4);
const points = pack.cells.p;
const links = pack.cells.routes;
for (const from in links) {
for (const to in links[from]) {
const [x1, y1] = points[from];
const [x3, y3] = points[to];
const [x2, y2] = [(x1 + x3) / 2, (y1 + y3) / 2];
const routeId = links[from][to];
routes
.append("line")
.attr("x1", x1)
.attr("y1", y1)
.attr("x2", x2)
.attr("y2", y2)
.attr("data-id", routeId)
.attr("stroke", C_12[routeId % 12]);
}
}
}

View file

@ -1,7 +1,9 @@
"use strict";
// FMG helper functions
// extracted d3 code to bypass version conflicts
// https://github.com/d3/d3-array/blob/main/src/group.js
export function rollups(values, reduce, ...keys) {
function rollups(values, reduce, ...keys) {
return nest(values, Array.from, reduce, keys);
}
@ -23,3 +25,7 @@ function nest(values, map, reduce, keys) {
return map(groups);
})(values, 0);
}
function dist2([x1, y1], [x2, y2]) {
return (x1 - x2) ** 2 + (y1 - y2) ** 2;
}

View file

@ -312,38 +312,6 @@ void (function addFindAll() {
};
})();
// helper function non-used for the generation
function drawCellsValue(data) {
debug.selectAll("text").remove();
debug
.selectAll("text")
.data(data)
.enter()
.append("text")
.attr("x", (d, i) => pack.cells.p[i][0])
.attr("y", (d, i) => pack.cells.p[i][1])
.text(d => d);
}
// helper function non-used for the main generation
function drawPolygons(data) {
const max = d3.max(data);
const min = d3.min(data);
const scheme = getColorScheme(terrs.select("#landHeights").attr("scheme"));
data = data.map(d => 1 - normalize(d, min, max));
debug.selectAll("polygon").remove();
debug
.selectAll("polygon")
.data(data)
.enter()
.append("polygon")
.attr("points", (d, i) => getGridPolygon(i))
.attr("fill", d => scheme(d))
.attr("stroke", d => scheme(d));
}
// draw raster heightmap preview (not used in main generation)
function drawHeights({heights, width, height, scheme, renderOcean}) {
const canvas = document.createElement("canvas");

View file

@ -1,7 +1,7 @@
"use strict";
// version and caching control
const version = "1.98.07"; // generator version, update each time
const version = "1.99.00"; // generator version, update each time
{
document.title += " v" + version;
@ -28,6 +28,8 @@ const version = "1.98.07"; // generator version, update each time
<ul>
<strong>Latest changes:</strong>
<li>New routes generatation algorithm</li>
<li>Routes overview tool</li>
<li>Configurable longitude</li>
<li>Preview villages map</li>
<li>Ability to render ocean heightmap</li>
@ -41,7 +43,6 @@ const version = "1.98.07"; // generator version, update each time
<li>North and South Poles temperature can be set independently</li>
<li>More than 70 new heraldic charges</li>
<li>Multi-color heraldic charges support</li>
<li>New 3D scene options and improvements</li>
</ul>
<p>Join our <a href="${discord}" target="_blank">Discord server</a> and <a href="${reddit}" target="_blank">Reddit community</a> to ask questions, share maps, discuss the Generator and Worlbuilding, report bugs and propose new features.</p>