Merge branch 'master' of https://github.com/Azgaar/Fantasy-Map-Generator into burg-groups

This commit is contained in:
Azgaar 2024-12-12 13:29:40 +01:00
commit e402120b8d
42 changed files with 1526 additions and 1067 deletions

2
.github/FUNDING.yml vendored
View file

@ -1,3 +1,3 @@
# These are supported funding model platforms
github: Azgaar
patreon: Azgaar

View file

@ -1081,8 +1081,14 @@
id="styleGridSizeFriendly"
data-tip="Distance between grid cell centers (in map scale)"
></output>
<a href="https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Scale-and-distance#grids" target="_blank">
<span data-tip="Open wiki article scale and distance to know about grid scale" class="icon-info-circled pointer"></span>
<a
href="https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Scale-and-distance#grids"
target="_blank"
>
<span
data-tip="Open wiki article scale and distance to know about grid scale"
class="icon-info-circled pointer"
></span>
</a>
</td>
</tr>
@ -1631,10 +1637,10 @@
</td>
<td>Cultures number</td>
<td>
<input id="culturesInput" data-stored="cultures" type="range" min="1" max="32" value="14" />
<input id="culturesInput" data-stored="cultures" type="range" min="1" />
</td>
<td>
<input id="culturesOutput" data-stored="cultures" type="number" min="1" max="32" value="14" />
<input id="culturesOutput" data-stored="cultures" type="number" min="1" />
</td>
</tr>
@ -1834,6 +1840,7 @@
<select id="azgaarAssistant" data-stored="azgaarAssistant">
<option value="show" selected>Show</option>
<option value="hide">Hide</option>
</select>
</td>
</tr>
@ -2179,8 +2186,8 @@
<div class="separator">Create</div>
<div class="grid">
<button id="openSubmapMenu" data-tip="Click to generate a submap from the current viewport">Submap</button>
<button id="openResampleMenu" data-tip="Click to transform the map">Transform</button>
<button id="openSubmapTool" data-tip="Click to generate a submap from the current viewport">Submap</button>
<button id="openTransformTool" data-tip="Click to transform the map">Transform</button>
</div>
</div>
@ -2418,7 +2425,9 @@
<div id="exitCustomization">
<div data-tip="Drag to move the pane">
<button data-tip="Finalize the heightmap and exit the edit mode" id="finalizeHeightmap">Exit Customization</button>
<button data-tip="Finalize the heightmap and exit the edit mode" id="finalizeHeightmap">
Exit Customization
</button>
</div>
</div>
@ -3498,7 +3507,11 @@
<button id="burgEditEmblem" data-tip="Edit emblem" class="icon-shield-alt"></button>
<button id="burgSetPreviewLink" data-tip="Set custom burg map URL" class="icon-map-o"></button>
<button id="burgLocate" data-tip="Zoom map and center view in the burg" class="icon-target"></button>
<button id="burgRelocate" data-tip="Relocate burg. Click on map to move the burg" class="icon-map-pin"></button>
<button
id="burgRelocate"
data-tip="Relocate burg. Click on map to move the burg"
class="icon-map-pin"
></button>
<button id="burglLegend" data-tip="Edit free text notes (legend) for this burg" class="icon-edit"></button>
<button id="burgLock" class="icon-lock-open" onmouseover="showElementLockTip(event)"></button>
<button
@ -4756,7 +4769,7 @@
<span style="margin-left: 2px">Names data: </span>
</div>
<div id="namesbaseBody" style="margin-block: 2px">
<div id="namesbaseBody" style="margin-block: 2px; width: auto">
<textarea
id="namesbaseTextarea"
data-base="0"
@ -4765,6 +4778,7 @@
placeholder="Provide a names data: a comma separated list of source names"
autocorrect="off"
spellcheck="false"
style="resize: none"
></textarea>
<div>
@ -4936,18 +4950,21 @@
>Model:
<select id="aiGeneratorModel"></select>
</label>
<label
for="aiGeneratorTemperature"
data-tip="Temperature controls response randomness; higher values mean more creativity, lower values mean more predictability"
>
Temperature:
<input id="aiGeneratorTemperature" type="number" min="-1" max="2" step=".1" class="icon-key" />
</label>
<label for="aiGeneratorKey"
>Key:
<input id="aiGeneratorKey" placeholder="Enter OpenAI API key" class="icon-key" />
<a
href="https://platform.openai.com/account/api-keys"
target="_blank"
rel="noreferrer"
<input id="aiGeneratorKey" placeholder="Enter API key" class="icon-key" />
<button
id="aiGeneratorKeyHelp"
class="icon-help-circled"
style="text-decoration: none"
data-tip="Get the key at OpenAI website. The key will be stored in your browser and send to OpenAI API directly. The Map Genenerator doesn't store the key or any generated data"
></a>
data-tip="Open provider's website to get the API key there. Note: the Map Genenerator doesn't store the key or any generated data"
/>
</label>
</div>
</div>
@ -5275,9 +5292,7 @@
<div data-tip="Click to sort by culture name" class="sortable alphabetically" data-sortby="culture">
Culture
</div>
<div data-tip="Click to sort by culture group" class="sortable alphabetically" data-sortby="group">
Group
</div>
<div data-tip="Click to sort by culture group" class="sortable alphabetically" data-sortby="group">Group</div>
<div
data-tip="Click to sort by burg population"
class="sortable icon-sort-number-down"
@ -5285,7 +5300,9 @@
>
Population
</div>
<div data-tip="Click to sort by burg features" class="sortable alphabetically" data-sortby="features">Features&nbsp;</div>
<div data-tip="Click to sort by burg features" class="sortable alphabetically" data-sortby="features">
Features&nbsp;
</div>
</div>
<div id="burgsBody" class="table"></div>
@ -5342,7 +5359,11 @@
<th data-tip="Type group name">Name</th>
<th data-tip="Burg preview generator">Preview generator</th>
<th data-tip="Set min population constraint" colspan="2">Population</th>
<th data-tip="Set population percentile: 0-100, where 90 means the burg must have a population higher than 90% of all burgs">Percentile</th>
<th
data-tip="Set population percentile: 0-100, where 90 means the burg must have a population higher than 90% of all burgs"
>
Percentile
</th>
<th data-tip="Select allowed biomes">Biomes</th>
<th data-tip="Select allowed states">States</th>
<th data-tip="Select allowed cultures">Cultures</th>
@ -5765,6 +5786,83 @@
</div>
</div>
<div id="submapTool" style="display: none" class="dialog">
<p style="font-weight: bold">
This operation is destructive and irreversible. It will create a completely new map based on the current one.
Don't forget to save the .map file to your machine first!
</p>
<div style="display: flex; flex-direction: column; gap: 0.5em">
<div data-tip="Set points (cells) number of the submap" style="display: flex; gap: 1em">
<div>Points number</div>
<div>
<input id="submapPointsInput" type="range" min="1" max="13" value="4" />
<output id="submapPointsFormatted" style="color: #053305">10K</output>
</div>
</div>
<div data-tip="Check to fit burg styles (icon and label size) to the submap scale">
<input type="checkbox" class="checkbox" id="submapRescaleBurgStyles" checked />
<label for="submapRescaleBurgStyles" class="checkbox-label">Rescale burg styles</label>
</div>
</div>
</div>
<div id="transformTool" style="display: none" class="dialog">
<div style="padding-top: 0.5em; width: 40em; font-weight: bold">
This operation is destructive and irreversible. It will create a completely new map based on the current one.
Don't forget to save the .map file to your machine first!
</div>
<div
id="transformToolBody"
style="
padding: 0.5em 0;
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: repeat(5, 1fr);
align-items: center;
"
>
<div>Points number</div>
<div>
<input id="transformPointsInput" type="range" min="1" max="13" value="4" />
<output id="transformPointsFormatted" style="color: #053305">10K</output>
</div>
<div>Shift</div>
<div>
<label>X: <input id="transformShiftX" type="number" size="4" value="0" /></label>
<label>Y: <input id="transformShiftY" type="number" size="4" value="0" /></label>
</div>
<div>Rotate</div>
<div>
<input id="transformAngleInput" type="range" min="0" max="359" value="0" />
<output id="transformAngleOutput">0</output>°
</div>
<div>Scale</div>
<div>
<input id="transformScaleInput" type="range" min="-25" max="25" value="0" />
<output id="transformScaleResult">1</output>x
</div>
<div>Mirror</div>
<div style="display: flex; gap: 0.5em">
<input type="checkbox" class="checkbox" id="transformMirrorH" />
<label for="transformMirrorH" class="checkbox-label">horizontally</label>
<input type="checkbox" class="checkbox" id="transformMirrorV" />
<label for="transformMirrorV" class="checkbox-label">vertically</label>
</div>
</div>
<div id="transformPreview" style="position: relative; overflow: hidden; outline: 1px solid #666">
<canvas id="transformPreviewCanvas" style="position: absolute; transform-origin: center"></canvas>
</div>
</div>
<div id="options3d" class="dialog stable" style="display: none">
<div id="options3dMesh" style="display: none">
<div data-tip="Set map rotation speed. Set to 0 is you want to toggle off the rotation">
@ -6084,105 +6182,6 @@
<div id="tileStatus" style="font-style: italic"></div>
</div>
<div id="resampleDialog" style="display: none" class="dialog">
<div style="width: 34em; max-width: 80vw; font-weight: bold; padding: 6px">
This operation is destructive and irreversible. It will create a completely new map based on the current one.
Don't forget to save the current project to your machine first!
</div>
<div
style="
width: auto;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: repeat(5, 1fr);
align-items: center;
padding: 0.5em;
"
>
<div>Points number</div>
<div>
<input id="submapPointsInput" type="range" min="1" max="13" value="4" />
<output id="submapPointsOutputFormatted" style="color: #053305">10K</output>
</div>
<div>Shift</div>
<div>
<label>X: <input id="submapShiftX" type="number" size="4" value="0" /></label>
<label>Y: <input id="submapShiftY" type="number" size="4" value="0" /></label>
</div>
<div>Rotate</div>
<div>
<input id="submapAngleInput" type="range" min="0" max="359" value="0" />
<output id="submapAngleOutput">0</output>°
</div>
<div>Scale</div>
<div>
<input id="submapScaleInput" type="range" min="-25" max="25" value="0" />
<output id="submapScaleOutput">1</output>x
</div>
<div>Mirror</div>
<div>
<input type="checkbox" class="checkbox" id="submapMirrorH" />
<label for="submapMirrorH" class="checkbox-label">horizontally</label>
&nbsp;
<input type="checkbox" class="checkbox" id="submapMirrorV" />
<label for="submapMirrorV" class="checkbox-label">vertically</label>
</div>
</div>
<div
id="submapPreview"
style="border: 1px solid #666; margin: 1em auto; overflow: hidden; position: relative"
></div>
</div>
<div id="submapOptionsDialog" style="display: none" class="dialog">
<p style="font-weight: bold">
This operation is destructive and irreversible. It will create a completely new map based on the current one.
Don't forget to save the current project as a .map file first!
</p>
<p>Settings to be changed: population rate, map pixel size</p>
<p>
Data to be copied: heightmap, biomes, religions, population, precipitation, cultures, states, provinces,
military regiments
</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>
<div data-tip="Lock all markers copied from the original map">
<input id="submapLockMarkers" class="checkbox" type="checkbox" checked />
<label for="submapLockMarkers" class="checkbox-label">Markers</label>
</div>
<div data-tip="Lock all burgs copied from the original map">
<input id="submapLockBurgs" class="checkbox" type="checkbox" checked />
<label for="submapLockBurgs" class="checkbox-label">Burgs</label>
</div>
<p>Experimental features:</p>
<div data-tip="Rivers on the parent map will errode land (helps to get similar river network)">
<input id="submapDepressRivers" class="checkbox" type="checkbox" />
<label for="submapDepressRivers" class="checkbox-label">Errode riverbeds</label>
</div>
<div data-tip="Rescale styles (burg labels, emblem size) to match the new scale">
<input id="submapRescaleStyles" class="checkbox" type="checkbox" checked />
<label for="submapRescaleStyles" class="checkbox-label">Rescale styles</label>
</div>
<div data-tip="Move all existing towns to the 'largetown' burg group">
<input id="submapPromoteTowns" class="checkbox" type="checkbox" />
<label for="submapPromoteTowns" class="checkbox-label">Promote towns to largetowns</label>
</div>
<div data-tip="Add lakes in depressions (can be very slow on big landmasses)">
<input id="submapAddLakeInDepression" class="checkbox" type="checkbox" />
<label for="submapAddLakeInDepression" class="checkbox-label">Add lakes in depressions (slow)</label>
</div>
</div>
<div id="alert" style="display: none" class="dialog">
<p id="alertMessage">Warning!</p>
</div>
@ -7798,7 +7797,9 @@
</symbol>
<symbol id="icon-store" viewBox="0 0 616 512">
<path d="M602 118.6L537.1 15C531.3 5.7 521 0 510 0H106C95 0 84.7 5.7 78.9 15L14 118.6c-33.5 53.5-3.8 127.9 58.8 136.4 4.5.6 9.1.9 13.7.9 29.6 0 55.8-13 73.8-33.1 18 20.1 44.3 33.1 73.8 33.1 29.6 0 55.8-13 73.8-33.1 18 20.1 44.3 33.1 73.8 33.1 29.6 0 55.8-13 73.8-33.1 18.1 20.1 44.3 33.1 73.8 33.1 4.7 0 9.2-.3 13.7-.9 62.8-8.4 92.6-82.8 59-136.4zM529.5 288c-10 0-19.9-1.5-29.5-3.8V384H116v-99.8c-9.6 2.2-19.5 3.8-29.5 3.8-6 0-12.1-.4-18-1.2-5.6-.8-11.1-2.1-16.4-3.6V480c0 17.7 14.3 32 32 32h448c17.7 0 32-14.3 32-32V283.2c-5.4 1.6-10.8 2.9-16.4 3.6-6.1.8-12.1 1.2-18.2 1.2z" />
<path
d="M602 118.6L537.1 15C531.3 5.7 521 0 510 0H106C95 0 84.7 5.7 78.9 15L14 118.6c-33.5 53.5-3.8 127.9 58.8 136.4 4.5.6 9.1.9 13.7.9 29.6 0 55.8-13 73.8-33.1 18 20.1 44.3 33.1 73.8 33.1 29.6 0 55.8-13 73.8-33.1 18 20.1 44.3 33.1 73.8 33.1 29.6 0 55.8-13 73.8-33.1 18.1 20.1 44.3 33.1 73.8 33.1 4.7 0 9.2-.3 13.7-.9 62.8-8.4 92.6-82.8 59-136.4zM529.5 288c-10 0-19.9-1.5-29.5-3.8V384H116v-99.8c-9.6 2.2-19.5 3.8-29.5 3.8-6 0-12.1-.4-18-1.2-5.6-.8-11.1-2.1-16.4-3.6V480c0 17.7 14.3 32 32 32h448c17.7 0 32-14.3 32-32V283.2c-5.4 1.6-10.8 2.9-16.4 3.6-6.1.8-12.1 1.2-18.2 1.2z"
/>
</symbol>
<symbol id="icon-route" viewBox="0 0 512 512">
@ -7866,32 +7867,57 @@
<pattern id="pattern_square" width="25" height="25" patternUnits="userSpaceOnUse" fill="none">
<path d="M 25 0 L 0 0 0 25" />
</pattern>
<pattern id="pattern_pointyHex" width="25" height="43.4" patternUnits="userSpaceOnUse" fill="none">
<path d="M 0,0 12.5,7.2 25,0 M 12.5,21.7 V 7.2 Z M 0,43.4 V 28.9 L 12.5,21.7 25,28.9 v 14.5" />
</pattern>
<pattern id="pattern_flatHex" width="43.4" height="25" patternUnits="userSpaceOnUse" fill="none">
<path d="M 43.4,0 36.2,12.5 43.4,25 M 21.7,12.5 H 36.2 Z M 0,0 H 14.5 L 21.7,12.5 14.5,25 H 0" />
</pattern>
<pattern id="pattern_square45deg" width="35.355" height="35.355" patternUnits="userSpaceOnUse" fill="none">
<path d="M 0 0 L 35.355 35.355 M 0 35.355 L 35.355 0" />
</pattern>
<pattern id="pattern_squareTruncated" width="25" height="25" patternUnits="userSpaceOnUse" fill="none">
<path d="M 8.33 25 L 0 16.66 V 8.33 L 8.33 0 16.66 0 25 8.33 M 16.66 25 L 25 16.66 L 25 8.33 M 8.33 25 L 16.66 25" />
<path
d="M 8.33 25 L 0 16.66 V 8.33 L 8.33 0 16.66 0 25 8.33 M 16.66 25 L 25 16.66 L 25 8.33 M 8.33 25 L 16.66 25"
/>
</pattern>
<pattern id="pattern_squareTetrakis" width="25" height="25" patternUnits="userSpaceOnUse" fill="none">
<path d="M 25 0 L 0 0 0 25 M 0 0 L 25 25 M 0 25 L 25 0 M 12.5 0 L 12.5 25 M 0 12.5 L 25 12.5 M 0 25 L 25 25 L 25 0" />
<path
d="M 25 0 L 0 0 0 25 M 0 0 L 25 25 M 0 25 L 25 0 M 12.5 0 L 12.5 25 M 0 12.5 L 25 12.5 M 0 25 L 25 25 L 25 0"
/>
</pattern>
<pattern id="pattern_triangleHorizontal" width="41.76" height="72.33" patternUnits="userSpaceOnUse" fill="none">
<path d="M 41.76 36.165 H 0 L 20.88 0 41.76 36.165 20.88 72.33 0 36.165 M 0 0 H 72.33 M 0 72.33 L 41.76 72.33" />
<pattern
id="pattern_triangleHorizontal"
width="41.76"
height="72.33"
patternUnits="userSpaceOnUse"
fill="none"
>
<path
d="M 41.76 36.165 H 0 L 20.88 0 41.76 36.165 20.88 72.33 0 36.165 M 0 0 H 72.33 M 0 72.33 L 41.76 72.33"
/>
</pattern>
<pattern id="pattern_triangleVertical" width="72.33" height="41.76" patternUnits="userSpaceOnUse" fill="none">
<path d="M 36.165 0 L 0 20.88 36.165 41.76 72.33 20.88 36.165 0 V 41.76 M 0 0 V 72.33 M 72.33 0 L 72.33 41.76">
<path
d="M 36.165 0 L 0 20.88 36.165 41.76 72.33 20.88 36.165 0 V 41.76 M 0 0 V 72.33 M 72.33 0 L 72.33 41.76"
/>
</pattern>
<pattern id="pattern_trihexagonal" width="25" height="43.4" patternUnits="userSpaceOnUse" fill="none">
<path d="M 25 10.85 H 0 L 18.85 43.4 25 32.55 H 0 L 18.85 0 25 10.85" />
</pattern>
<pattern id="pattern_rhombille" width="82.5" height="50" patternUnits="userSpaceOnUse" fill="none">
<path d="M 13.8 50 L 0 25 13.8 0 H 41.2 L 27.5 25 41.2 50 55 25 41.2 0 68.8 0 82.5 25 68.8 50 M 0 25 H 27.5 M 55 25 H 82.5 M 13.8 50 H 41.2 L 68.8 50" />
<path
d="M 13.8 50 L 0 25 13.8 0 H 41.2 L 27.5 25 41.2 50 55 25 41.2 0 68.8 0 82.5 25 68.8 50 M 0 25 H 27.5 M 55 25 H 82.5 M 13.8 50 H 41.2 L 68.8 50"
/>
</pattern>
</g>
@ -8051,26 +8077,25 @@
<script src="libs/jquery-ui.min.js"></script>
<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.99.00"></script>
<script src="utils/shorthands.js?v=1.99.00"></script>
<script src="utils/shorthands.js?v=1.106.0"></script>
<script src="utils/commonUtils.js?v=1.103.0"></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/graphUtils.js?v=1.106.0"></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.05"></script>
<script src="utils/stringUtils.js?v=1.99.00"></script>
<script src="utils/stringUtils.js?v=1.106.0"></script>
<script src="utils/languageUtils.js?v=1.99.00"></script>
<script src="utils/unitUtils.js?v=1.99.00"></script>
<script src="utils/pathUtils.js?v=1.105.6"></script>
<script defer src="utils/debugUtils.js?v=1.99.00"></script>
<script src="utils/pathUtils.js?v=1.106.0"></script>
<script defer src="utils/debugUtils.js?v=1.106.0"></script>
<script src="modules/voronoi.js"></script>
<script src="config/heightmap-templates.js"></script>
@ -8078,62 +8103,61 @@
<script src="modules/heightmap-generator.js?v=1.99.00"></script>
<script src="modules/features.js?v=1.104.0"></script>
<script src="modules/ocean-layers.js?v=1.104.8"></script>
<script src="modules/river-generator.js?v=1.99.05"></script>
<script src="modules/river-generator.js?v=1.106.0"></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.99.05"></script>
<script src="modules/burgs-generator.js?v=1.105.7"></script>
<script src="modules/states-generator.js?v=1.105.7"></script>
<script src="modules/provinces-generator.js?v=1.104.0"></script>
<script src="modules/routes-generator.js?v=1.104.10"></script>
<script src="modules/religions-generator.js?v=1.99.05"></script>
<script src="modules/names-generator.js?v=1.106.0"></script>
<script src="modules/cultures-generator.js?v=1.106.0"></script>
<script src="modules/burgs-and-states.js?v=1.106.0"></script>
<script src="modules/provinces-generator.js?v=1.106.0"></script>
<script src="modules/routes-generator.js?v=1.106.0"></script>
<script src="modules/religions-generator.js?v=1.106.0"></script>
<script src="modules/military-generator.js?v=1.104.0"></script>
<script src="modules/markers-generator.js?v=1.104.0"></script>
<script src="modules/zones-generator.js?v=1.104.0"></script>
<script src="modules/zones-generator.js?v=1.106.0"></script>
<script src="modules/coa-generator.js?v=1.99.00"></script>
<script src="modules/submap.js?v=1.104.0"></script>
<script src="modules/resample.js?v=1.105.13"></script>
<script src="libs/alea.min.js?v1.105.0"></script>
<script src="libs/polylabel.min.js?v1.105.0"></script>
<script src="libs/lineclip.min.js?v1.105.0"></script>
<script src="libs/simplify.js?v1.105.6"></script>
<script src="modules/fonts.js?v=1.99.03"></script>
<script src="modules/ui/layers.js?v=1.101.00"></script>
<script src="modules/ui/layers.js?v=1.106.0"></script>
<script src="modules/ui/measurers.js?v=1.99.00"></script>
<script src="modules/ui/style-presets.js?v=1.100.00"></script>
<script src="modules/ui/general.js?v=1.100.00"></script>
<script src="modules/ui/options.js?v=1.105.0"></script>
<script src="main.js?v=1.105.2"></script>
<script src="modules/ui/options.js?v=1.106.0"></script>
<script src="main.js?v=1.106.0"></script>
<script defer src="modules/relief-icons.js?v=1.99.05"></script>
<script defer src="modules/ui/style.js?v=1.104.0"></script>
<script defer src="modules/ui/editors.js?v=1.105.2"></script>
<script defer src="modules/ui/tools.js?v=1.104.0"></script>
<script defer src="modules/ui/editors.js?v=1.105.23"></script>
<script defer src="modules/ui/tools.js?v=1.106.0"></script>
<script defer src="modules/ui/world-configurator.js?v=1.105.4"></script>
<script defer src="modules/ui/heightmap-editor.js?v=1.105.2"></script>
<script defer src="modules/ui/provinces-editor.js?v=1.104.0"></script>
<script defer src="modules/ui/biomes-editor.js?v=1.99.05"></script>
<script defer src="modules/ui/namesbase-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/namesbase-editor.js?v=1.105.11"></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.104.3"></script>
<script defer src="modules/ui/routes-creator.js?v=1.104.3"></script>
<script defer src="modules/ui/route-group-editor.js?v=1.103.8"></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/lakes-editor.js?v=1.106.0"></script>
<script defer src="modules/ui/coastline-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/labels-editor.js?v=1.101.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/labels-editor.js?v=1.106.0"></script>
<script defer src="modules/ui/rivers-editor.js?v=1.106.0"></script>
<script defer src="modules/ui/rivers-creator.js?v=1.106.0"></script>
<script defer src="modules/ui/relief-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/burg-editor.js?v=1.102.00"></script>
<script defer src="modules/ui/burg-group-editor.js?v=1.106.0"></script>
<script defer src="modules/ui/units-editor.js?v=1.104.0"></script>
<script defer src="modules/ui/notes-editor.js?v=1.99.06"></script>
<script defer src="modules/ui/ai-generator.js?v=1.99.09"></script>
<script defer src="modules/ui/ai-generator.js?v=1.105.22"></script>
<script defer src="modules/ui/diplomacy-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/zones-editor.js?v=1.100.00"></script>
<script defer src="modules/ui/burgs-overview.js?v=1.105.7"></script>
<script defer src="modules/ui/zones-editor.js?v=1.105.20"></script>
<script defer src="modules/ui/burgs-overview.js?v=1.105.15"></script>
<script defer src="modules/ui/routes-overview.js?v=1.104.3"></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>
@ -8144,17 +8168,18 @@
<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.10"></script>
<script defer src="modules/ui/submap-tool.js?v=1.105.13"></script>
<script defer src="modules/ui/transform-tool.js?v=1.105.13"></script>
<script defer src="modules/ui/hotkeys.js?v=1.104.0"></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.100.00"></script>
<script defer src="modules/io/load.js?v=1.105.5"></script>
<script defer src="modules/io/cloud.js?v=1.99.00"></script>
<script defer src="modules/io/load.js?v=1.105.24"></script>
<script defer src="modules/io/cloud.js?v=1.106.0"></script>
<script defer src="modules/io/export.js?v=1.100.00"></script>
<script defer src="modules/renderers/draw-features.js?v=1.104.15"></script>
<script defer src="modules/renderers/draw-features.js?v=1.106.0"></script>
<script defer src="modules/renderers/draw-borders.js?v=1.104.0"></script>
<script defer src="modules/renderers/draw-heightmap.js?v=1.104.0"></script>
<script defer src="modules/renderers/draw-markers.js?v=1.104.0"></script>
@ -8162,7 +8187,7 @@
<script defer src="modules/renderers/draw-temperature.js?v=1.104.0"></script>
<script defer src="modules/renderers/draw-emblems.js?v=1.104.0"></script>
<script defer src="modules/renderers/draw-military.js?v=1.104.13"></script>
<script defer src="modules/renderers/draw-state-labels.js?v=1.104.0"></script>
<script defer src="modules/renderers/draw-state-labels.js?v=1.106.0"></script>
<script defer src="modules/renderers/draw-burg-labels.js?v=1.104.0"></script>
<script defer src="modules/renderers/draw-burg-icons.js?v=1.104.0"></script>
</body>

File diff suppressed because one or more lines are too long

19
main.js
View file

@ -4,7 +4,7 @@
// set debug options
const PRODUCTION = location.hostname && location.hostname !== "localhost" && location.hostname !== "127.0.0.1";
const DEBUG = localStorage.getItem("debug");
const DEBUG = JSON.safeParse(localStorage.getItem("debug")) || {};
const INFO = true;
const TIME = true;
const WARN = true;
@ -494,14 +494,6 @@ function resetZoom(d = 1000) {
svg.transition().duration(d).call(zoom.transform, d3.zoomIdentity);
}
// calculate x y extreme points of viewBox
function getViewBoxExtent() {
return [
[Math.abs(viewX / scale), Math.abs(viewY / scale)],
[Math.abs(viewX / scale) + graphWidth / scale, Math.abs(viewY / scale) + graphHeight / scale]
];
}
// active zooming feature
function invokeActiveZooming() {
const isOptimized = shapeRendering.value === "optimizeSpeed";
@ -728,10 +720,11 @@ function setSeed(precreatedSeed) {
function addLakesInDeepDepressions() {
TIME && console.time("addLakesInDeepDepressions");
const elevationLimit = +byId("lakeElevationLimitOutput").value;
if (elevationLimit === 80) return;
const {cells, features} = grid;
const {c, h, b} = cells;
const ELEVATION_LIMIT = +byId("lakeElevationLimitOutput").value;
if (ELEVATION_LIMIT === 80) return;
for (const i of cells.i) {
if (b[i] || h[i] < 20) continue;
@ -740,7 +733,7 @@ function addLakesInDeepDepressions() {
if (h[i] > minHeight) continue;
let deep = true;
const threshold = h[i] + ELEVATION_LIMIT;
const threshold = h[i] + elevationLimit;
const queue = [i];
const checked = [];
checked[i] = true;
@ -926,7 +919,7 @@ function calculateTemperatures() {
const [, y] = grid.points[rowCellId];
const rowLatitude = mapCoordinates.latN - (y / graphHeight) * mapCoordinates.latT; // [90; -90]
const tempSeaLevel = calculateSeaLevelTemp(rowLatitude);
DEBUG && console.info(`${rn(rowLatitude)}° sea temperature: ${rn(tempSeaLevel)}°C`);
DEBUG.temperature && console.info(`${rn(rowLatitude)}° sea temperature: ${rn(tempSeaLevel)}°C`);
for (let cellId = rowCellId; cellId < rowCellId + grid.cellsX; cellId++) {
const tempAltitudeDrop = getAltitudeTemperatureDrop(cells.h[cellId]);

View file

@ -8,7 +8,10 @@ window.Cultures = (function () {
cells = pack.cells;
const cultureIds = new Uint16Array(cells.i.length); // cell cultures
let count = Math.min(+culturesInput.value, +culturesSet.selectedOptions[0].dataset.max);
const culturesInputNumber = +byId("culturesInput").value;
const culturesInSetNumber = +byId("culturesSet").selectedOptions[0].dataset.max;
let count = Math.min(culturesInputNumber, culturesInSetNumber);
const populated = cells.i.filter(i => cells.s[i]); // populated cells
if (populated.length < count * 25) {
@ -120,26 +123,26 @@ window.Cultures = (function () {
cultures.forEach(c => (c.base = c.base % nameBases.length));
function selectCultures(culturesNumber) {
let def = getDefault(culturesNumber);
let defaultCultures = getDefault(culturesNumber);
const cultures = [];
pack.cultures?.forEach(function (culture) {
if (culture.lock) cultures.push(culture);
if (culture.lock && !culture.removed) cultures.push(culture);
});
if (!cultures.length) {
if (culturesNumber === def.length) return def;
if (def.every(d => d.odd === 1)) return def.splice(0, culturesNumber);
if (culturesNumber === defaultCultures.length) return defaultCultures;
if (defaultCultures.every(d => d.odd === 1)) return defaultCultures.splice(0, culturesNumber);
}
for (let culture, rnd, i = 0; cultures.length < culturesNumber && def.length > 0; ) {
for (let culture, rnd, i = 0; cultures.length < culturesNumber && defaultCultures.length > 0; ) {
do {
rnd = rand(def.length - 1);
culture = def[rnd];
rnd = rand(defaultCultures.length - 1);
culture = defaultCultures[rnd];
i++;
} while (i < 200 && !P(culture.odd));
cultures.push(culture);
def.splice(rnd, 1);
defaultCultures.splice(rnd, 1);
}
return cultures;
}
@ -515,7 +518,7 @@ window.Cultures = (function () {
TIME && console.time("expandCultures");
const {cells, cultures} = pack;
const queue = new PriorityQueue({comparator: (a, b) => a.priority - b.priority});
const queue = new FlatQueue();
const cost = [];
const neutralRate = byId("neutralRate")?.valueAsNumber || 1;
@ -535,11 +538,11 @@ window.Cultures = (function () {
for (const culture of cultures) {
if (!culture.i || culture.removed || culture.lock) continue;
queue.queue({cellId: culture.center, cultureId: culture.i, priority: 0});
queue.push({cellId: culture.center, cultureId: culture.i, priority: 0}, 0);
}
while (queue.length) {
const {cellId, priority, cultureId} = queue.dequeue();
const {cellId, priority, cultureId} = queue.pop();
const {type, expansionism} = cultures[cultureId];
cells.c[cellId].forEach(neibCellId => {
@ -563,7 +566,7 @@ window.Cultures = (function () {
if (!cost[neibCellId] || totalCost < cost[neibCellId]) {
if (cells.pop[neibCellId] > 0) cells.culture[neibCellId] = cultureId; // assign culture to populated cell
cost[neibCellId] = totalCost;
queue.queue({cellId: neibCellId, cultureId, priority: totalCost});
queue.push({cellId: neibCellId, cultureId, priority: totalCost}, totalCost);
}
});
}

View file

@ -943,7 +943,21 @@ export function resolveVersionConflicts(mapVersion) {
viewbox.select("#coastline").selectAll("path, use").remove();
drawFeatures();
// v1.106.0 change burg groups and added customizable icons
// v1.104.0 introduced bugs with state borders
regions
.attr("opacity", null)
.attr("stroke-width", null)
.attr("letter-spacing", null)
.attr("fill", null)
.attr("stroke", null);
// pole can be missing for some states/provinces
BurgsAndStates.getPoles();
Provinces.getPoles();
}
if (isOlderThan("1.107.0")) {
// v1.107.0 changeв burg groups and added customizable icons
icons.selectAll("circle, use").remove();
const groups = Array.from(document.querySelectorAll("#burgIcons > g")).map(g => g.id);

View file

@ -266,6 +266,7 @@ function getTypeOptions(type) {
function getBaseOptions(base) {
let options = "";
nameBases.forEach((n, i) => (options += `<option ${base === i ? "selected" : ""} value="${i}">${n.name}</option>`));
if (!nameBases[base]) options += `<option selected value="${base}">removed</option>`; // in case namesbase was removed
return options;
}
@ -344,10 +345,13 @@ function cultureChangeName() {
}
function cultureRegenerateName() {
const culture = +this.parentNode.dataset.id;
const name = Names.getCultureShort(culture);
const cultureId = +this.parentNode.dataset.id;
const base = pack.cultures[cultureId].base;
if (!nameBases[base]) return tip("Namesbase is not defined, please select a valid namesbase", false, "error", 5000);
const name = Names.getCultureShort(cultureId);
this.parentNode.querySelector("input.cultureName").value = name;
pack.cultures[culture].name = name;
pack.cultures[cultureId].name = name;
}
function cultureChangeExpansionism() {
@ -493,12 +497,15 @@ function cultureRegenerateBurgs() {
if (customization === 4) return;
const cultureId = +this.parentNode.dataset.id;
const cBurgs = pack.burgs.filter(b => b.culture === cultureId && !b.lock);
cBurgs.forEach(b => {
const base = pack.cultures[cultureId].base;
if (!nameBases[base]) return tip("Namesbase is not defined, please select a valid namesbase", false, "error", 5000);
const cultureBurgs = pack.burgs.filter(b => b.culture === cultureId && !b.removed && !b.lock);
cultureBurgs.forEach(b => {
b.name = Names.getCulture(cultureId);
labels.select("[data-id='" + b.i + "']").text(b.name);
});
tip(`Names for ${cBurgs.length} burgs are regenerated`, false, "success");
tip(`Names for ${cultureBurgs.length} burgs are regenerated`, false, "success");
}
function removeCulture(cultureId) {
@ -848,14 +855,15 @@ async function uploadCulturesData() {
this.value = "";
const csv = await file.text();
const data = d3.csvParse(csv, d => ({
i: +d.Id,
name: d.Name,
i: +d.Id,
color: d.Color,
expansionism: +d.Expansionism,
type: d.Type,
population: +d.Population,
emblemsShape: d["Emblems Shape"],
origins: d.Origins
origins: d.Origins,
namesbase: d.Namesbase
}));
const {cultures, cells} = pack;
@ -882,7 +890,7 @@ async function uploadCulturesData() {
culture.i
);
} else {
current = {i: cultures.length, center: ra(populated), area: 0, cells: 0, origin: 0, rural: 0, urban: 0};
current = {i: cultures.length, center: ra(populated), area: 0, cells: 0, origins: [0], rural: 0, urban: 0};
cultures.push(current);
}
@ -902,6 +910,10 @@ async function uploadCulturesData() {
else current.type = "Generic";
}
culture.origins = current.i ? restoreOrigins(culture.origins || "") : [null];
current.shield = shapes.includes(culture.emblemsShape) ? culture.emblemsShape : "heater";
current.base = nameBases.findIndex(n => n.name == culture.namesbase); // can be -1 if namesbase is not found
function restoreOrigins(originsString) {
const originNames = originsString
.replaceAll('"', "")
@ -917,12 +929,6 @@ async function uploadCulturesData() {
current.origins = originIds.filter(id => id !== null);
if (!current.origins.length) current.origins = [0];
}
culture.origins = current.i ? restoreOrigins(culture.origins || "") : [null];
current.shield = shapes.includes(culture.emblemsShape) ? culture.emblemsShape : "heater";
const nameBaseIndex = nameBases.findIndex(n => n.name == culture.namesbase);
current.base = nameBaseIndex === -1 ? 0 : nameBaseIndex;
}
cultures.filter(c => c.removed).forEach(c => removeCulture(c.i));

View file

@ -583,4 +583,10 @@ James Benware
FortunesFaded
breadsticks
Murderbits
Ben Jones`;
Ben Jones
Marco Faltracco
L
silentArtifact
Keith Potter
Morgan Gilbert
Alengork Gamer`;

View file

@ -60,7 +60,7 @@ window.Cloud = (function () {
async save(fileName, contents) {
const resp = await this.call("filesUpload", {path: "/" + fileName, contents});
DEBUG && console.info("Dropbox response:", resp);
DEBUG.cloud && console.info("Dropbox response:", resp);
return true;
},
@ -104,7 +104,7 @@ window.Cloud = (function () {
// Callback function for auth window
async setDropBoxToken(token) {
DEBUG && console.info("Access token:", token);
DEBUG.cloud && console.info("Access token:", token);
setToken(this.name, token);
await this.connect(token);
this.authWindow.close();
@ -131,7 +131,7 @@ window.Cloud = (function () {
allow_download: true
};
const resp = await this.call("sharingCreateSharedLinkWithSettings", {path, settings});
DEBUG && console.info("Dropbox link object:", resp.result);
DEBUG.cloud && console.info("Dropbox link object:", resp.result);
return resp.result.url;
}
};

View file

@ -13,7 +13,7 @@ async function quickLoad() {
async function loadFromDropbox() {
const mapPath = byId("loadFromDropboxSelect")?.value;
DEBUG && console.info("Loading map from Dropbox:", mapPath);
console.info("Loading map from Dropbox:", mapPath);
const blob = await Cloud.providers.dropbox.load(mapPath);
uploadMap(blob);
}
@ -96,6 +96,7 @@ function showUploadErrorMessage(error, URL, random) {
title: "Loading error",
width: "32em",
buttons: {
"Clear cache": () => cleanupData(),
OK: function () {
$(this).dialog("close");
}
@ -152,11 +153,21 @@ async function uncompress(compressedData) {
async function parseLoadedResult(result) {
try {
const resultAsString = new TextDecoder().decode(result);
// data can be in FMG internal format or base64 encoded
const isDelimited = resultAsString.substring(0, 10).includes("|");
const decoded = isDelimited ? resultAsString : decodeURIComponent(atob(resultAsString));
let content = isDelimited ? resultAsString : decodeURIComponent(atob(resultAsString));
const mapData = decoded.split("\r\n"); // split by CRLF
// fix if svg part has CRLF line endings instead of LF
const svgMatch = content.match(/<svg[^>]*id="map"[\s\S]*?<\/svg>/);
const svgContent = svgMatch[0];
const hasCrlfEndings = svgContent.includes("\r\n");
if (hasCrlfEndings) {
const correctedSvgContent = svgContent.replace(/\r\n/g, "\n");
content = content.replace(svgContent, correctedSvgContent);
}
const mapData = content.split("\r\n"); // split by CRLF
const mapVersion = parseMapVersion(mapData[0].split("|")[0] || mapData[0] || "");
return {mapData, mapVersion};
@ -195,6 +206,7 @@ function showUploadMessage(type, mapData, mapVersion) {
$("#alert").dialog({
title,
buttons: {
"Clear cache": () => cleanupData(),
OK: function () {
$(this).dialog("close");
}
@ -459,7 +471,7 @@ async function parseLoadedData(data, mapVersion) {
{
// dynamically import and run auto-update script
const {resolveVersionConflicts} = await import("../dynamic/auto-update.js?v=1.105.5");
const {resolveVersionConflicts} = await import("../dynamic/auto-update.js?v=1.105.24");
resolveVersionConflicts(mapVersion);
}
@ -735,6 +747,7 @@ async function parseLoadedData(data, mapVersion) {
title: "Loading error",
maxWidth: "50em",
buttons: {
"Clear cache": () => cleanupData(),
"Select file": function () {
$(this).dialog("close");
mapToLoad.click();

View file

@ -48,18 +48,28 @@ window.Names = (function () {
return chain;
};
// update chain for specific base
const updateChain = i => (chains[i] = nameBases[i] || nameBases[i].b ? calculateChain(nameBases[i].b) : null);
const updateChain = i => {
chains[i] = nameBases[i]?.b ? calculateChain(nameBases[i].b) : null;
};
// update chains for all used bases
const clearChains = () => (chains = []);
const clearChains = () => {
chains = [];
};
// generate name using Markov's chain
const getBase = function (base, min, max, dupl) {
if (base === undefined) {
ERROR && console.error("Please define a base");
return;
if (base === undefined) return ERROR && console.error("Please define a base");
if (nameBases[base] === undefined) {
if (nameBases[0]) {
WARN && console.warn("Namebase " + base + " is not found. First available namebase will be used");
base = 0;
} else {
ERROR && console.error("Namebase " + base + " is not found");
return "ERROR";
}
}
if (!chains[base]) updateChain(base);
const data = chains[base];
@ -141,16 +151,8 @@ window.Names = (function () {
// generate short name for base
const getBaseShort = function (base) {
if (nameBases[base] === undefined) {
tip(
`Namebase ${base} does not exist. Please upload custom namebases of change the base in Cultures Editor`,
false,
"error"
);
base = 1;
}
const min = nameBases[base].min - 1;
const max = Math.max(nameBases[base].max - 2, min);
const min = nameBases[base] ? nameBases[base].min - 1 : null;
const max = min ? Math.max(nameBases[base].max - 2, min) : null;
return getBase(base, min, max, "", 0);
};

View file

@ -77,18 +77,18 @@ window.Provinces = (function () {
});
// expand generated provinces
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
const queue = new FlatQueue();
const cost = [];
provinces.forEach(p => {
if (!p.i || p.removed || isProvinceLocked(p)) return;
provinceIds[p.center] = p.i;
queue.queue({e: p.center, p: 0, province: p.i, state: p.state});
queue.push({e: p.center, province: p.i, state: p.state, p: 0}, 0);
cost[p.center] = 1;
});
while (queue.length) {
const {e, p, province, state} = queue.dequeue();
const {e, p, province, state} = queue.pop();
cells.c[e].forEach(e => {
if (isProvinceCellLocked(e)) return; // do not overwrite cell of locked provinces
@ -103,7 +103,7 @@ window.Provinces = (function () {
if (!cost[e] || totalCost < cost[e]) {
if (land) provinceIds[e] = province; // assign province to a cell
cost[e] = totalCost;
queue.queue({e, p: totalCost, province, state});
queue.push({e, province, state, p: totalCost}, totalCost);
}
});
}
@ -158,9 +158,9 @@ window.Provinces = (function () {
// expand province
const cost = [];
cost[center] = 1;
queue.queue({e: center, p: 0});
queue.push({e: center, p: 0}, 0);
while (queue.length) {
const {e, p} = queue.dequeue();
const {e, p} = queue.pop();
cells.c[e].forEach(nextCellId => {
if (provinceIds[nextCellId]) return;
@ -173,7 +173,7 @@ window.Provinces = (function () {
if (!cost[nextCellId] || totalCost < cost[nextCellId]) {
if (land && cells.state[nextCellId] === s.i) provinceIds[nextCellId] = provinceId; // assign province to a cell
cost[nextCellId] = totalCost;
queue.queue({e: nextCellId, p: totalCost});
queue.push({e: nextCellId, p: totalCost}, totalCost);
}
});
}
@ -216,15 +216,15 @@ window.Provinces = (function () {
// check if there is a land way within the same state between two cells
function isPassable(from, to) {
if (cells.f[from] !== cells.f[to]) return false; // on different islands
const queue = [from],
const passableQueue = [from],
used = new Uint8Array(cells.i.length),
state = cells.state[from];
while (queue.length) {
const current = queue.pop();
while (passableQueue.length) {
const current = passableQueue.pop();
if (current === to) return true; // way is found
cells.c[current].forEach(c => {
if (used[c] || cells.h[c] < 20 || cells.state[c] !== state) return;
queue.push(c);
passableQueue.push(c);
used[c] = 1;
});
}

View file

@ -695,7 +695,7 @@ window.Religions = (function () {
const {cells, routes} = pack;
const religionIds = spreadFolkReligions(religions);
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
const queue = new FlatQueue();
const cost = [];
// limit cost for organized religions growth
@ -705,14 +705,14 @@ window.Religions = (function () {
.filter(r => r.i && !r.lock && r.type !== "Folk" && !r.removed)
.forEach(r => {
religionIds[r.center] = r.i;
queue.queue({e: r.center, p: 0, r: r.i, s: cells.state[r.center]});
queue.push({e: r.center, p: 0, r: r.i, s: cells.state[r.center]}, 0);
cost[r.center] = 1;
});
const religionsMap = new Map(religions.map(r => [r.i, r]));
while (queue.length) {
const {e: cellId, p, r, s: state} = queue.dequeue();
const {e: cellId, p, r, s: state} = queue.pop();
const {culture, expansion, expansionism} = religionsMap.get(r);
cells.c[cellId].forEach(nextCell => {
@ -732,7 +732,7 @@ window.Religions = (function () {
if (cells.culture[nextCell]) religionIds[nextCell] = r; // assign religion to cell
cost[nextCell] = totalCost;
queue.queue({e: nextCell, p: totalCost, r, s: state});
queue.push({e: nextCell, p: totalCost, r, s: state}, totalCost);
}
});
}

View file

@ -55,7 +55,7 @@ function getFeaturePath(feature) {
const clippedPoints = clipPoly(simplifiedPoints, 1);
const lineGen = d3.line().curve(d3.curveBasisClosed);
const path = round(lineGen(clippedPoints));
const path = round(lineGen(clippedPoints)) + "Z";
return path;
}

View file

@ -14,11 +14,11 @@ function drawStateLabels(list) {
// increase step to 15 or 30 to make it faster and more horyzontal
// decrease step to 5 to improve accuracy
const ANGLE_STEP = 9;
const raycast = precalculateAngles(ANGLE_STEP);
const angles = precalculateAngles(ANGLE_STEP);
const INITIAL_DISTANCE = 10;
const DISTANCE_STEP = 15;
const MAX_ITERATIONS = 100;
const LENGTH_START = 5;
const LENGTH_STEP = 5;
const LENGTH_MAX = 300;
const labelPaths = getLabelPaths();
const letterLength = checkExampleLetterLength();
@ -35,87 +35,27 @@ function drawStateLabels(list) {
if (list && !list.includes(state.i)) continue;
const offset = getOffsetWidth(state.cells);
const maxLakeSize = state.cells / 50;
const maxLakeSize = state.cells / 20;
const [x0, y0] = state.pole;
const offsetPoints = new Map(
(offset ? raycast : []).map(({angle, x: x1, y: y1}) => {
const [x, y] = [x0 + offset * x1, y0 + offset * y1];
return [angle, {x, y}];
})
);
const rays = angles.map(({angle, dx, dy}) => {
const {length, x, y} = raycast({stateId: state.i, x0, y0, dx, dy, maxLakeSize, offset});
return {angle, length, x, y};
});
const [ray1, ray2] = findBestRayPair(rays);
const distances = raycast.map(({angle, x: dx, y: dy, modifier}) => {
let distanceMin;
const distance1 = getMaxDistance(state.i, {x: x0, y: y0}, dx, dy, maxLakeSize);
const pathPoints = [[ray1.x, ray1.y], state.pole, [ray2.x, ray2.y]];
if (ray1.x > ray2.x) pathPoints.reverse();
if (offset) {
const point2 = offsetPoints.get(angle - 90 < 0 ? angle + 270 : angle - 90);
const distance2 = getMaxDistance(state.i, point2, dx, dy, maxLakeSize);
const point3 = offsetPoints.get(angle + 90 >= 360 ? angle - 270 : angle + 90);
const distance3 = getMaxDistance(state.i, point3, dx, dy, maxLakeSize);
distanceMin = Math.min(distance1, distance2, distance3);
} else {
distanceMin = distance1;
if (DEBUG.stateLabels) {
drawPoint(state.pole, {color: "black", radius: 1});
drawPath(pathPoints, {color: "black", width: 0.2});
}
const [x, y] = [x0 + distanceMin * dx, y0 + distanceMin * dy];
return {angle, distance: distanceMin * modifier, x, y};
});
const {
angle,
x: x1,
y: y1
} = distances.reduce(
(acc, {angle, distance, x, y}) => {
if (distance > acc.distance) return {angle, distance, x, y};
return acc;
},
{angle: 0, distance: 0, x: 0, y: 0}
);
const oppositeAngle = angle >= 180 ? angle - 180 : angle + 180;
const {x: x2, y: y2} = distances.reduce(
(acc, {angle, distance, x, y}) => {
const angleDif = getAnglesDif(angle, oppositeAngle);
const score = distance * getAngleModifier(angleDif);
if (score > acc.score) return {angle, score, x, y};
return acc;
},
{angle: 0, score: 0, x: 0, y: 0}
);
const pathPoints = [[x1, y1], state.pole, [x2, y2]];
if (x1 > x2) pathPoints.reverse();
labelPaths.push([state.i, pathPoints]);
}
return labelPaths;
function getMaxDistance(stateId, point, dx, dy, maxLakeSize) {
let distance = INITIAL_DISTANCE;
for (let i = 0; i < MAX_ITERATIONS; i++) {
const [x, y] = [point.x + distance * dx, point.y + distance * dy];
const cellId = findCell(x, y, DISTANCE_STEP);
// drawPoint([x, y], {color: cellId && isPassable(cellId) ? "blue" : "red", radius: 0.8});
if (!cellId || !isPassable(cellId)) break;
distance += DISTANCE_STEP;
}
return distance;
function isPassable(cellId) {
const feature = features[cells.f[cellId]];
if (feature.type === "lake") return feature.cells <= maxLakeSize;
return stateIds[cellId] === stateId;
}
}
}
function checkExampleLetterLength() {
@ -129,7 +69,7 @@ function drawStateLabels(list) {
function drawLabelPath(letterLength) {
const mode = options.stateLabelsMode || "auto";
const lineGen = d3.line().curve(d3.curveBundle.beta(1));
const lineGen = d3.line().curve(d3.curveNatural);
const textGroup = d3.select("g#labels > g#states");
const pathGroup = d3.select("defs > g#deftemp > g#textPaths");
@ -192,35 +132,15 @@ function drawStateLabels(list) {
const text = pathLength > state.fullName.length * 1.8 ? state.fullName : state.name;
textElement.innerHTML = `<tspan x="0">${text}</tspan>`;
const correctedRatio = minmax(rn((pathLength / text.length) * 50), 40, 130);
const correctedRatio = minmax(rn((pathLength / text.length) * 50), 50, 130);
textElement.setAttribute("font-size", correctedRatio + "%");
}
}
// point offset to reduce label overlap with state borders
function getOffsetWidth(cellsNumber) {
if (cellsNumber < 80) return 0;
if (cellsNumber < 140) return 5;
if (cellsNumber < 200) return 15;
if (cellsNumber < 300) return 20;
if (cellsNumber < 500) return 25;
return 30;
}
// difference between two angles in range [0, 180]
function getAnglesDif(angle1, angle2) {
return 180 - Math.abs(Math.abs(angle1 - angle2) - 180);
}
// score multiplier based on angle difference betwee left and right sides
function getAngleModifier(angleDif) {
if (angleDif === 0) return 1;
if (angleDif <= 15) return 0.95;
if (angleDif <= 30) return 0.9;
if (angleDif <= 45) return 0.6;
if (angleDif <= 60) return 0.3;
if (angleDif <= 90) return 0.1;
return 0; // >90
if (cellsNumber < 40) return 0;
if (cellsNumber < 200) return 5;
return 10;
}
function precalculateAngles(step) {
@ -228,38 +148,136 @@ function drawStateLabels(list) {
const RAD = Math.PI / 180;
for (let angle = 0; angle < 360; angle += step) {
const x = Math.cos(angle * RAD);
const y = Math.sin(angle * RAD);
const angleDif = 90 - Math.abs((angle % 180) - 90);
const modifier = 1 - angleDif / 120; // [0.25, 1]
angles.push({angle, modifier, x, y});
const dx = Math.cos(angle * RAD);
const dy = Math.sin(angle * RAD);
angles.push({angle, dx, dy});
}
return angles;
}
function raycast({stateId, x0, y0, dx, dy, maxLakeSize, offset}) {
let ray = {length: 0, x: x0, y: y0};
for (let length = LENGTH_START; length < LENGTH_MAX; length += LENGTH_STEP) {
const [x, y] = [x0 + length * dx, y0 + length * dy];
// offset points are perpendicular to the ray
const offset1 = [x + -dy * offset, y + dx * offset];
const offset2 = [x + dy * offset, y + -dx * offset];
if (DEBUG.stateLabels) {
drawPoint([x, y], {color: isInsideState(x, y) ? "blue" : "red", radius: 0.8});
drawPoint(offset1, {color: isInsideState(...offset1) ? "blue" : "red", radius: 0.4});
drawPoint(offset2, {color: isInsideState(...offset2) ? "blue" : "red", radius: 0.4});
}
const inState = isInsideState(x, y) && isInsideState(...offset1) && isInsideState(...offset2);
if (!inState) break;
ray = {length, x, y};
}
return ray;
function isInsideState(x, y) {
if (x < 0 || x > graphWidth || y < 0 || y > graphHeight) return false;
const cellId = findCell(x, y);
const feature = features[cells.f[cellId]];
if (feature.type === "lake") return isInnerLake(feature) || isSmallLake(feature);
return stateIds[cellId] === stateId;
}
function isInnerLake(feature) {
return feature.shoreline.every(cellId => stateIds[cellId] === stateId);
}
function isSmallLake(feature) {
return feature.cells <= maxLakeSize;
}
}
function findBestRayPair(rays) {
let bestPair = null;
let bestScore = -Infinity;
for (let i = 0; i < rays.length; i++) {
const score1 = rays[i].length * scoreRayAngle(rays[i].angle);
for (let j = i + 1; j < rays.length; j++) {
const score2 = rays[j].length * scoreRayAngle(rays[j].angle);
const pairScore = (score1 + score2) * scoreCurvature(rays[i].angle, rays[j].angle);
if (pairScore > bestScore) {
bestScore = pairScore;
bestPair = [rays[i], rays[j]];
}
}
}
return bestPair;
}
function scoreRayAngle(angle) {
const normalizedAngle = Math.abs(angle % 180); // [0, 180]
const horizontality = Math.abs(normalizedAngle - 90) / 90; // [0, 1]
if (horizontality === 1) return 1; // Best: horizontal
if (horizontality >= 0.75) return 0.9; // Very good: slightly slanted
if (horizontality >= 0.5) return 0.6; // Good: moderate slant
if (horizontality >= 0.25) return 0.5; // Acceptable: more slanted
if (horizontality >= 0.15) return 0.2; // Poor: almost vertical
return 0.1; // Very poor: almost vertical
}
function scoreCurvature(angle1, angle2) {
const delta = getAngleDelta(angle1, angle2);
const similarity = evaluateArc(angle1, angle2);
if (delta === 180) return 1; // straight line: best
if (delta < 90) return 0; // acute: not allowed
if (delta < 120) return 0.6 * similarity;
if (delta < 140) return 0.7 * similarity;
if (delta < 160) return 0.8 * similarity;
return similarity;
}
function getAngleDelta(angle1, angle2) {
let delta = Math.abs(angle1 - angle2) % 360;
if (delta > 180) delta = 360 - delta; // [0, 180]
return delta;
}
// compute arc similarity towards x-axis
function evaluateArc(angle1, angle2) {
const proximity1 = Math.abs((angle1 % 180) - 90);
const proximity2 = Math.abs((angle2 % 180) - 90);
return 1 - Math.abs(proximity1 - proximity2) / 90;
}
function getLinesAndRatio(mode, name, fullName, pathLength) {
// short name
if (mode === "short" || (mode === "auto" && pathLength <= name.length)) {
const lines = splitInTwo(name);
const longestLineLength = d3.max(lines.map(({length}) => length));
const ratio = pathLength / longestLineLength;
return [lines, minmax(rn(ratio * 60), 50, 150)];
if (mode === "short") return getShortOneLine();
if (pathLength > fullName.length * 2) return getFullOneLine();
return getFullTwoLines();
function getShortOneLine() {
const ratio = pathLength / name.length;
return [[name], minmax(rn(ratio * 60), 50, 150)];
}
// full name: one line
if (pathLength > fullName.length * 2) {
const lines = [fullName];
const ratio = pathLength / lines[0].length;
return [lines, minmax(rn(ratio * 70), 70, 170)];
function getFullOneLine() {
const ratio = pathLength / fullName.length;
return [[fullName], minmax(rn(ratio * 70), 70, 170)];
}
// full name: two lines
function getFullTwoLines() {
const lines = splitInTwo(fullName);
const longestLineLength = d3.max(lines.map(({length}) => length));
const ratio = pathLength / longestLineLength;
return [lines, minmax(rn(ratio * 60), 70, 150)];
}
}
// check whether multi-lined label is mostly inside the state. If no, replace it with short name label
function checkIfInsideState(textElement, angleRad, halfwidth, halfheight, stateIds, stateId) {

365
modules/resample.js Normal file
View file

@ -0,0 +1,365 @@
"use strict";
window.Resample = (function () {
/*
generate new map based on an existing one (resampling parentMap)
parentMap: {grid, pack, notes} from original map
projection: f(Number, Number) -> [Number, Number]
inverse: f(Number, Number) -> [Number, Number]
scale: Number
*/
function process({projection, inverse, scale}) {
const parentMap = {grid: deepCopy(grid), pack: deepCopy(pack), notes: deepCopy(notes)};
const riversData = saveRiversData(pack.rivers);
grid = generateGrid();
pack = {};
notes = parentMap.notes;
resamplePrimaryGridData(parentMap, inverse, scale);
Features.markupGrid();
addLakesInDeepDepressions();
openNearSeaLakes();
OceanLayers();
calculateMapCoordinates();
calculateTemperatures();
reGraph();
Features.markupPack();
createDefaultRuler();
restoreCellData(parentMap, inverse, scale);
restoreRivers(riversData, projection, scale);
restoreCultures(parentMap, projection);
restoreBurgs(parentMap, projection, scale);
restoreStates(parentMap, projection);
restoreRoutes(parentMap, projection);
restoreReligions(parentMap, projection);
restoreProvinces(parentMap);
restoreFeatureDetails(parentMap, inverse);
restoreMarkers(parentMap, projection);
restoreZones(parentMap, projection, scale);
showStatistics();
}
function resamplePrimaryGridData(parentMap, inverse, scale) {
grid.cells.h = new Uint8Array(grid.points.length);
grid.cells.temp = new Int8Array(grid.points.length);
grid.cells.prec = new Uint8Array(grid.points.length);
grid.points.forEach(([x, y], newGridCell) => {
const [parentX, parentY] = inverse(x, y);
const parentPackCell = parentMap.pack.cells.q.find(parentX, parentY, Infinity)[2];
const parentGridCell = parentMap.pack.cells.g[parentPackCell];
grid.cells.h[newGridCell] = parentMap.grid.cells.h[parentGridCell];
grid.cells.temp[newGridCell] = parentMap.grid.cells.temp[parentGridCell];
grid.cells.prec[newGridCell] = parentMap.grid.cells.prec[parentGridCell];
});
if (scale >= 2) smoothHeightmap();
}
function smoothHeightmap() {
grid.cells.h.forEach((height, newGridCell) => {
const heights = [height, ...grid.cells.c[newGridCell].map(c => grid.cells.h[c])];
const meanHeight = d3.mean(heights);
grid.cells.h[newGridCell] = isWater(grid, newGridCell) ? Math.min(meanHeight, 19) : Math.max(meanHeight, 20);
});
}
function restoreCellData(parentMap, inverse, scale) {
pack.cells.biome = new Uint8Array(pack.cells.i.length);
pack.cells.fl = new Uint16Array(pack.cells.i.length);
pack.cells.s = new Int16Array(pack.cells.i.length);
pack.cells.pop = new Float32Array(pack.cells.i.length);
pack.cells.culture = new Uint16Array(pack.cells.i.length);
pack.cells.state = new Uint16Array(pack.cells.i.length);
pack.cells.burg = new Uint16Array(pack.cells.i.length);
pack.cells.religion = new Uint16Array(pack.cells.i.length);
pack.cells.province = new Uint16Array(pack.cells.i.length);
const parentPackCellGroups = groupCellsByType(parentMap.pack);
const parentPackLandCellsQuadtree = d3.quadtree(parentPackCellGroups.land);
for (const newPackCell of pack.cells.i) {
const [x, y] = inverse(...pack.cells.p[newPackCell]);
if (isWater(pack, newPackCell)) continue;
const parentPackCell = parentPackLandCellsQuadtree.find(x, y, Infinity)[2];
const parentCellArea = parentMap.pack.cells.area[parentPackCell];
const areaRatio = pack.cells.area[newPackCell] / parentCellArea;
const scaleRatio = areaRatio / scale;
pack.cells.biome[newPackCell] = parentMap.pack.cells.biome[parentPackCell];
pack.cells.fl[newPackCell] = parentMap.pack.cells.fl[parentPackCell];
pack.cells.s[newPackCell] = parentMap.pack.cells.s[parentPackCell] * scaleRatio;
pack.cells.pop[newPackCell] = parentMap.pack.cells.pop[parentPackCell] * scaleRatio;
pack.cells.culture[newPackCell] = parentMap.pack.cells.culture[parentPackCell];
pack.cells.state[newPackCell] = parentMap.pack.cells.state[parentPackCell];
pack.cells.religion[newPackCell] = parentMap.pack.cells.religion[parentPackCell];
pack.cells.province[newPackCell] = parentMap.pack.cells.province[parentPackCell];
}
}
function saveRiversData(parentRivers) {
return parentRivers.map(river => {
const meanderedPoints = Rivers.addMeandering(river.cells, river.points);
return {...river, meanderedPoints};
});
}
function restoreRivers(riversData, projection, scale) {
pack.cells.r = new Uint16Array(pack.cells.i.length);
pack.cells.conf = new Uint8Array(pack.cells.i.length);
pack.rivers = riversData
.map(river => {
let wasInMap = true;
const points = [];
river.meanderedPoints.forEach(([parentX, parentY]) => {
const [x, y] = projection(parentX, parentY);
const inMap = isInMap(x, y);
if (inMap || wasInMap) points.push([rn(x, 2), rn(y, 2)]);
wasInMap = inMap;
});
if (points.length < 2) return null;
const cells = points.map(point => findCell(...point));
cells.forEach(cellId => {
if (pack.cells.r[cellId]) pack.cells.conf[cellId] = 1;
pack.cells.r[cellId] = river.i;
});
const widthFactor = river.widthFactor * scale;
return {...river, cells, points, source: cells.at(0), mouth: cells.at(-2), widthFactor};
})
.filter(Boolean);
pack.rivers.forEach(river => {
river.basin = Rivers.getBasin(river.i);
river.length = Rivers.getApproximateLength(river.points);
});
}
function restoreCultures(parentMap, projection) {
const validCultures = new Set(pack.cells.culture);
const culturePoles = getPolesOfInaccessibility(pack, cellId => pack.cells.culture[cellId]);
pack.cultures = parentMap.pack.cultures.map(culture => {
if (!culture.i || culture.removed) return culture;
if (!validCultures.has(culture.i)) return {...culture, removed: true, lock: false};
const [xp, yp] = projection(...parentMap.pack.cells.p[culture.center]);
const [x, y] = [rn(xp, 2), rn(yp, 2)];
const centerCoords = isInMap(x, y) ? [x, y] : culturePoles[culture.i];
const center = findCell(...centerCoords);
return {...culture, center};
});
}
function restoreBurgs(parentMap, projection, scale) {
const packLandCellsQuadtree = d3.quadtree(groupCellsByType(pack).land);
const findLandCell = (x, y) => packLandCellsQuadtree.find(x, y, Infinity)?.[2];
pack.burgs = parentMap.pack.burgs.map(burg => {
if (!burg.i || burg.removed) return burg;
burg.population *= scale; // adjust for populationRate change
const [xp, yp] = projection(burg.x, burg.y);
if (!isInMap(xp, yp)) return {...burg, removed: true, lock: false};
const closestCell = findCell(xp, yp);
const cell = isWater(pack, closestCell) ? findLandCell(xp, yp) : closestCell;
if (pack.cells.burg[cell]) {
WARN && console.warn(`Cell ${cell} already has a burg. Removing burg ${burg.name} (${burg.i})`);
return {...burg, removed: true, lock: false};
}
pack.cells.burg[cell] = burg.i;
const [x, y] = getBurgCoordinates(burg, closestCell, cell, xp, yp);
return {...burg, cell, x, y};
});
function getBurgCoordinates(burg, closestCell, cell, xp, yp) {
const haven = pack.cells.haven[cell];
if (burg.port && haven) return BurgsAndStates.getCloseToEdgePoint(cell, haven);
if (closestCell !== cell) return pack.cells.p[cell];
return [rn(xp, 2), rn(yp, 2)];
}
}
function restoreStates(parentMap, projection) {
const validStates = new Set(pack.cells.state);
pack.states = parentMap.pack.states.map(state => {
if (!state.i || state.removed) return state;
if (validStates.has(state.i)) return state;
return {...state, removed: true, lock: false};
});
BurgsAndStates.getPoles();
const regimentCellsMap = {};
const VERTICAL_GAP = 8;
pack.states = pack.states.map(state => {
if (!state.i || state.removed) return state;
const capital = pack.burgs[state.capital];
state.center = !capital || capital.removed ? findCell(...state.pole) : capital.cell;
const military = state.military.map(regiment => {
const cellCoords = projection(...parentMap.pack.cells.p[regiment.cell]);
const cell = isInMap(...cellCoords) ? findCell(...cellCoords) : state.center;
const [xPos, yPos] = projection(regiment.x, regiment.y);
const [xBase, yBase] = projection(regiment.bx, regiment.by);
const [xCell, yCell] = pack.cells.p[cell];
const regsOnCell = regimentCellsMap[cell] || 0;
regimentCellsMap[cell] = regsOnCell + 1;
const name =
isInMap(xPos, yPos) || regiment.name.includes("[relocated]") ? regiment.name : `[relocated] ${regiment.name}`;
const pos = isInMap(xPos, yPos)
? {x: rn(xPos, 2), y: rn(yPos, 2)}
: {x: xCell, y: yCell + regsOnCell * VERTICAL_GAP};
const base = isInMap(xBase, yBase) ? {bx: rn(xBase, 2), by: rn(yBase, 2)} : {bx: xCell, by: yCell};
return {...regiment, cell, name, ...base, ...pos};
});
const neighbors = state.neighbors.filter(stateId => validStates.has(stateId));
return {...state, neighbors, military};
});
}
function restoreRoutes(parentMap, projection) {
pack.routes = parentMap.pack.routes
.map(route => {
let wasInMap = true;
const points = [];
route.points.forEach(([parentX, parentY]) => {
const [x, y] = projection(parentX, parentY);
const inMap = isInMap(x, y);
if (inMap || wasInMap) points.push([rn(x, 2), rn(y, 2)]);
wasInMap = inMap;
});
if (points.length < 2) return null;
const firstCell = points[0][2];
const feature = pack.cells.f[firstCell];
return {...route, feature, points};
})
.filter(Boolean);
pack.cells.routes = Routes.buildLinks(pack.routes);
}
function restoreReligions(parentMap, projection) {
const validReligions = new Set(pack.cells.religion);
const religionPoles = getPolesOfInaccessibility(pack, cellId => pack.cells.religion[cellId]);
pack.religions = parentMap.pack.religions.map(religion => {
if (!religion.i || religion.removed) return religion;
if (!validReligions.has(religion.i)) return {...religion, removed: true, lock: false};
const [xp, yp] = projection(...parentMap.pack.cells.p[religion.center]);
const [x, y] = [rn(xp, 2), rn(yp, 2)];
const centerCoords = isInMap(x, y) ? [x, y] : religionPoles[religion.i];
const center = findCell(...centerCoords);
return {...religion, center};
});
}
function restoreProvinces(parentMap) {
const validProvinces = new Set(pack.cells.province);
pack.provinces = parentMap.pack.provinces.map(province => {
if (!province.i || province.removed) return province;
if (!validProvinces.has(province.i)) return {...province, removed: true, lock: false};
return province;
});
Provinces.getPoles();
pack.provinces.forEach(province => {
if (!province.i || province.removed) return;
const capital = pack.burgs[province.burg];
province.center = !capital?.removed ? capital.cell : findCell(...province.pole);
});
}
function restoreMarkers(parentMap, projection) {
pack.markers = parentMap.pack.markers;
pack.markers.forEach(marker => {
const [x, y] = projection(marker.x, marker.y);
if (!isInMap(x, y)) Markers.deleteMarker(marker.i);
const cell = findCell(x, y);
marker.x = rn(x, 2);
marker.y = rn(y, 2);
marker.cell = cell;
});
}
function restoreZones(parentMap, projection, scale) {
const getSearchRadius = cellId => Math.sqrt(parentMap.pack.cells.area[cellId] / Math.PI) * scale;
pack.zones = parentMap.pack.zones.map(zone => {
const cells = zone.cells
.map(cellId => {
const [x, y] = projection(...parentMap.pack.cells.p[cellId]);
if (!isInMap(x, y)) return null;
return findAll(x, y, getSearchRadius(cellId));
})
.filter(Boolean)
.flat();
return {...zone, cells: unique(cells)};
});
}
function restoreFeatureDetails(parentMap, inverse) {
pack.features.forEach(feature => {
if (!feature) return;
const [x, y] = pack.cells.p[feature.firstCell];
const [parentX, parentY] = inverse(x, y);
const parentCell = parentMap.pack.cells.q.find(parentX, parentY, Infinity)[2];
if (parentCell === undefined) return;
const parentFeature = parentMap.pack.features[parentMap.pack.cells.f[parentCell]];
if (parentFeature.group) feature.group = parentFeature.group;
if (parentFeature.name) feature.name = parentFeature.name;
if (parentFeature.height) feature.height = parentFeature.height;
});
}
function groupCellsByType(graph) {
return graph.cells.p.reduce(
(acc, [x, y], cellId) => {
const group = isWater(graph, cellId) ? "water" : "land";
acc[group].push([x, y, cellId]);
return acc;
},
{land: [], water: []}
);
}
function isWater(graph, cellId) {
return graph.cells.h[cellId] < 20;
}
function isInMap(x, y) {
return x >= 0 && x <= graphWidth && y >= 0 && y <= graphHeight;
}
return {process};
})();

View file

@ -190,7 +190,15 @@ window.Rivers = (function () {
const meanderedPoints = addMeandering(riverCells);
const discharge = cells.fl[mouth]; // m3 in second
const length = getApproximateLength(meanderedPoints);
const width = getWidth(getOffset(discharge, meanderedPoints.length, widthFactor, 0));
const sourceWidth = getSourceWidth(cells.fl[source]);
const width = getWidth(
getOffset({
flux: discharge,
pointIndex: meanderedPoints.length,
widthFactor,
sourceWidth
})
);
pack.rivers.push({
i: riverId,
@ -200,7 +208,7 @@ window.Rivers = (function () {
length,
width,
widthFactor,
sourceWidth: 0,
sourceWidth,
parent,
cells: riverCells
});
@ -306,59 +314,49 @@ window.Rivers = (function () {
// add points at 1/3 and 2/3 of a line between adjacents river cells
const addMeandering = function (riverCells, riverPoints = null, meandering = 0.5) {
const {fl, conf, h} = pack.cells;
const {fl, h} = pack.cells;
const meandered = [];
const lastStep = riverCells.length - 1;
const points = getRiverPoints(riverCells, riverPoints);
let step = h[riverCells[0]] < 20 ? 1 : 10;
let fluxPrev = 0;
const getFlux = (step, flux) => (step === lastStep ? fluxPrev : flux);
for (let i = 0; i <= lastStep; i++, step++) {
const cell = riverCells[i];
const isLastCell = i === lastStep;
const [x1, y1] = points[i];
const flux1 = getFlux(i, fl[cell]);
fluxPrev = flux1;
meandered.push([x1, y1, flux1]);
meandered.push([x1, y1, fl[cell]]);
if (isLastCell) break;
const nextCell = riverCells[i + 1];
const [x2, y2] = points[i + 1];
if (nextCell === -1) {
meandered.push([x2, y2, fluxPrev]);
meandered.push([x2, y2, fl[cell]]);
break;
}
const dist2 = (x2 - x1) ** 2 + (y2 - y1) ** 2; // square distance between cells
if (dist2 <= 25 && riverCells.length >= 6) continue;
const flux2 = getFlux(i + 1, fl[nextCell]);
const keepInitialFlux = conf[nextCell] || flux1 === flux2;
const meander = meandering + 1 / step + Math.max(meandering - step / 100, 0);
const angle = Math.atan2(y2 - y1, x2 - x1);
const sinMeander = Math.sin(angle) * meander;
const cosMeander = Math.cos(angle) * meander;
if (step < 10 && (dist2 > 64 || (dist2 > 36 && riverCells.length < 5))) {
if (step < 20 && (dist2 > 64 || (dist2 > 36 && riverCells.length < 5))) {
// if dist2 is big or river is small add extra points at 1/3 and 2/3 of segment
const p1x = (x1 * 2 + x2) / 3 + -sinMeander;
const p1y = (y1 * 2 + y2) / 3 + cosMeander;
const p2x = (x1 + x2 * 2) / 3 + sinMeander / 2;
const p2y = (y1 + y2 * 2) / 3 - cosMeander / 2;
const [p1fl, p2fl] = keepInitialFlux ? [flux1, flux1] : [(flux1 * 2 + flux2) / 3, (flux1 + flux2 * 2) / 3];
meandered.push([p1x, p1y, p1fl], [p2x, p2y, p2fl]);
meandered.push([p1x, p1y, 0], [p2x, p2y, 0]);
} else if (dist2 > 25 || riverCells.length < 6) {
// if dist is medium or river is small add 1 extra middlepoint
const p1x = (x1 + x2) / 2 + -sinMeander;
const p1y = (y1 + y2) / 2 + cosMeander;
const p1fl = keepInitialFlux ? flux1 : (flux1 + flux2) / 2;
meandered.push([p1x, p1y, p1fl]);
meandered.push([p1x, p1y, 0]);
}
}
@ -385,29 +383,36 @@ window.Rivers = (function () {
};
const FLUX_FACTOR = 500;
const MAX_FLUX_WIDTH = 2;
const MAX_FLUX_WIDTH = 1;
const LENGTH_FACTOR = 200;
const STEP_WIDTH = 1 / LENGTH_FACTOR;
const LENGTH_STEP_WIDTH = 1 / LENGTH_FACTOR;
const LENGTH_PROGRESSION = [1, 1, 2, 3, 5, 8, 13, 21, 34].map(n => n / LENGTH_FACTOR);
const MAX_PROGRESSION = last(LENGTH_PROGRESSION);
const getOffset = (flux, pointNumber, widthFactor, startingWidth = 0) => {
const fluxWidth = Math.min(flux ** 0.9 / FLUX_FACTOR, MAX_FLUX_WIDTH);
const lengthWidth = pointNumber * STEP_WIDTH + (LENGTH_PROGRESSION[pointNumber] || MAX_PROGRESSION);
const getOffset = ({flux, pointIndex, widthFactor, startingWidth}) => {
if (pointIndex === 0) return startingWidth;
const fluxWidth = Math.min(flux ** 0.7 / FLUX_FACTOR, MAX_FLUX_WIDTH);
const lengthWidth = pointIndex * LENGTH_STEP_WIDTH + (LENGTH_PROGRESSION[pointIndex] || MAX_PROGRESSION);
return widthFactor * (lengthWidth + fluxWidth) + startingWidth;
};
const getSourceWidth = flux => rn(Math.min(flux ** 0.9 / FLUX_FACTOR, MAX_FLUX_WIDTH), 2);
// build polygon from a list of points and calculated offset (width)
const getRiverPath = function (points, widthFactor, startingWidth = 0) {
const getRiverPath = (points, widthFactor, startingWidth) => {
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
const riverPointsLeft = [];
const riverPointsRight = [];
let flux = 0;
for (let p = 0; p < points.length; p++) {
const [x0, y0] = points[p - 1] || points[p];
const [x1, y1, flux] = points[p];
const [x2, y2] = points[p + 1] || points[p];
for (let pointIndex = 0; pointIndex < points.length; pointIndex++) {
const [x0, y0] = points[pointIndex - 1] || points[pointIndex];
const [x1, y1, pointFlux] = points[pointIndex];
const [x2, y2] = points[pointIndex + 1] || points[pointIndex];
if (pointFlux > flux) flux = pointFlux;
const offset = getOffset(flux, p, widthFactor, startingWidth);
const offset = getOffset({flux, pointIndex, widthFactor, startingWidth});
const angle = Math.atan2(y0 - y2, x0 - x2);
const sinOffset = Math.sin(angle) * offset;
const cosOffset = Math.cos(angle) * offset;
@ -507,6 +512,7 @@ window.Rivers = (function () {
getBasin,
getWidth,
getOffset,
getSourceWidth,
getApproximateLength,
getRiverPoints,
remove,

View file

@ -1,6 +1,15 @@
const ROUTES_SHARP_ANGLE = 135;
const ROUTES_VERY_SHARP_ANGLE = 115;
const MIN_PASSABLE_SEA_TEMP = -4;
const ROUTE_TYPE_MODIFIERS = {
"-1": 1, // coastline
"-2": 1.8, // sea
"-3": 4, // open sea
"-4": 6, // ocean
default: 8 // far ocean
};
window.Routes = (function () {
function generate(lockedRoutes = []) {
const {capitalsByFeature, burgsByFeature, portsByFeature} = sortBurgsByFeature(pack.burgs);
@ -118,10 +127,9 @@ window.Routes = (function () {
}
function findPathSegments({isWater, connections, start, exit}) {
const from = findPath(isWater, start, exit, connections);
if (!from) return [];
const pathCells = restorePath(start, exit, from);
const getCost = createCostEvaluator({isWater, connections});
const pathCells = findPath(start, current => current === exit, getCost);
if (!pathCells) return [];
const segments = getRouteSegments(pathCells, connections);
return segments;
}
@ -172,6 +180,39 @@ window.Routes = (function () {
return routesMerged > 1 ? mergeRoutes(routes) : routes;
}
}
function createCostEvaluator({isWater, connections}) {
return isWater ? getWaterPathCost : getLandPathCost;
function getLandPathCost(current, next) {
if (pack.cells.h[next] < 20) return Infinity; // ignore water cells
const habitability = biomesData.habitability[pack.cells.biome[next]];
if (!habitability) return Infinity; // inhabitable cells are not passable (e.g. glacier)
const distanceCost = dist2(pack.cells.p[current], pack.cells.p[next]);
const habitabilityModifier = 1 + Math.max(100 - habitability, 0) / 1000; // [1, 1.1];
const heightModifier = 1 + Math.max(pack.cells.h[next] - 25, 25) / 25; // [1, 3];
const connectionModifier = connections.has(`${current}-${next}`) ? 0.5 : 1;
const burgModifier = pack.cells.burg[next] ? 1 : 3;
const pathCost = distanceCost * habitabilityModifier * heightModifier * connectionModifier * burgModifier;
return pathCost;
}
function getWaterPathCost(current, next) {
if (pack.cells.h[next] >= 20) return Infinity; // ignore land cells
if (grid.cells.temp[pack.cells.g[next]] < MIN_PASSABLE_SEA_TEMP) return Infinity; // ignore too cold cells
const distanceCost = dist2(pack.cells.p[current], pack.cells.p[next]);
const typeModifier = ROUTE_TYPE_MODIFIERS[pack.cells.t[next]] || ROUTE_TYPE_MODIFIERS.default;
const connectionModifier = connections.has(`${current}-${next}`) ? 0.5 : 1;
const pathCost = distanceCost * typeModifier * connectionModifier;
return pathCost;
}
}
function buildLinks(routes) {
const links = {};
@ -195,7 +236,6 @@ window.Routes = (function () {
return links;
}
}
function preparePointsArray() {
const {cells, burgs} = pack;
@ -249,109 +289,6 @@ window.Routes = (function () {
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 = [];
@ -422,21 +359,16 @@ window.Routes = (function () {
// connect cell with routes system by land
function connect(cellId) {
if (isConnected(cellId)) return;
const getCost = createCostEvaluator({isWater: false, connections: new Map()});
const pathCells = findPath(cellId, isConnected, getCost);
if (!pathCells) 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 feature = pack.cells.f[cellId];
const routeId = getNextId();
const newRoute = {i: routeId, group: "trails", feature, points};
routes.push(newRoute);
pack.routes.push(newRoute);
for (let i = 0; i < pathCells.length; i++) {
const cellId = pathCells[i];
@ -446,43 +378,6 @@ window.Routes = (function () {
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;
@ -763,6 +658,7 @@ window.Routes = (function () {
return {
generate,
buildLinks,
connect,
isConnected,
areConnected,

View file

@ -50,7 +50,8 @@ window.States = (() => {
const {cells, states, cultures, burgs} = pack;
cells.state = cells.state || new Uint16Array(cells.i.length);
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
const queue = new FlatQueue();
const cost = [];
const globalGrowthRate = byId("growthRate").valueAsNumber || 1;
@ -71,12 +72,13 @@ window.States = (() => {
cells.state[capitalCell] = state.i;
const cultureCenter = cultures[state.culture].center;
const b = cells.biome[cultureCenter]; // state native biome
queue.queue({e: state.center, p: 0, s: state.i, b});
queue.push({e: state.center, p: 0, s: state.i, b}, 0);
cost[state.center] = 1;
}
while (queue.length) {
const next = queue.dequeue();
const next = queue.pop();
const {e, p, s, b} = next;
const {type, culture} = states[s];
@ -99,7 +101,7 @@ window.States = (() => {
if (!cost[e] || totalCost < cost[e]) {
if (cells.h[e] >= 20) cells.state[e] = s; // assign state to cell
cost[e] = totalCost;
queue.queue({e, p: totalCost, s, b});
queue.push({e, p: totalCost, s, b}, totalCost);
}
});
}

View file

@ -31,7 +31,6 @@ window.Submap = (function () {
seed = parentMap.seed;
Math.random = aleaPRNG(seed);
INFO && console.group("SubMap with seed: " + seed);
DEBUG && console.info("Using Options:", options);
applyGraphSize();
grid = generateGrid();
@ -373,7 +372,7 @@ window.Submap = (function () {
b.removed = true;
return;
}
DEBUG && console.info(`Moving ${b.name} from ${cityCell} to ${newCell} near ${neighbor}.`);
[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;

View file

@ -1,9 +1,115 @@
"use strict";
const GPT_MODELS = ["gpt-4o-mini", "chatgpt-4o-latest", "gpt-4o", "gpt-4-turbo", "gpt-4", "gpt-3.5-turbo"];
const PROVIDERS = {
openai: {
keyLink: "https://platform.openai.com/account/api-keys",
generate: generateWithOpenAI
},
anthropic: {
keyLink: "https://console.anthropic.com/account/keys",
generate: generateWithAnthropic
}
};
const DEFAULT_MODEL = "gpt-4o-mini";
const MODELS = {
"gpt-4o-mini": "openai",
"chatgpt-4o-latest": "openai",
"gpt-4o": "openai",
"gpt-4-turbo": "openai",
"o1-preview": "openai",
"o1-mini": "openai",
"claude-3-5-haiku-latest": "anthropic",
"claude-3-5-sonnet-latest": "anthropic",
"claude-3-opus-latest": "anthropic"
};
const SYSTEM_MESSAGE = "I'm working on my fantasy map.";
function geneateWithAi(defaultPrompt, onApply) {
async function generateWithOpenAI({key, model, prompt, temperature, onContent}) {
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${key}`
};
const messages = [
{role: "system", content: SYSTEM_MESSAGE},
{role: "user", content: prompt}
];
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers,
body: JSON.stringify({model, messages, temperature, stream: true})
});
const getContent = json => {
const content = json.choices?.[0]?.delta?.content;
if (content) onContent(content);
};
await handleStream(response, getContent);
}
async function generateWithAnthropic({key, model, prompt, temperature, onContent}) {
const headers = {
"Content-Type": "application/json",
"x-api-key": key,
"anthropic-version": "2023-06-01",
"anthropic-dangerous-direct-browser-access": "true"
};
const messages = [{role: "user", content: prompt}];
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers,
body: JSON.stringify({model, system: SYSTEM_MESSAGE, messages, temperature, max_tokens: 4096, stream: true})
});
const getContent = json => {
const content = json.delta?.text;
if (content) onContent(content);
};
await handleStream(response, getContent);
}
async function handleStream(response, getContent) {
if (!response.ok) {
const json = await response.json();
throw new Error(json?.error?.message || "Failed to generate");
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const {done, value} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
const lines = buffer.split("\n");
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i].trim();
if (line.startsWith("data: ") && line !== "data: [DONE]") {
try {
const json = JSON.parse(line.slice(6));
getContent(json);
} catch (jsonError) {
ERROR && console.error(`Failed to parse JSON:`, jsonError, `Line: ${line}`);
}
}
}
buffer = lines.at(-1);
}
}
function generateWithAi(defaultPrompt, onApply) {
updateValues();
$("#aiGenerator").dialog({
@ -26,86 +132,56 @@ function geneateWithAi(defaultPrompt, onApply) {
}
});
if (modules.geneateWithAi) return;
modules.geneateWithAi = true;
if (modules.generateWithAi) return;
modules.generateWithAi = true;
byId("aiGeneratorKeyHelp").on("click", function (e) {
const model = byId("aiGeneratorModel").value;
const provider = MODELS[model];
openURL(PROVIDERS[provider].keyLink);
});
function updateValues() {
byId("aiGeneratorResult").value = "";
byId("aiGeneratorPrompt").value = defaultPrompt;
byId("aiGeneratorKey").value = localStorage.getItem("fmg-ai-kl") || "";
byId("aiGeneratorTemperature").value = localStorage.getItem("fmg-ai-temperature") || "1";
const select = byId("aiGeneratorModel");
select.options.length = 0;
GPT_MODELS.forEach(model => select.options.add(new Option(model, model)));
select.value = localStorage.getItem("fmg-ai-model") || GPT_MODELS[0];
Object.keys(MODELS).forEach(model => select.options.add(new Option(model, model)));
select.value = localStorage.getItem("fmg-ai-model");
if (!select.value || !MODELS[select.value]) select.value = DEFAULT_MODEL;
const provider = MODELS[select.value];
byId("aiGeneratorKey").value = localStorage.getItem(`fmg-ai-kl-${provider}`) || "";
}
async function generate(button) {
const key = byId("aiGeneratorKey").value;
if (!key) return tip("Please enter an OpenAI API key", true, "error", 4000);
localStorage.setItem("fmg-ai-kl", key);
if (!key) return tip("Please enter an API key", true, "error", 4000);
const model = byId("aiGeneratorModel").value;
if (!model) return tip("Please select a model", true, "error", 4000);
localStorage.setItem("fmg-ai-model", model);
const provider = MODELS[model];
localStorage.setItem(`fmg-ai-kl-${provider}`, key);
const prompt = byId("aiGeneratorPrompt").value;
if (!prompt) return tip("Please enter a prompt", true, "error", 4000);
const temperature = byId("aiGeneratorTemperature").valueAsNumber;
if (isNaN(temperature)) return tip("Temperature must be a number", true, "error", 4000);
localStorage.setItem("fmg-ai-temperature", temperature);
try {
button.disabled = true;
const resultArea = byId("aiGeneratorResult");
resultArea.value = "";
resultArea.disabled = true;
resultArea.value = "";
const onContent = content => (resultArea.value += content);
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${key}`
},
body: JSON.stringify({
model,
messages: [
{role: "system", content: SYSTEM_MESSAGE},
{role: "user", content: prompt}
],
temperature: 1.2,
stream: true // Enable streaming
})
});
if (!response.ok) {
const json = await response.json();
throw new Error(json?.error?.message || "Failed to generate");
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const {done, value} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
const lines = buffer.split("\n");
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i].trim();
if (line.startsWith("data: ") && line !== "data: [DONE]") {
try {
const jsonData = JSON.parse(line.slice(6));
const content = jsonData.choices[0].delta.content;
if (content) resultArea.value += content;
} catch (jsonError) {
console.warn("Failed to parse JSON:", jsonError, "Line:", line);
}
}
}
buffer = lines[lines.length - 1];
}
await PROVIDERS[provider].generate({key, model, prompt, temperature, onContent});
} catch (error) {
return tip(error.message, true, "error", 4000);
} finally {

View file

@ -1066,7 +1066,7 @@ async function editStates() {
async function editCultures() {
if (customization) return;
const Editor = await import("../dynamic/editors/cultures-editor.js?v=1.104.0");
const Editor = await import("../dynamic/editors/cultures-editor.js?v=1.105.23");
Editor.open();
}

View file

@ -122,7 +122,7 @@ function editLabel() {
function redrawLabelPath() {
const path = byId("textPath_" + elSelected.attr("id"));
lineGen.curve(d3.curveBundle.beta(1));
lineGen.curve(d3.curveNatural);
const points = [];
debug
.select("#controlPoints")

View file

@ -15,7 +15,7 @@ function editLake() {
debug.append("g").attr("id", "vertices");
elSelected = d3.select(node);
updateLakeValues();
selectLakeGroup(node);
selectLakeGroup();
drawLakeVertices();
viewbox.on("touchmove mousemove", null);
@ -140,13 +140,13 @@ function editLake() {
lake.name = lakeName.value = Names.getBase(rand(nameBases.length - 1));
}
function selectLakeGroup(node) {
const group = node.parentNode.id;
function selectLakeGroup() {
const lake = getLake();
const select = byId("lakeGroup");
select.options.length = 0; // remove all options
lakes.selectAll("g").each(function () {
select.options.add(new Option(this.id, this.id, false, this.id === group));
select.options.add(new Option(this.id, this.id, false, this.id === lake.group));
});
}

View file

@ -796,14 +796,12 @@ function drawRivers() {
TIME && console.time("drawRivers");
rivers.selectAll("*").remove();
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
const riverPaths = pack.rivers.map(({cells, points, i, widthFactor, sourceWidth}) => {
if (!cells || cells.length < 2) return;
if (points && points.length !== cells.length) {
console.error(
`River ${i} has ${cells.length} cells, but only ${points.length} points defined.`,
"Resetting points data"
`River ${i} has ${cells.length} cells, but only ${points.length} points defined. Resetting points data`
);
points = undefined;
}

View file

@ -41,7 +41,7 @@ function editNamesbase() {
$("#namesbaseEditor").dialog({
title: "Namesbase Editor",
width: "auto",
width: "60vw",
position: {my: "center", at: "center", of: "svg"}
});
@ -66,7 +66,7 @@ function editNamesbase() {
function updateExamples() {
const base = +document.getElementById("namesbaseSelect").value;
let examples = "";
for (let i = 0; i < 10; i++) {
for (let i = 0; i < 7; i++) {
const example = Names.getBase(base);
if (example === undefined) {
examples = "Cannot generate examples. Please verify the data";
@ -250,7 +250,7 @@ function editNamesbase() {
const [rawName, min, max, d, m, rawNames] = base.split("|");
const name = rawName.replace(unsafe, "");
const names = rawNames.replace(unsafe, "");
nameBases.push({name, min, max, d, m, b: names});
nameBases.push({name, min: +min, max: +max, d, m: +m, b: names});
});
createBasesList();

View file

@ -160,7 +160,7 @@ function editNotes(id, name) {
}
};
geneateWithAi(prompt, onApply);
generateWithAi(prompt, onApply);
}
function downloadLegends() {

View file

@ -332,16 +332,12 @@ const cellsDensityMap = {
function changeCellsDensity(value) {
pointsInput.value = value;
const cells = cellsDensityMap[value] || 1000;
const cells = cellsDensityMap[value] || pointsInput.dataset.cells;
pointsInput.dataset.cells = cells;
pointsOutputFormatted.value = getCellsDensityValue(cells);
pointsOutputFormatted.value = cells / 1000 + "K";
pointsOutputFormatted.style.color = getCellsDensityColor(cells);
}
function getCellsDensityValue(cells) {
return cells / 1000 + "K";
}
function getCellsDensityColor(cells) {
return cells > 50000 ? "#b12117" : cells !== 10000 ? "#dfdf12" : "#053305";
}
@ -558,10 +554,10 @@ function applyStoredOptions() {
if (key.slice(0, 5) === "style") applyOption(stylePreset, key, key.slice(5));
}
if (stored("winds")) options.winds = localStorage.getItem("winds").split(",").map(Number);
if (stored("temperatureEquator")) options.temperatureEquator = +localStorage.getItem("temperatureEquator");
if (stored("temperatureNorthPole")) options.temperatureNorthPole = +localStorage.getItem("temperatureNorthPole");
if (stored("temperatureSouthPole")) options.temperatureSouthPole = +localStorage.getItem("temperatureSouthPole");
if (stored("winds")) options.winds = stored("winds").split(",").map(Number);
if (stored("temperatureEquator")) options.temperatureEquator = +stored("temperatureEquator");
if (stored("temperatureNorthPole")) options.temperatureNorthPole = +stored("temperatureNorthPole");
if (stored("temperatureSouthPole")) options.temperatureSouthPole = +stored("temperatureSouthPole");
if (stored("military")) options.military = JSON.parse(stored("military"));
if (stored("tooltipSize")) changeTooltipSize(stored("tooltipSize"));

View file

@ -74,13 +74,10 @@ function createRiver() {
function addRiver() {
const {rivers, cells} = pack;
const {addMeandering, getApproximateLength, getWidth, getOffset, getName, getRiverPath, getBasin, getNextId} =
Rivers;
const riverCells = createRiver.cells;
if (riverCells.length < 2) return tip("Add at least 2 cells", false, "error");
const riverId = getNextId(rivers);
const riverId = Rivers.getNextId(rivers);
const parent = cells.r[last(riverCells)] || riverId;
riverCells.forEach(cell => {
@ -89,17 +86,24 @@ function createRiver() {
const source = riverCells[0];
const mouth = parent === riverId ? last(riverCells) : riverCells[riverCells.length - 2];
const sourceWidth = 0.05;
const sourceWidth = Rivers.getSourceWidth(cells.fl[source]);
const defaultWidthFactor = rn(1 / (pointsInput.dataset.cells / 10000) ** 0.25, 2);
const widthFactor = 1.2 * defaultWidthFactor;
const meanderedPoints = addMeandering(riverCells);
const meanderedPoints = Rivers.addMeandering(riverCells);
const discharge = cells.fl[mouth]; // m3 in second
const length = getApproximateLength(meanderedPoints);
const width = getWidth(getOffset(discharge, meanderedPoints.length, widthFactor, sourceWidth));
const name = getName(mouth);
const basin = getBasin(parent);
const length = Rivers.getApproximateLength(meanderedPoints);
const width = Rivers.getWidth(
Rivers.getOffset({
flux: discharge,
pointIndex: meanderedPoints.length,
widthFactor,
startingWidth: sourceWidth
})
);
const name = Rivers.getName(mouth);
const basin = Rivers.getBasin(parent);
rivers.push({
i: riverId,
@ -118,13 +122,11 @@ function createRiver() {
});
const id = "river" + riverId;
// render river
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
viewbox
.select("#rivers")
.append("path")
.attr("id", id)
.attr("d", getRiverPath(meanderedPoints, widthFactor, sourceWidth));
.attr("d", Rivers.getRiverPath(meanderedPoints, widthFactor, sourceWidth));
editRiver(id);
}

View file

@ -86,10 +86,16 @@ function editRiver(id) {
}
function updateRiverWidth(river) {
const {addMeandering, getWidth, getOffset} = Rivers;
const {cells, discharge, widthFactor, sourceWidth} = river;
const meanderedPoints = addMeandering(cells);
river.width = getWidth(getOffset(discharge, meanderedPoints.length, widthFactor, sourceWidth));
const meanderedPoints = Rivers.addMeandering(cells);
river.width = Rivers.getWidth(
Rivers.getOffset({
flux: discharge,
pointIndex: meanderedPoints.length,
widthFactor,
startingWidth: sourceWidth
})
);
const width = `${rn(river.width * distanceScale, 3)} ${distanceUnitInput.value}`;
byId("riverWidth").value = width;
@ -158,11 +164,9 @@ function editRiver(id) {
river.points = debug.selectAll("#controlPoints > *").data();
river.cells = river.points.map(([x, y]) => findCell(x, y));
const {widthFactor, sourceWidth} = river;
const meanderedPoints = Rivers.addMeandering(river.cells, river.points);
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
const path = Rivers.getRiverPath(meanderedPoints, widthFactor, sourceWidth);
const meanderedPoints = Rivers.addMeandering(river.cells, river.points);
const path = Rivers.getRiverPath(meanderedPoints, river.widthFactor, river.sourceWidth);
elSelected.attr("d", path);
updateRiverLength(river);

View file

@ -116,20 +116,20 @@ function selectStyleElement() {
if (
[
"armies",
"routes",
"lakes",
"biomes",
"borders",
"cults",
"relig",
"cells",
"coastline",
"prec",
"coordinates",
"cults",
"gridOverlay",
"ice",
"icons",
"coordinates",
"zones",
"gridOverlay"
"lakes",
"prec",
"relig",
"routes",
"zones"
].includes(styleElement)
) {
styleStroke.style.display = "block";
@ -140,7 +140,7 @@ function selectStyleElement() {
// stroke dash
if (
["routes", "borders", "temperature", "legend", "population", "coordinates", "zones", "gridOverlay"].includes(
["borders", "cells", "coordinates", "gridOverlay", "legend", "population", "routes", "temperature", "zones"].includes(
styleElement
)
) {

95
modules/ui/submap-tool.js Normal file
View file

@ -0,0 +1,95 @@
"use strict";
function openSubmapTool() {
resetInputs();
$("#submapTool").dialog({
title: "Create a submap",
resizable: false,
width: "32em",
position: {my: "center", at: "center", of: "svg"},
buttons: {
Submap: function () {
closeDialogs();
generateSubmap();
},
Cancel: function () {
$(this).dialog("close");
}
}
});
if (modules.openSubmapTool) return;
modules.openSubmapTool = true;
function resetInputs() {
updateCellsNumber(byId("pointsInput").value);
byId("submapPointsInput").oninput = e => updateCellsNumber(e.target.value);
function updateCellsNumber(value) {
byId("submapPointsInput").value = value;
const cells = cellsDensityMap[value];
byId("submapPointsInput").dataset.cells = cells;
const output = byId("submapPointsFormatted");
output.value = cells / 1000 + "K";
output.style.color = getCellsDensityColor(cells);
}
}
function generateSubmap() {
INFO && console.group("generateSubmap");
const [x0, y0] = [Math.abs(viewX / scale), Math.abs(viewY / scale)]; // top-left corner
recalculateMapSize(x0, y0);
const submapPointsValue = byId("submapPointsInput").value;
const globalPointsValue = byId("pointsInput").value;
if (submapPointsValue !== globalPointsValue) changeCellsDensity(submapPointsValue);
const projection = (x, y) => [(x - x0) * scale, (y - y0) * scale];
const inverse = (x, y) => [x / scale + x0, y / scale + y0];
resetZoom(0);
undraw();
Resample.process({projection, inverse, scale});
if (byId("submapRescaleBurgStyles").checked) rescaleBurgStyles(scale);
drawLayers();
INFO && console.groupEnd("generateSubmap");
}
function recalculateMapSize(x0, y0) {
const mapSize = +byId("mapSizeOutput").value;
byId("mapSizeOutput").value = byId("mapSizeInput").value = rn(mapSize / scale, 2);
const latT = mapCoordinates.latT / scale;
const latN = getLatitude(y0);
const latShift = (90 - latN) / (180 - latT);
byId("latitudeOutput").value = byId("latitudeInput").value = rn(latShift * 100, 2);
const lotT = mapCoordinates.lonT / scale;
const lonE = getLongitude(x0 + graphWidth / scale);
const lonShift = (180 - lonE) / (360 - lotT);
byId("longitudeOutput").value = byId("longitudeInput").value = rn(lonShift * 100, 2);
distanceScale = distanceScaleInput.value = rn(distanceScale / scale, 2);
populationRate = populationRateInput.value = rn(populationRate / scale, 2);
}
function rescaleBurgStyles(scale) {
const burgIcons = [...byId("burgIcons").querySelectorAll("g")];
for (const group of burgIcons) {
const newRadius = rn(minmax(group.getAttribute("size") * scale, 0.2, 10), 2);
changeRadius(newRadius, group.id);
const strokeWidth = group.attributes["stroke-width"];
strokeWidth.value = strokeWidth.value * scale;
}
const burgLabels = [...byId("burgLabels").querySelectorAll("g")];
for (const group of burgLabels) {
const size = +group.dataset.size;
group.dataset.size = Math.max(rn((size + size / scale) / 2, 2), 1) * scale;
}
}
}

View file

@ -1,332 +0,0 @@
"use strict";
// UI elements for submap generation
window.UISubmap = (function () {
byId("submapPointsInput").addEventListener("input", function () {
const output = byId("submapPointsOutputFormatted");
const cells = cellsDensityMap[+this.value] || 1000;
this.dataset.cells = cells;
output.value = getCellsDensityValue(cells);
output.style.color = getCellsDensityColor(cells);
});
byId("submapScaleInput").addEventListener("input", function (event) {
const exp = Math.pow(1.1, +event.target.value);
byId("submapScaleOutput").value = rn(exp, 2);
});
byId("submapAngleInput").addEventListener("input", function (event) {
byId("submapAngleOutput").value = event.target.value;
});
const $previewBox = byId("submapPreview");
const $scaleInput = byId("submapScaleInput");
const $shiftX = byId("submapShiftX");
const $shiftY = byId("submapShiftY");
function openSubmapMenu() {
$("#submapOptionsDialog").dialog({
title: "Create a submap",
resizable: false,
width: "32em",
position: {my: "center", at: "center", of: "svg"},
buttons: {
Submap: function () {
$(this).dialog("close");
generateSubmap();
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
const getTransformInput = _ => ({
angle: (+byId("submapAngleInput").value / 180) * Math.PI,
shiftX: +byId("submapShiftX").value,
shiftY: +byId("submapShiftY").value,
ratio: +byId("submapScaleInput").value,
mirrorH: byId("submapMirrorH").checked,
mirrorV: byId("submapMirrorV").checked
});
async function openResampleMenu() {
resetZoom(0);
byId("submapAngleInput").value = 0;
byId("submapAngleOutput").value = "0";
byId("submapScaleOutput").value = 1;
byId("submapMirrorH").checked = false;
byId("submapMirrorV").checked = false;
$scaleInput.value = 0;
$shiftX.value = 0;
$shiftY.value = 0;
const w = Math.min(400, window.innerWidth * 0.5);
const previewScale = w / graphWidth;
const h = graphHeight * previewScale;
$previewBox.style.width = w + "px";
$previewBox.style.height = h + "px";
// handle mouse input
const dispatchInput = e => e.dispatchEvent(new Event("input", {bubbles: true}));
// mouse wheel
$previewBox.onwheel = e => {
$scaleInput.value = $scaleInput.valueAsNumber - Math.sign(e.deltaY);
dispatchInput($scaleInput);
};
// mouse drag
let mouseIsDown = false,
mouseX = 0,
mouseY = 0;
$previewBox.onmousedown = e => {
mouseIsDown = true;
mouseX = $shiftX.value - e.clientX / previewScale;
mouseY = $shiftY.value - e.clientY / previewScale;
};
$previewBox.onmouseup = _ => (mouseIsDown = false);
$previewBox.onmouseleave = _ => (mouseIsDown = false);
$previewBox.onmousemove = e => {
if (!mouseIsDown) return;
e.preventDefault();
$shiftX.value = Math.round(mouseX + e.clientX / previewScale);
$shiftY.value = Math.round(mouseY + e.clientY / previewScale);
dispatchInput($shiftX);
// dispatchInput($shiftY); // not needed X bubbles anyway
};
$("#resampleDialog").dialog({
title: "Transform map",
resizable: false,
position: {my: "center", at: "center", of: "svg"},
buttons: {
Transform: function () {
$(this).dialog("close");
resampleCurrentMap();
},
Cancel: function () {
$(this).dialog("close");
}
}
});
// use double resolution for PNG to get sharper image
const $preview = await loadPreview($previewBox, w * 2, h * 2);
// could be done with SVG. Faster to load, slower to use.
// const $preview = await loadPreviewSVG($previewBox, w, h);
$preview.style.position = "absolute";
$preview.style.width = w + "px";
$preview.style.height = h + "px";
byId("resampleDialog").oninput = event => {
const {angle, shiftX, shiftY, ratio, mirrorH, mirrorV} = getTransformInput();
const scale = Math.pow(1.1, ratio);
const transformStyle = `
translate(${shiftX * previewScale}px, ${shiftY * previewScale}px)
scale(${mirrorH ? -scale : scale}, ${mirrorV ? -scale : scale})
rotate(${angle}rad)
`;
$preview.style.transform = transformStyle;
$preview.style["transform-origin"] = "center";
event.stopPropagation();
};
}
async function loadPreview($container, w, h) {
const url = await getMapURL("png", {
globe: false,
noWater: true,
fullMap: true,
noLabels: true,
noScaleBar: true,
noVignette: true,
noIce: true
});
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = w;
canvas.height = h;
const img = new Image();
img.src = url;
img.onload = function () {
ctx.drawImage(img, 0, 0, w, h);
};
$container.textContent = "";
$container.appendChild(canvas);
return canvas;
}
// Resample the whole map to different cell resolution or shape
const resampleCurrentMap = debounce(function () {
WARN && console.warn("Resampling current map");
const cellNumId = +byId("submapPointsInput").value;
if (!cellsDensityMap[cellNumId]) return console.error("Unknown cell number!");
const {angle, shiftX, shiftY, ratio, mirrorH, mirrorV} = getTransformInput();
const [cx, cy] = [graphWidth / 2, graphHeight / 2];
const rot = alfa => (x, y) =>
[
(x - cx) * Math.cos(alfa) - (y - cy) * Math.sin(alfa) + cx,
(y - cy) * Math.cos(alfa) + (x - cx) * Math.sin(alfa) + cy
];
const shift = (dx, dy) => (x, y) => [x + dx, y + dy];
const scale = r => (x, y) => [(x - cx) * r + cx, (y - cy) * r + cy];
const flipH = (x, y) => [-x + 2 * cx, y];
const flipV = (x, y) => [x, -y + 2 * cy];
const app = (f, g) => (x, y) => f(...g(x, y));
const id = (x, y) => [x, y];
let projection = id;
let inverse = id;
if (angle) [projection, inverse] = [rot(angle), rot(-angle)];
if (ratio)
[projection, inverse] = [
app(scale(Math.pow(1.1, ratio)), projection),
app(inverse, scale(Math.pow(1.1, -ratio)))
];
if (mirrorH) [projection, inverse] = [app(flipH, projection), app(inverse, flipH)];
if (mirrorV) [projection, inverse] = [app(flipV, projection), app(inverse, flipV)];
if (shiftX || shiftY) {
projection = app(shift(shiftX, shiftY), projection);
inverse = app(inverse, shift(-shiftX, -shiftY));
}
changeCellsDensity(cellNumId);
startResample({
lockMarkers: false,
lockBurgs: false,
depressRivers: false,
addLakesInDepressions: false,
promoteTowns: false,
smoothHeightMap: false,
rescaleStyles: false,
scale: 1,
projection,
inverse
});
}, 1000);
// Create submap from the current map. Submap limits defined by the current window size (canvas viewport)
const generateSubmap = debounce(function () {
WARN && console.warn("Resampling current map");
closeDialogs("#worldConfigurator, #options3d");
const checked = id => Boolean(byId(id).checked);
// Create projection func from current zoom extents
const [[x0, y0], [x1, y1]] = getViewBoxExtent();
const origScale = scale;
const options = {
lockMarkers: checked("submapLockMarkers"),
lockBurgs: checked("submapLockBurgs"),
depressRivers: checked("submapDepressRivers"),
addLakesInDepressions: checked("submapAddLakeInDepression"),
promoteTowns: checked("submapPromoteTowns"),
rescaleStyles: checked("submapRescaleStyles"),
smoothHeightMap: scale > 2,
inverse: (x, y) => [x / origScale + x0, y / origScale + y0],
projection: (x, y) => [(x - x0) * origScale, (y - y0) * origScale],
scale: origScale
};
// converting map position on the planet
const mapSizeOutput = byId("mapSizeOutput");
const latitudeOutput = byId("latitudeOutput");
const latN = 90 - ((180 - (mapSizeInput.value / 100) * 180) * latitudeOutput.value) / 100;
const newLatN = latN - ((y0 / graphHeight) * mapSizeOutput.value * 180) / 100;
mapSizeOutput.value /= scale;
latitudeOutput.value = ((90 - newLatN) / (180 - (mapSizeOutput.value / 100) * 180)) * 100;
byId("mapSizeInput").value = mapSizeOutput.value;
byId("latitudeInput").value = latitudeOutput.value;
// fix scale
distanceScale = distanceScaleInput.value = rn(distanceScaleInput.value / scale, 2);
populationRate = populationRateInput.value = rn(populationRateInput.value / scale, 2);
customization = 0;
startResample(options);
}, 1000);
async function startResample(options) {
// Do model changes with Submap.resample then do view changes if needed
resetZoom(0);
let oldstate = {
grid: deepCopy(grid),
pack: deepCopy(pack),
notes: deepCopy(notes),
seed,
graphWidth,
graphHeight
};
undraw();
try {
const oldScale = scale;
await Submap.resample(oldstate, options);
if (options.promoteTowns) {
const groupName = "largetowns";
moveAllBurgsToGroup("towns", groupName);
changeRadius(rn(oldScale * 0.8, 2), groupName);
changeFontSize(svg.select(`#labels #${groupName}`), rn(oldScale * 2, 2));
invokeActiveZooming();
}
if (options.rescaleStyles) changeStyles(oldScale);
} catch (error) {
showSubmapErrorHandler(error);
}
oldstate = null; // destroy old state to free memory
drawLayers();
if (ThreeD.options.isOn) ThreeD.redraw();
if ($("#worldConfigurator").is(":visible")) editWorld();
}
function changeStyles(scale) {
// resize burgIcons
const burgIcons = [...byId("burgIcons").querySelectorAll("g")];
for (const bi of burgIcons) {
const newRadius = rn(minmax(bi.getAttribute("size") * scale, 0.2, 10), 2);
changeRadius(newRadius, bi.id);
const swAttr = bi.attributes["stroke-width"];
swAttr.value = +swAttr.value * scale;
}
// burglabels
const burgLabels = [...byId("burgLabels").querySelectorAll("g")];
for (const bl of burgLabels) {
const size = +bl.dataset["size"];
bl.dataset["size"] = Math.max(rn((size + size / scale) / 2, 2), 1) * scale;
}
drawEmblems();
}
function showSubmapErrorHandler(error) {
ERROR && console.error(error);
clearMainTip();
alertMessage.innerHTML = /* html */ `Map resampling failed: <br />You may retry after clearing stored data or contact us at discord.
<p id="errorBox">${parseError(error)}</p>`;
$("#alert").dialog({
resizable: false,
title: "Resampling error",
width: "32em",
buttons: {
Ok: function () {
$(this).dialog("close");
}
},
position: {my: "center", at: "center", of: "svg"}
});
}
return {openSubmapMenu, openResampleMenu};
})();

View file

@ -3,7 +3,7 @@
// module to control the Tools options (click to edit, to re-geenerate, tp add)
toolsContent.addEventListener("click", function (event) {
if (customization) return tip("Please exit the customization mode first", false, "warning");
if (customization) return tip("Please exit the customization mode first", false, "error");
if (!["BUTTON", "I"].includes(event.target.tagName)) return;
const button = event.target.id;
@ -70,8 +70,8 @@ toolsContent.addEventListener("click", function (event) {
else if (button === "addRoute") createRoute();
else if (button === "addMarker") toggleAddMarker();
// click to create a new map buttons
else if (button === "openSubmapMenu") UISubmap.openSubmapMenu();
else if (button === "openResampleMenu") UISubmap.openResampleMenu();
else if (button === "openSubmapTool") openSubmapTool();
else if (button === "openTransformTool") openTransformTool();
});
function processFeatureRegeneration(event, button) {
@ -514,8 +514,8 @@ function regenerateEmblems() {
function regenerateReligions() {
Religions.generate();
if (layerIsOn("toggleReligions")) drawReligions();
else toggleReligions();
layerIsOn("toggleReligions") ? drawReligions() : toggleReligions();
refreshAllEditors();
}
@ -685,28 +685,15 @@ function addRiverOnClick() {
if (cells.h[i] < 20) return tip("Cannot create river in water cell", false, "error");
if (cells.b[i]) return;
const {
alterHeights,
resolveDepressions,
addMeandering,
getRiverPath,
getBasin,
getName,
getType,
getWidth,
getOffset,
getApproximateLength,
getNextId
} = Rivers;
const riverCells = [];
let riverId = getNextId(rivers);
let riverId = Rivers.getNextId(rivers);
let parent = riverId;
const initialFlux = grid.cells.prec[cells.g[i]];
cells.fl[i] = initialFlux;
const h = alterHeights();
resolveDepressions(h);
const h = Rivers.alterHeights();
Rivers.resolveDepressions(h);
while (i) {
cells.r[i] = riverId;
@ -780,11 +767,19 @@ function addRiverOnClick() {
const defaultWidthFactor = rn(1 / (pointsInput.dataset.cells / 10000) ** 0.25, 2);
const widthFactor =
river?.widthFactor || (!parent || parent === riverId ? defaultWidthFactor * 1.2 : defaultWidthFactor);
const meanderedPoints = addMeandering(riverCells);
const sourceWidth = river?.sourceWidth || Rivers.getSourceWidth(cells.fl[source]);
const meanderedPoints = Rivers.addMeandering(riverCells);
const discharge = cells.fl[mouth]; // m3 in second
const length = getApproximateLength(meanderedPoints);
const width = getWidth(getOffset(discharge, meanderedPoints.length, widthFactor));
const length = Rivers.getApproximateLength(meanderedPoints);
const width = Rivers.getWidth(
Rivers.getOffset({
flux: discharge,
pointIndex: meanderedPoints.length,
widthFactor,
startingWidth: sourceWidth
})
);
if (river) {
river.source = source;
@ -793,9 +788,9 @@ function addRiverOnClick() {
river.width = width;
river.cells = riverCells;
} else {
const basin = getBasin(parent);
const name = getName(mouth);
const type = getType({i: riverId, length, parent});
const basin = Rivers.getBasin(parent);
const name = Rivers.getName(mouth);
const type = Rivers.getType({i: riverId, length, parent});
rivers.push({
i: riverId,
@ -805,7 +800,7 @@ function addRiverOnClick() {
length,
width,
widthFactor,
sourceWidth: 0,
sourceWidth,
parent,
cells: riverCells,
basin,
@ -815,8 +810,7 @@ function addRiverOnClick() {
}
// render river
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
const path = getRiverPath(meanderedPoints, widthFactor);
const path = Rivers.getRiverPath(meanderedPoints, widthFactor, sourceWidth);
const id = "river" + riverId;
const riversG = viewbox.select("#rivers");
riversG.append("path").attr("id", id).attr("d", path);

View file

@ -0,0 +1,201 @@
"use strict";
async function openTransformTool() {
const width = Math.min(400, window.innerWidth * 0.5);
const previewScale = width / graphWidth;
const height = graphHeight * previewScale;
let mouseIsDown = false;
let mouseX = 0;
let mouseY = 0;
resetInputs();
loadPreview();
$("#transformTool").dialog({
title: "Transform map",
resizable: false,
position: {my: "center", at: "center", of: "svg"},
buttons: {
Transform: function () {
closeDialogs();
transformMap();
},
Cancel: function () {
$(this).dialog("close");
}
}
});
if (modules.openTransformTool) return;
modules.openTransformTool = true;
// add listeners
byId("transformToolBody").on("input", handleInput);
byId("transformPreview")
.on("mousedown", handleMousedown)
.on("mouseup", _ => (mouseIsDown = false))
.on("mousemove", handleMousemove)
.on("wheel", handleWheel);
async function loadPreview() {
byId("transformPreview").style.width = width + "px";
byId("transformPreview").style.height = height + "px";
const options = {noWater: true, fullMap: true, noLabels: true, noScaleBar: true, noVignette: true, noIce: true};
const url = await getMapURL("png", options);
const SCALE = 4;
const img = new Image();
img.src = url;
img.onload = function () {
const $canvas = byId("transformPreviewCanvas");
$canvas.style.width = width + "px";
$canvas.style.height = height + "px";
$canvas.width = width * SCALE;
$canvas.height = height * SCALE;
$canvas.getContext("2d").drawImage(img, 0, 0, width * SCALE, height * SCALE);
};
}
function resetInputs() {
byId("transformAngleInput").value = 0;
byId("transformAngleOutput").value = "0";
byId("transformMirrorH").checked = false;
byId("transformMirrorV").checked = false;
byId("transformScaleInput").value = 0;
byId("transformScaleResult").value = 1;
byId("transformShiftX").value = 0;
byId("transformShiftY").value = 0;
handleInput();
updateCellsNumber(byId("pointsInput").value);
byId("transformPointsInput").oninput = e => updateCellsNumber(e.target.value);
function updateCellsNumber(value) {
byId("transformPointsInput").value = value;
const cells = cellsDensityMap[value];
byId("transformPointsInput").dataset.cells = cells;
const output = byId("transformPointsFormatted");
output.value = cells / 1000 + "K";
output.style.color = getCellsDensityColor(cells);
}
}
function handleInput() {
const angle = (+byId("transformAngleInput").value / 180) * Math.PI;
const shiftX = +byId("transformShiftX").value;
const shiftY = +byId("transformShiftY").value;
const mirrorH = byId("transformMirrorH").checked;
const mirrorV = byId("transformMirrorV").checked;
const EXP = 1.0965;
const scale = rn(EXP ** +byId("transformScaleInput").value, 2); // [0.1, 10]x
byId("transformScaleResult").value = scale;
byId("transformPreviewCanvas").style.transform = `
translate(${shiftX * previewScale}px, ${shiftY * previewScale}px)
scale(${mirrorH ? -scale : scale}, ${mirrorV ? -scale : scale})
rotate(${angle}rad)
`;
}
function handleMousedown(e) {
mouseIsDown = true;
const shiftX = +byId("transformShiftX").value;
const shiftY = +byId("transformShiftY").value;
mouseX = shiftX - e.clientX / previewScale;
mouseY = shiftY - e.clientY / previewScale;
}
function handleMousemove(e) {
if (!mouseIsDown) return;
e.preventDefault();
byId("transformShiftX").value = Math.round(mouseX + e.clientX / previewScale);
byId("transformShiftY").value = Math.round(mouseY + e.clientY / previewScale);
handleInput();
}
function handleWheel(e) {
const $scaleInput = byId("transformScaleInput");
$scaleInput.value = $scaleInput.valueAsNumber - Math.sign(e.deltaY);
handleInput();
}
function transformMap() {
INFO && console.group("transformMap");
const transformPointsValue = byId("transformPointsInput").value;
const globalPointsValue = byId("pointsInput").value;
if (transformPointsValue !== globalPointsValue) changeCellsDensity(transformPointsValue);
const [projection, inverse] = getProjection();
resetZoom(0);
undraw();
Resample.process({projection, inverse, scale: 1});
drawLayers();
INFO && console.groupEnd("transformMap");
}
function getProjection() {
const centerX = graphWidth / 2;
const centerY = graphHeight / 2;
const shiftX = +byId("transformShiftX").value;
const shiftY = +byId("transformShiftY").value;
const angle = (+byId("transformAngleInput").value / 180) * Math.PI;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const scale = +byId("transformScaleResult").value;
const mirrorH = byId("transformMirrorH").checked;
const mirrorV = byId("transformMirrorV").checked;
function project(x, y) {
// center the point
x -= centerX;
y -= centerY;
// apply scale
if (scale !== 1) {
x *= scale;
y *= scale;
}
// apply rotation
if (angle) [x, y] = [x * cos - y * sin, x * sin + y * cos];
// apply mirroring
if (mirrorH) x = -x;
if (mirrorV) y = -y;
// uncenter the point and apply shift
return [x + centerX + shiftX, y + centerY + shiftY];
}
function inverse(x, y) {
// undo shift and center the point
x -= centerX + shiftX;
y -= centerY + shiftY;
// undo mirroring
if (mirrorV) y = -y;
if (mirrorH) x = -x;
// undo rotation
if (angle !== 0) [x, y] = [x * cos + y * sin, -x * sin + y * cos];
// undo scale
if (scale !== 1) {
x /= scale;
y /= scale;
}
// uncenter the point
return [x + centerX, y + centerY];
}
return [project, inverse];
}
}

View file

@ -1,7 +1,7 @@
"use strict";
function editZones() {
closeDialogs();
closeDialogs("#zonesEditor, .stable");
if (!layerIsOn("toggleZones")) toggleZones();
const body = byId("zonesBodySection");
@ -341,6 +341,8 @@ function editZones() {
}
function toggleLegend() {
if (legend.selectAll("*").size()) return clearLegend(); // hide legend
const filterBy = byId("zonesFilterType").value;
const isFiltered = filterBy && filterBy !== "all";
const visibleZones = pack.zones.filter(zone => !zone.hidden && (!isFiltered || zone.type === filterBy));

View file

@ -209,11 +209,11 @@ window.Zones = (function () {
const cost = [];
const maxCells = rand(20, 40);
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
queue.queue({e: burg.cell, p: 0});
const queue = new FlatQueue();
queue.push({e: burg.cell, p: 0}, 0);
while (queue.length) {
const next = queue.dequeue();
const next = queue.pop();
if (cells.burg[next.e] || cells.pop[next.e]) cellsArray.push(next.e);
usedCells[next.e] = 1;
@ -224,7 +224,7 @@ window.Zones = (function () {
if (!cost[nextCellId] || p < cost[nextCellId]) {
cost[nextCellId] = p;
queue.queue({e: nextCellId, p});
queue.push({e: nextCellId, p}, p);
}
});
}
@ -251,11 +251,11 @@ window.Zones = (function () {
const cost = [];
const maxCells = rand(5, 25);
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
queue.queue({e: burg.cell, p: 0});
const queue = new FlatQueue();
queue.push({e: burg.cell, p: 0}, 0);
while (queue.length) {
const next = queue.dequeue();
const next = queue.pop();
if (cells.burg[next.e] || cells.pop[next.e]) cellsArray.push(next.e);
usedCells[next.e] = 1;
@ -266,7 +266,7 @@ window.Zones = (function () {
if (!cost[e] || p < cost[e]) {
cost[e] = p;
queue.queue({e, p});
queue.push({e, p}, p);
}
});
}

View file

@ -56,3 +56,17 @@ function drawRouteConnections() {
}
}
}
function drawPoint([x, y], {color = "red", radius = 0.5}) {
debug.append("circle").attr("cx", x).attr("cy", y).attr("r", radius).attr("fill", color);
}
function drawPath(points, {color = "red", width = 0.5}) {
const lineGen = d3.line().curve(d3.curveBundle);
debug
.append("path")
.attr("d", round(lineGen(points)))
.attr("stroke", color)
.attr("stroke-width", width)
.attr("fill", "none");
}

View file

@ -241,10 +241,8 @@ void (function addFindAll() {
};
const tree_filter = function (x, y, radius) {
var t = {x, y, x0: this._x0, y0: this._y0, x3: this._x1, y3: this._y1, quads: [], node: this._root};
if (t.node) {
t.quads.push(new Quad(t.node, t.x0, t.y0, t.x3, t.y3));
}
const t = {x, y, x0: this._x0, y0: this._y0, x3: this._x1, y3: this._y1, quads: [], node: this._root};
if (t.node) t.quads.push(new Quad(t.node, t.x0, t.y0, t.x3, t.y3));
radiusSearchInit(t, radius);
var i = 0;

View file

@ -78,11 +78,10 @@ function getBorderPath(vertices, vertexChain, discontinue) {
}
const operation = discontinued ? "M" : "L";
const command = operation === lastOperation ? "" : operation;
discontinued = false;
lastOperation = operation;
const command = operation === "L" && operation === lastOperation ? "" : operation;
return ` ${command}${vertices.p[vertexId]}`;
});
@ -177,3 +176,60 @@ function connectVertices({vertices, startingVertex, ofSameType, addToChecked, cl
if (closeRing) chain.push(startingVertex);
return chain;
}
/**
* Finds the shortest path between two cells using a cost-based pathfinding algorithm.
* @param {number} start - The ID of the starting cell.
* @param {(id: number) => boolean} isExit - A function that returns true if the cell is the exit cell.
* @param {(current: number, next: number) => number} getCost - A function that returns the path cost from current cell to the next cell. Must return `Infinity` for impassable connections.
* @returns {number[] | null} An array of cell IDs of the path from start to exit, or null if no path is found or start and exit are the same.
*/
function findPath(start, isExit, getCost) {
if (isExit(start)) return null;
const from = [];
const cost = [];
const queue = new FlatQueue();
queue.push(start, 0);
while (queue.length) {
const currentCost = queue.peekValue();
const current = queue.pop();
for (const next of pack.cells.c[current]) {
if (isExit(next)) {
from[next] = current;
return restorePath(next, start, from);
}
const nextCost = getCost(current, next);
if (nextCost === Infinity) continue; // impassable cell
const totalCost = currentCost + nextCost;
if (totalCost >= cost[next]) continue; // has cheaper path
from[next] = current;
cost[next] = totalCost;
queue.push(next, totalCost);
}
}
return null;
}
// supplementary function for findPath
function restorePath(exit, start, from) {
const pathCells = [];
let current = exit;
let prev = exit;
while (current !== start) {
pathCells.push(current);
prev = from[current];
current = prev;
}
pathCells.push(current);
return pathCells.reverse();
}

View file

@ -57,6 +57,14 @@ JSON.isValid = str => {
}
};
JSON.safeParse = str => {
try {
return JSON.parse(str);
} catch (e) {
return null;
}
};
function sanitizeId(string) {
if (!string) throw new Error("No string provided");

View file

@ -12,7 +12,7 @@
*
* Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2
*/
const VERSION = "1.106.0";
const VERSION = "1.107.0";
if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function");
{
@ -36,6 +36,7 @@ if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format o
<ul>
<strong>Latest changes:</strong>
<li>Submap and Transform tools rework</li>
<li>Azgaar Bot to answer questions and provide help</li>
<li>Labels: ability to set letter spacing</li>
<li>Zones performance improvement</li>
@ -46,7 +47,6 @@ if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format o
<li>Configurable longitude</li>
<li>Preview villages map</li>
<li>Ability to render ocean heightmap</li>
<li>Scale bar styling features</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>
@ -58,7 +58,7 @@ if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format o
width: "28em",
position: {my: "center center-4em", at: "center", of: "svg"},
buttons: {
"Cleanup data": () => cleanupData(),
"Clear cache": () => cleanupData(),
"Don't show again": function () {
$(this).dialog("close");
localStorage.setItem("version", VERSION);