This commit is contained in:
Azgaar 2019-04-21 21:55:13 +03:00
parent 707913f630
commit 680044ddd6
65 changed files with 14257 additions and 13020 deletions

View file

@ -209,4 +209,18 @@
.icon-smooth:before {font-weight: bold; content: ''; } .icon-smooth:before {font-weight: bold; content: ''; }
.icon-disrupt:before {font-weight: bold; content: '෴'; } .icon-disrupt:before {font-weight: bold; content: '෴'; }
.icon-if:before {font-style: italic; font-weight: bold; content: 'if'; } .icon-if:before {font-style: italic; font-weight: bold; content: 'if'; }
.icon-arc:before {font-weight: bold; font-size: 1.2em; content: '⌒'; }
.icon-ruler:before {content: 'I'; }
.icon-curve:before {content: 'C'; }
.icon-area:before {content: 'O'; }
.icon-ruler:before,
.icon-curve:before,
.icon-area:before {
font-size: 1.5em;
padding: 0;
writing-mode: tb-rl;
margin-left: 1px;
width: 10px;
font-family: monospace;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 852 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

View file

@ -0,0 +1,12 @@
All used textures should be distrubuted under free license
marble.jpg https://www.rawpixel.com/image/327647/closeup-marble-textured-background by Teddy Rawpixel
marble-blue.jpg https://www.pexels.com/photo/gray-and-blue-surface-988871/
timbercut.jpg https://www.pexels.com/photo/brown-close-up-hd-wallpaper-surface-172289
antique.jpg https://www.pexels.com/photo/abstract-ancient-antique-art-235985/
pergamena.jpg https://www.freepik.com/free-photo/old-paper-texture-background_1007802.htm
stone.jpg https://www.wildtextures.com/free-textures/grungy-yet-elegant-elevation-stone-ii
mars.jpg https://www.solarsystemscope.com/textures/download/2k_mars.jpg
mercury.jpg https://www.solarsystemscope.com/textures/download/2k_mercury.jpg
mauritania.jpg https://go.nasa.gov/2Ugu9M8 NASA Worldview (Jan 24, 2019)
iran.jpg https://go.nasa.gov/2FNmiT5 NASA Worldview (Jan 24, 2019)
spain.jpg https://go.nasa.gov/2FMDPuu NASA Worldview (Jan 24, 2019)

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

1284
index.css

File diff suppressed because one or more lines are too long

2367
index.html

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
libs/d3.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
libs/d3.v4.min.js vendored

File diff suppressed because one or more lines are too long

1
libs/delaunator.min.js vendored Normal file
View file

@ -0,0 +1 @@
!function(t,i){"object"==typeof exports&&"undefined"!=typeof module?module.exports=i():"function"==typeof define&&define.amd?define(i):t.Delaunator=i()}(this,function(){"use strict";var t=Math.pow(2,-52),i=function(i){var n=i.length>>1;if(n>0&&"number"!=typeof i[0])throw new Error("Expected coords to contain numbers.");this.coords=i;var s=2*n-5,l=this.triangles=new Uint32Array(3*s),o=this.halfedges=new Int32Array(3*s);this._hashSize=Math.ceil(Math.sqrt(n));for(var f=this.hullPrev=new Uint32Array(n),u=this.hullNext=new Uint32Array(n),v=this.hullTri=new Uint32Array(n),d=new Int32Array(this._hashSize).fill(-1),_=new Uint32Array(n),y=1/0,g=1/0,c=-1/0,w=-1/0,p=0;p<n;p++){var b=i[2*p],x=i[2*p+1];b<y&&(y=b),x<g&&(g=x),b>c&&(c=b),x>w&&(w=x),_[p]=p}for(var z,S,k,A=(y+c)/2,T=(g+w)/2,M=1/0,K=0;K<n;K++){var m=r(A,T,i[2*K],i[2*K+1]);m<M&&(z=K,M=m)}var U=i[2*z],L=i[2*z+1];M=1/0;for(var N=0;N<n;N++)if(N!==z){var E=r(U,L,i[2*N],i[2*N+1]);E<M&&E>0&&(S=N,M=E)}for(var D=i[2*S],F=i[2*S+1],I=1/0,P=0;P<n;P++)if(P!==z&&P!==S){var j=h(U,L,D,F,i[2*P],i[2*P+1]);j<I&&(k=P,I=j)}var q=i[2*k],B=i[2*k+1];if(I===1/0)throw new Error("No Delaunay triangulation exists for this input.");if(a(U,L,D,F,q,B)){var C=S,G=D,H=F;S=k,D=q,F=B,k=C,q=G,B=H}var J=function(t,i,r,a,h,e){var n=r-t,s=a-i,l=h-t,o=e-i,f=n*n+s*s,u=l*l+o*o,v=.5/(n*o-s*l);return{x:t+(o*f-s*u)*v,y:i+(n*u-l*f)*v}}(U,L,D,F,q,B);this._cx=J.x,this._cy=J.y;for(var O=new Float64Array(n),Q=0;Q<n;Q++)O[Q]=r(i[2*Q],i[2*Q+1],J.x,J.y);!function t(i,r,a,h){if(h-a<=20)for(var n=a+1;n<=h;n++){for(var s=i[n],l=r[s],o=n-1;o>=a&&r[i[o]]>l;)i[o+1]=i[o--];i[o+1]=s}else{var f=a+h>>1,u=a+1,v=h;e(i,f,u),r[i[a]]>r[i[h]]&&e(i,a,h),r[i[u]]>r[i[h]]&&e(i,u,h),r[i[a]]>r[i[u]]&&e(i,a,u);for(var d=i[u],_=r[d];;){do{u++}while(r[i[u]]<_);do{v--}while(r[i[v]]>_);if(v<u)break;e(i,u,v)}i[a+1]=i[v],i[v]=d,h-u+1>=v-a?(t(i,r,u,h),t(i,r,a,v-1)):(t(i,r,a,v-1),t(i,r,u,h))}}(_,O,0,n-1),this.hullStart=z;var R=3;u[z]=f[k]=S,u[S]=f[z]=k,u[k]=f[S]=z,v[z]=0,v[S]=1,v[k]=2,d[this._hashKey(U,L)]=z,d[this._hashKey(D,F)]=S,d[this._hashKey(q,B)]=k,this.trianglesLen=0,this._addTriangle(z,S,k,-1,-1,-1);for(var V=0,W=void 0,X=void 0;V<_.length;V++){var Y=_[V],Z=i[2*Y],$=i[2*Y+1];if(!(V>0&&Math.abs(Z-W)<=t&&Math.abs($-X)<=t)&&(W=Z,X=$,Y!==z&&Y!==S&&Y!==k)){for(var tt=0,it=0,rt=this._hashKey(Z,$);it<this._hashSize&&(-1===(tt=d[(rt+it)%this._hashSize])||tt===u[tt]);it++);for(var at=tt=f[tt],ht=void 0;ht=u[at],!a(Z,$,i[2*at],i[2*at+1],i[2*ht],i[2*ht+1]);)if((at=ht)===tt){at=-1;break}if(-1!==at){var et=this._addTriangle(at,Y,u[at],-1,-1,v[at]);v[Y]=this._legalize(et+2),v[at]=et,R++;for(var nt=u[at];ht=u[nt],a(Z,$,i[2*nt],i[2*nt+1],i[2*ht],i[2*ht+1]);)et=this._addTriangle(nt,Y,ht,v[Y],-1,v[nt]),v[Y]=this._legalize(et+2),u[nt]=nt,R--,nt=ht;if(at===tt)for(;a(Z,$,i[2*(ht=f[at])],i[2*ht+1],i[2*at],i[2*at+1]);)et=this._addTriangle(ht,Y,at,-1,v[at],v[ht]),this._legalize(et+2),v[ht]=et,u[at]=at,R--,at=ht;this.hullStart=f[Y]=at,u[at]=f[nt]=Y,u[Y]=nt,d[this._hashKey(Z,$)]=Y,d[this._hashKey(i[2*at],i[2*at+1])]=at}}}this.hull=new Uint32Array(R);for(var st=0,lt=this.hullStart;st<R;st++)this.hull[st]=lt,lt=u[lt];this.hullPrev=this.hullNext=this.hullTri=null,this.triangles=l.subarray(0,this.trianglesLen),this.halfedges=o.subarray(0,this.trianglesLen)};function r(t,i,r,a){var h=t-r,e=i-a;return h*h+e*e}function a(t,i,r,a,h,e){return(a-i)*(h-r)-(r-t)*(e-a)<0}function h(t,i,r,a,h,e){var n=r-t,s=a-i,l=h-t,o=e-i,f=n*n+s*s,u=l*l+o*o,v=.5/(n*o-s*l),d=(o*f-s*u)*v,_=(n*u-l*f)*v;return d*d+_*_}function e(t,i,r){var a=t[i];t[i]=t[r],t[r]=a}function n(t){return t[0]}function s(t){return t[1]}return i.from=function(t,r,a){void 0===r&&(r=n),void 0===a&&(a=s);for(var h=t.length,e=new Float64Array(2*h),l=0;l<h;l++){var o=t[l];e[2*l]=r(o),e[2*l+1]=a(o)}return new i(e)},i.prototype._hashKey=function(t,i){return Math.floor((r=t-this._cx,a=i-this._cy,h=r/(Math.abs(r)+Math.abs(a)),(a>0?3-h:1+h)/4*this._hashSize))%this._hashSize;var r,a,h},i.prototype._legalize=function(t){var i=this.triangles,r=this.coords,a=this.halfedges,h=a[t],e=t-t%3,n=h-h%3,s=e+(t+1)%3,l=e+(t+2)%3,o=n+(h+2)%3;if(-1===h)return l;var f,u,v,d,_,y,g,c,w,p,b,x,z,S,k,A,T=i[l],M=i[t],K=i[s],m=i[o];if(f=r[2*T],u=r[2*T+1],v=r[2*M],d=r[2*M+1],_=r[2*K],y=r[2*K+1],g=r[2*m],c=r[2*m+1],(w=f-g)*((x=d-c)*(A=(z=_-g)*z+(S=y-c)*S)-(k=(b=v-g)*b+x*x)*S)-(p=u-c)*(b*A-k*z)+(w*w+p*p)*(b*S-x*z)<0){i[t]=m,i[h]=T;var U=a[o];if(-1===U){var L=this.hullStart;do{if(this.hullTri[L]===o){this.hullTri[L]=t;break}L=this.hullNext[L]}while(L!==this.hullStart)}this._link(t,U),this._link(h,a[l]),this._link(l,o);var N=n+(h+1)%3;return this._legalize(t),this._legalize(N)}return l},i.prototype._link=function(t,i){this.halfedges[t]=i,-1!==i&&(this.halfedges[i]=t)},i.prototype._addTriangle=function(t,i,r,a,h,e){var n=this.trianglesLen;return this.triangles[n]=t,this.triangles[n+1]=i,this.triangles[n+2]=r,this._link(n,a),this._link(n+1,h),this._link(n+2,e),this.trianglesLen+=3,n},i});

23
libs/jquery-ui.css vendored
View file

@ -314,13 +314,13 @@ body .ui-dialog {
left: 0; left: 0;
outline: 0; outline: 0;
padding: 0; padding: 0;
font-size: 12px;
background-color: inherit; background-color: inherit;
} }
.ui-dialog .ui-dialog-titlebar { .ui-dialog .ui-dialog-titlebar {
padding: .4em 1em; padding: .4em 1em;
position: relative; position: relative;
font-size: 14px; font-size: 1.2em;
min-width: 150px;
} }
.ui-dialog .ui-dialog-title { .ui-dialog .ui-dialog-title {
float: left; float: left;
@ -330,18 +330,33 @@ body .ui-dialog {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.ui-dialog .ui-dialog-titlebar-close {
.ui-dialog .ui-dialog-titlebar button {
position: absolute; position: absolute;
right: .5em; right: .5em;
top: 53%; top: 53%;
width: 18px; width: 18px;
margin: -10px 0 0 0;
padding: 0; padding: 0;
height: 18px; height: 18px;
color: #ffffff; color: #ffffff;
background: none; background: none;
font-size: 10px; font-size: 10px;
border: 1px solid #c5c5c5;
} }
.ui-dialog .ui-dialog-titlebar button.ui-dialog-titlebar-collapse {
margin: -10px 22px 0 0;
}
.ui-dialog .ui-dialog-titlebar button.ui-dialog-titlebar-close {
margin: -10px 0 0 0;
}
.ui-dialog .ui-dialog-titlebar button:active {
border: 1px solid #5d4651;
color: #5d4651;
}
.ui-dialog .ui-dialog-content { .ui-dialog .ui-dialog-content {
position: relative; position: relative;
border: 0; border: 0;

10
libs/jquery-ui.min.js vendored

File diff suppressed because one or more lines are too long

View file

@ -1,232 +0,0 @@
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.polylabel = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
'use strict';
var Queue = require('tinyqueue');
module.exports = polylabel;
function polylabel(polygon, precision, debug) {
precision = precision || 1.0;
// find the bounding box of the outer ring
var minX, minY, maxX, maxY;
for (var i = 0; i < polygon[0].length; i++) {
var p = polygon[0][i];
if (!i || p[0] < minX) minX = p[0];
if (!i || p[1] < minY) minY = p[1];
if (!i || p[0] > maxX) maxX = p[0];
if (!i || p[1] > maxY) maxY = p[1];
}
var width = maxX - minX;
var height = maxY - minY;
var cellSize = Math.min(width, height);
var h = cellSize / 2;
// a priority queue of cells in order of their "potential" (max distance to polygon)
var cellQueue = new Queue(null, compareMax);
// cover polygon with initial cells
for (var x = minX; x < maxX; x += cellSize) {
for (var y = minY; y < maxY; y += cellSize) {
cellQueue.push(new Cell(x + h, y + h, h, polygon));
}
}
// take centroid as the first best guess
var bestCell = getCentroidCell(polygon);
var numProbes = cellQueue.length;
while (cellQueue.length) {
// pick the most promising cell from the queue
var cell = cellQueue.pop();
// update the best cell if we found a better one
if (cell.d > bestCell.d) {
bestCell = cell;
if (debug) console.log('found best %d after %d probes', Math.round(1e4 * cell.d) / 1e4, numProbes);
}
// do not drill down further if there's no chance of a better solution
if (cell.max - bestCell.d <= precision) continue;
// split the cell into four cells
h = cell.h / 2;
cellQueue.push(new Cell(cell.x - h, cell.y - h, h, polygon));
cellQueue.push(new Cell(cell.x + h, cell.y - h, h, polygon));
cellQueue.push(new Cell(cell.x - h, cell.y + h, h, polygon));
cellQueue.push(new Cell(cell.x + h, cell.y + h, h, polygon));
numProbes += 4;
}
if (debug) {
console.log('num probes: ' + numProbes);
console.log('best distance: ' + bestCell.d);
}
return [bestCell.x, bestCell.y];
}
function compareMax(a, b) {
return b.max - a.max;
}
function Cell(x, y, h, polygon) {
this.x = x; // cell center x
this.y = y; // cell center y
this.h = h; // half the cell size
this.d = pointToPolygonDist(x, y, polygon); // distance from cell center to polygon
this.max = this.d + this.h * Math.SQRT2; // max distance to polygon within a cell
}
// signed distance from point to polygon outline (negative if point is outside)
function pointToPolygonDist(x, y, polygon) {
var inside = false;
var minDistSq = Infinity;
for (var k = 0; k < polygon.length; k++) {
var ring = polygon[k];
for (var i = 0, len = ring.length, j = len - 1; i < len; j = i++) {
var a = ring[i];
var b = ring[j];
if ((a[1] > y !== b[1] > y) &&
(x < (b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]) + a[0])) inside = !inside;
minDistSq = Math.min(minDistSq, getSegDistSq(x, y, a, b));
}
}
return (inside ? 1 : -1) * Math.sqrt(minDistSq);
}
// get polygon centroid
function getCentroidCell(polygon) {
var area = 0;
var x = 0;
var y = 0;
var points = polygon[0];
for (var i = 0, len = points.length, j = len - 1; i < len; j = i++) {
var a = points[i];
var b = points[j];
var f = a[0] * b[1] - b[0] * a[1];
x += (a[0] + b[0]) * f;
y += (a[1] + b[1]) * f;
area += f * 3;
}
return new Cell(x / area, y / area, 0, polygon);
}
// get squared distance from a point to a segment
function getSegDistSq(px, py, a, b) {
var x = a[0];
var y = a[1];
var dx = b[0] - x;
var dy = b[1] - y;
if (dx !== 0 || dy !== 0) {
var t = ((px - x) * dx + (py - y) * dy) / (dx * dx + dy * dy);
if (t > 1) {
x = b[0];
y = b[1];
} else if (t > 0) {
x += dx * t;
y += dy * t;
}
}
dx = px - x;
dy = py - y;
return dx * dx + dy * dy;
}
},{"tinyqueue":2}],2:[function(require,module,exports){
'use strict';
module.exports = TinyQueue;
function TinyQueue(data, compare) {
if (!(this instanceof TinyQueue)) return new TinyQueue(data, compare);
this.data = data || [];
this.length = this.data.length;
this.compare = compare || defaultCompare;
if (data) for (var i = Math.floor(this.length / 2); i >= 0; i--) this._down(i);
}
function defaultCompare(a, b) {
return a < b ? -1 : a > b ? 1 : 0;
}
TinyQueue.prototype = {
push: function (item) {
this.data.push(item);
this.length++;
this._up(this.length - 1);
},
pop: function () {
var top = this.data[0];
this.data[0] = this.data[this.length - 1];
this.length--;
this.data.pop();
this._down(0);
return top;
},
peek: function () {
return this.data[0];
},
_up: function (pos) {
var data = this.data,
compare = this.compare;
while (pos > 0) {
var parent = Math.floor((pos - 1) / 2);
if (compare(data[pos], data[parent]) < 0) {
swap(data, parent, pos);
pos = parent;
} else break;
}
},
_down: function (pos) {
var data = this.data,
compare = this.compare,
len = this.length;
while (true) {
var left = 2 * pos + 1,
right = left + 1,
min = pos;
if (left < len && compare(data[left], data[min]) < 0) min = left;
if (right < len && compare(data[right], data[min]) < 0) min = right;
if (min === pos) return;
swap(data, min, pos);
pos = min;
}
}
};
function swap(data, i, j) {
var tmp = data[i];
data[i] = data[j];
data[j] = tmp;
}
},{}]},{},[1])(1)
});

View file

@ -1,387 +0,0 @@
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.PriorityQueue = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(_dereq_,module,exports){
var AbstractPriorityQueue, ArrayStrategy, BHeapStrategy, BinaryHeapStrategy, PriorityQueue,
extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
hasProp = {}.hasOwnProperty;
AbstractPriorityQueue = _dereq_('./PriorityQueue/AbstractPriorityQueue');
ArrayStrategy = _dereq_('./PriorityQueue/ArrayStrategy');
BinaryHeapStrategy = _dereq_('./PriorityQueue/BinaryHeapStrategy');
BHeapStrategy = _dereq_('./PriorityQueue/BHeapStrategy');
PriorityQueue = (function(superClass) {
extend(PriorityQueue, superClass);
function PriorityQueue(options) {
options || (options = {});
options.strategy || (options.strategy = BinaryHeapStrategy);
options.comparator || (options.comparator = function(a, b) {
return (a || 0) - (b || 0);
});
PriorityQueue.__super__.constructor.call(this, options);
}
return PriorityQueue;
})(AbstractPriorityQueue);
PriorityQueue.ArrayStrategy = ArrayStrategy;
PriorityQueue.BinaryHeapStrategy = BinaryHeapStrategy;
PriorityQueue.BHeapStrategy = BHeapStrategy;
module.exports = PriorityQueue;
},{"./PriorityQueue/AbstractPriorityQueue":2,"./PriorityQueue/ArrayStrategy":3,"./PriorityQueue/BHeapStrategy":4,"./PriorityQueue/BinaryHeapStrategy":5}],2:[function(_dereq_,module,exports){
var AbstractPriorityQueue;
module.exports = AbstractPriorityQueue = (function() {
function AbstractPriorityQueue(options) {
var ref;
if ((options != null ? options.strategy : void 0) == null) {
throw 'Must pass options.strategy, a strategy';
}
if ((options != null ? options.comparator : void 0) == null) {
throw 'Must pass options.comparator, a comparator';
}
this.priv = new options.strategy(options);
this.length = (options != null ? (ref = options.initialValues) != null ? ref.length : void 0 : void 0) || 0;
}
AbstractPriorityQueue.prototype.queue = function(value) {
this.length++;
this.priv.queue(value);
return void 0;
};
AbstractPriorityQueue.prototype.dequeue = function(value) {
if (!this.length) {
throw 'Empty queue';
}
this.length--;
return this.priv.dequeue();
};
AbstractPriorityQueue.prototype.peek = function(value) {
if (!this.length) {
throw 'Empty queue';
}
return this.priv.peek();
};
AbstractPriorityQueue.prototype.clear = function() {
this.length = 0;
return this.priv.clear();
};
return AbstractPriorityQueue;
})();
},{}],3:[function(_dereq_,module,exports){
var ArrayStrategy, binarySearchForIndexReversed;
binarySearchForIndexReversed = function(array, value, comparator) {
var high, low, mid;
low = 0;
high = array.length;
while (low < high) {
mid = (low + high) >>> 1;
if (comparator(array[mid], value) >= 0) {
low = mid + 1;
} else {
high = mid;
}
}
return low;
};
module.exports = ArrayStrategy = (function() {
function ArrayStrategy(options) {
var ref;
this.options = options;
this.comparator = this.options.comparator;
this.data = ((ref = this.options.initialValues) != null ? ref.slice(0) : void 0) || [];
this.data.sort(this.comparator).reverse();
}
ArrayStrategy.prototype.queue = function(value) {
var pos;
pos = binarySearchForIndexReversed(this.data, value, this.comparator);
this.data.splice(pos, 0, value);
return void 0;
};
ArrayStrategy.prototype.dequeue = function() {
return this.data.pop();
};
ArrayStrategy.prototype.peek = function() {
return this.data[this.data.length - 1];
};
ArrayStrategy.prototype.clear = function() {
this.data.length = 0;
return void 0;
};
return ArrayStrategy;
})();
},{}],4:[function(_dereq_,module,exports){
var BHeapStrategy;
module.exports = BHeapStrategy = (function() {
function BHeapStrategy(options) {
var arr, i, j, k, len, ref, ref1, shift, value;
this.comparator = (options != null ? options.comparator : void 0) || function(a, b) {
return a - b;
};
this.pageSize = (options != null ? options.pageSize : void 0) || 512;
this.length = 0;
shift = 0;
while ((1 << shift) < this.pageSize) {
shift += 1;
}
if (1 << shift !== this.pageSize) {
throw 'pageSize must be a power of two';
}
this._shift = shift;
this._emptyMemoryPageTemplate = arr = [];
for (i = j = 0, ref = this.pageSize; 0 <= ref ? j < ref : j > ref; i = 0 <= ref ? ++j : --j) {
arr.push(null);
}
this._memory = [];
this._mask = this.pageSize - 1;
if (options.initialValues) {
ref1 = options.initialValues;
for (k = 0, len = ref1.length; k < len; k++) {
value = ref1[k];
this.queue(value);
}
}
}
BHeapStrategy.prototype.queue = function(value) {
this.length += 1;
this._write(this.length, value);
this._bubbleUp(this.length, value);
return void 0;
};
BHeapStrategy.prototype.dequeue = function() {
var ret, val;
ret = this._read(1);
val = this._read(this.length);
this.length -= 1;
if (this.length > 0) {
this._write(1, val);
this._bubbleDown(1, val);
}
return ret;
};
BHeapStrategy.prototype.peek = function() {
return this._read(1);
};
BHeapStrategy.prototype.clear = function() {
this.length = 0;
this._memory.length = 0;
return void 0;
};
BHeapStrategy.prototype._write = function(index, value) {
var page;
page = index >> this._shift;
while (page >= this._memory.length) {
this._memory.push(this._emptyMemoryPageTemplate.slice(0));
}
return this._memory[page][index & this._mask] = value;
};
BHeapStrategy.prototype._read = function(index) {
return this._memory[index >> this._shift][index & this._mask];
};
BHeapStrategy.prototype._bubbleUp = function(index, value) {
var compare, indexInPage, parentIndex, parentValue;
compare = this.comparator;
while (index > 1) {
indexInPage = index & this._mask;
if (index < this.pageSize || indexInPage > 3) {
parentIndex = (index & ~this._mask) | (indexInPage >> 1);
} else if (indexInPage < 2) {
parentIndex = (index - this.pageSize) >> this._shift;
parentIndex += parentIndex & ~(this._mask >> 1);
parentIndex |= this.pageSize >> 1;
} else {
parentIndex = index - 2;
}
parentValue = this._read(parentIndex);
if (compare(parentValue, value) < 0) {
break;
}
this._write(parentIndex, value);
this._write(index, parentValue);
index = parentIndex;
}
return void 0;
};
BHeapStrategy.prototype._bubbleDown = function(index, value) {
var childIndex1, childIndex2, childValue1, childValue2, compare;
compare = this.comparator;
while (index < this.length) {
if (index > this._mask && !(index & (this._mask - 1))) {
childIndex1 = childIndex2 = index + 2;
} else if (index & (this.pageSize >> 1)) {
childIndex1 = (index & ~this._mask) >> 1;
childIndex1 |= index & (this._mask >> 1);
childIndex1 = (childIndex1 + 1) << this._shift;
childIndex2 = childIndex1 + 1;
} else {
childIndex1 = index + (index & this._mask);
childIndex2 = childIndex1 + 1;
}
if (childIndex1 !== childIndex2 && childIndex2 <= this.length) {
childValue1 = this._read(childIndex1);
childValue2 = this._read(childIndex2);
if (compare(childValue1, value) < 0 && compare(childValue1, childValue2) <= 0) {
this._write(childIndex1, value);
this._write(index, childValue1);
index = childIndex1;
} else if (compare(childValue2, value) < 0) {
this._write(childIndex2, value);
this._write(index, childValue2);
index = childIndex2;
} else {
break;
}
} else if (childIndex1 <= this.length) {
childValue1 = this._read(childIndex1);
if (compare(childValue1, value) < 0) {
this._write(childIndex1, value);
this._write(index, childValue1);
index = childIndex1;
} else {
break;
}
} else {
break;
}
}
return void 0;
};
return BHeapStrategy;
})();
},{}],5:[function(_dereq_,module,exports){
var BinaryHeapStrategy;
module.exports = BinaryHeapStrategy = (function() {
function BinaryHeapStrategy(options) {
var ref;
this.comparator = (options != null ? options.comparator : void 0) || function(a, b) {
return a - b;
};
this.length = 0;
this.data = ((ref = options.initialValues) != null ? ref.slice(0) : void 0) || [];
this._heapify();
}
BinaryHeapStrategy.prototype._heapify = function() {
var i, j, ref;
if (this.data.length > 0) {
for (i = j = 1, ref = this.data.length; 1 <= ref ? j < ref : j > ref; i = 1 <= ref ? ++j : --j) {
this._bubbleUp(i);
}
}
return void 0;
};
BinaryHeapStrategy.prototype.queue = function(value) {
this.data.push(value);
this._bubbleUp(this.data.length - 1);
return void 0;
};
BinaryHeapStrategy.prototype.dequeue = function() {
var last, ret;
ret = this.data[0];
last = this.data.pop();
if (this.data.length > 0) {
this.data[0] = last;
this._bubbleDown(0);
}
return ret;
};
BinaryHeapStrategy.prototype.peek = function() {
return this.data[0];
};
BinaryHeapStrategy.prototype.clear = function() {
this.length = 0;
this.data.length = 0;
return void 0;
};
BinaryHeapStrategy.prototype._bubbleUp = function(pos) {
var parent, x;
while (pos > 0) {
parent = (pos - 1) >>> 1;
if (this.comparator(this.data[pos], this.data[parent]) < 0) {
x = this.data[parent];
this.data[parent] = this.data[pos];
this.data[pos] = x;
pos = parent;
} else {
break;
}
}
return void 0;
};
BinaryHeapStrategy.prototype._bubbleDown = function(pos) {
var last, left, minIndex, right, x;
last = this.data.length - 1;
while (true) {
left = (pos << 1) + 1;
right = left + 1;
minIndex = pos;
if (left <= last && this.comparator(this.data[left], this.data[minIndex]) < 0) {
minIndex = left;
}
if (right <= last && this.comparator(this.data[right], this.data[minIndex]) < 0) {
minIndex = right;
}
if (minIndex !== pos) {
x = this.data[minIndex];
this.data[minIndex] = this.data[pos];
this.data[pos] = x;
pos = minIndex;
} else {
break;
}
}
return void 0;
};
return BinaryHeapStrategy;
})();
},{}]},{},[1])(1)
});

View file

@ -1,436 +0,0 @@
// Forked from color-thief.js Copyright 2011 Lokesh Dhakar under MIT license
// var pixelArray = [[190,197,190], [202,204,200], [207,214,210]]; // ... etc;
// var cmap = MMCQ.quantize(pixelArray, colorCount);
// var palette = cmap ? cmap.palette() : null;
// Protovis. Copyright 2010 Stanford Visualization Group (http://mbostock.github.com/protovis/)
// Licensed under the BSD License: http://www.opensource.org/licenses/bsd-license.php
if (!pv) {
var pv = {
map: function(array, f) {
var o = {};
return f ? array.map(function(d, i) { o.index = i; return f.call(o, d); }) : array.slice();
},
naturalOrder: function(a, b) {
return (a < b) ? -1 : ((a > b) ? 1 : 0);
},
sum: function(array, f) {
var o = {};
return array.reduce(f ? function(p, d, i) { o.index = i; return p + f.call(o, d); } : function(p, d) { return p + d; }, 0);
},
max: function(array, f) {
return Math.max.apply(null, f ? pv.map(array, f) : array);
}
};
}
// MMCQ (Modified median cut quantization). Algorithm from the Leptonica library, modified by Nick Rabinowitz
// quantize.js Copyright 2008 Nick Rabinowitz under MIT license
var MMCQ = (function() {
// private constants
var sigbits = 5,
rshift = 8 - sigbits,
maxIterations = 1000,
fractByPopulations = 0.75;
// get reduced-space color index for a pixel
function getColorIndex(r, g, b) {
return (r << (2 * sigbits)) + (g << sigbits) + b;
}
// Simple priority queue
function PQueue(comparator) {
var contents = [],
sorted = false;
function sort() {
contents.sort(comparator);
sorted = true;
}
return {
push: function(o) {
contents.push(o);
sorted = false;
},
peek: function(index) {
if (!sorted) sort();
if (index===undefined) index = contents.length - 1;
return contents[index];
},
pop: function() {
if (!sorted) sort();
return contents.pop();
},
size: function() {
return contents.length;
},
map: function(f) {
return contents.map(f);
},
debug: function() {
if (!sorted) sort();
return contents;
}
};
}
// 3d color space box
function VBox(r1, r2, g1, g2, b1, b2, histo) {
var vbox = this;
vbox.r1 = r1;
vbox.r2 = r2;
vbox.g1 = g1;
vbox.g2 = g2;
vbox.b1 = b1;
vbox.b2 = b2;
vbox.histo = histo;
}
VBox.prototype = {
volume: function(force) {
var vbox = this;
if (!vbox._volume || force) {
vbox._volume = ((vbox.r2 - vbox.r1 + 1) * (vbox.g2 - vbox.g1 + 1) * (vbox.b2 - vbox.b1 + 1));
}
return vbox._volume;
},
count: function(force) {
var vbox = this,
histo = vbox.histo;
if (!vbox._count_set || force) {
var npix = 0,
index, i, j, k;
for (i = vbox.r1; i <= vbox.r2; i++) {
for (j = vbox.g1; j <= vbox.g2; j++) {
for (k = vbox.b1; k <= vbox.b2; k++) {
index = getColorIndex(i,j,k);
npix += (histo[index] || 0);
}
}
}
vbox._count = npix;
vbox._count_set = true;
}
return vbox._count;
},
copy: function() {
var vbox = this;
return new VBox(vbox.r1, vbox.r2, vbox.g1, vbox.g2, vbox.b1, vbox.b2, vbox.histo);
},
avg: function(force) {
var vbox = this,
histo = vbox.histo;
if (!vbox._avg || force) {
var ntot = 0,
mult = 1 << (8 - sigbits),
rsum = 0,
gsum = 0,
bsum = 0,
hval,
i, j, k, histoindex;
for (i = vbox.r1; i <= vbox.r2; i++) {
for (j = vbox.g1; j <= vbox.g2; j++) {
for (k = vbox.b1; k <= vbox.b2; k++) {
histoindex = getColorIndex(i,j,k);
hval = histo[histoindex] || 0;
ntot += hval;
rsum += (hval * (i + 0.5) * mult);
gsum += (hval * (j + 0.5) * mult);
bsum += (hval * (k + 0.5) * mult);
}
}
}
if (ntot) {
vbox._avg = [~~(rsum/ntot), ~~(gsum/ntot), ~~(bsum/ntot)];
} else {
// console.log('empty box');
vbox._avg = [
~~(mult * (vbox.r1 + vbox.r2 + 1) / 2),
~~(mult * (vbox.g1 + vbox.g2 + 1) / 2),
~~(mult * (vbox.b1 + vbox.b2 + 1) / 2)
];
}
}
return vbox._avg;
},
contains: function(pixel) {
var vbox = this,
rval = pixel[0] >> rshift;
gval = pixel[1] >> rshift;
bval = pixel[2] >> rshift;
return (rval >= vbox.r1 && rval <= vbox.r2 &&
gval >= vbox.g1 && gval <= vbox.g2 &&
bval >= vbox.b1 && bval <= vbox.b2);
}
};
// Color map
function CMap() {
this.vboxes = new PQueue(function(a,b) {
return pv.naturalOrder(
a.vbox.count()*a.vbox.volume(),
b.vbox.count()*b.vbox.volume()
);
});
}
CMap.prototype = {
push: function(vbox) {
this.vboxes.push({
vbox: vbox,
color: vbox.avg()
});
},
palette: function() {
return this.vboxes.map(function(vb) { return vb.color; });
},
size: function() {
return this.vboxes.size();
},
map: function(color) {
var vboxes = this.vboxes;
for (var i=0; i<vboxes.size(); i++) {
if (vboxes.peek(i).vbox.contains(color)) {
return vboxes.peek(i).color;
}
}
return this.nearest(color);
},
nearest: function(color) {
var vboxes = this.vboxes,
d1, d2, pColor;
for (var i=0; i<vboxes.size(); i++) {
d2 = Math.sqrt(
Math.pow(color[0] - vboxes.peek(i).color[0], 2) +
Math.pow(color[1] - vboxes.peek(i).color[1], 2) +
Math.pow(color[2] - vboxes.peek(i).color[2], 2)
);
if (d2 < d1 || d1 === undefined) {
d1 = d2;
pColor = vboxes.peek(i).color;
}
}
return pColor;
},
forcebw: function() {
// XXX: won't work yet
var vboxes = this.vboxes;
vboxes.sort(function(a,b) { return pv.naturalOrder(pv.sum(a.color), pv.sum(b.color));});
// force darkest color to black if everything < 5
var lowest = vboxes[0].color;
if (lowest[0] < 5 && lowest[1] < 5 && lowest[2] < 5)
vboxes[0].color = [0,0,0];
// force lightest color to white if everything > 251
var idx = vboxes.length-1,
highest = vboxes[idx].color;
if (highest[0] > 251 && highest[1] > 251 && highest[2] > 251)
vboxes[idx].color = [255,255,255];
}
};
// histo (1-d array, giving the number of pixels in
// each quantized region of color space), or null on error
function getHisto(pixels) {
var histosize = 1 << (3 * sigbits),
histo = new Array(histosize),
index, rval, gval, bval;
pixels.forEach(function(pixel) {
rval = pixel[0] >> rshift;
gval = pixel[1] >> rshift;
bval = pixel[2] >> rshift;
index = getColorIndex(rval, gval, bval);
histo[index] = (histo[index] || 0) + 1;
});
return histo;
}
function vboxFromPixels(pixels, histo) {
var rmin=1000000, rmax=0,
gmin=1000000, gmax=0,
bmin=1000000, bmax=0,
rval, gval, bval;
// find min/max
pixels.forEach(function(pixel) {
rval = pixel[0] >> rshift;
gval = pixel[1] >> rshift;
bval = pixel[2] >> rshift;
if (rval < rmin) rmin = rval;
else if (rval > rmax) rmax = rval;
if (gval < gmin) gmin = gval;
else if (gval > gmax) gmax = gval;
if (bval < bmin) bmin = bval;
else if (bval > bmax) bmax = bval;
});
return new VBox(rmin, rmax, gmin, gmax, bmin, bmax, histo);
}
function medianCutApply(histo, vbox) {
if (!vbox.count()) return;
var rw = vbox.r2 - vbox.r1 + 1,
gw = vbox.g2 - vbox.g1 + 1,
bw = vbox.b2 - vbox.b1 + 1,
maxw = pv.max([rw, gw, bw]);
// only one pixel, no split
if (vbox.count() == 1) {
return [vbox.copy()];
}
/* Find the partial sum arrays along the selected axis. */
var total = 0,
partialsum = [],
lookaheadsum = [],
i, j, k, sum, index;
if (maxw == rw) {
for (i = vbox.r1; i <= vbox.r2; i++) {
sum = 0;
for (j = vbox.g1; j <= vbox.g2; j++) {
for (k = vbox.b1; k <= vbox.b2; k++) {
index = getColorIndex(i,j,k);
sum += (histo[index] || 0);
}
}
total += sum;
partialsum[i] = total;
}
}
else if (maxw == gw) {
for (i = vbox.g1; i <= vbox.g2; i++) {
sum = 0;
for (j = vbox.r1; j <= vbox.r2; j++) {
for (k = vbox.b1; k <= vbox.b2; k++) {
index = getColorIndex(j,i,k);
sum += (histo[index] || 0);
}
}
total += sum;
partialsum[i] = total;
}
}
else { /* maxw == bw */
for (i = vbox.b1; i <= vbox.b2; i++) {
sum = 0;
for (j = vbox.r1; j <= vbox.r2; j++) {
for (k = vbox.g1; k <= vbox.g2; k++) {
index = getColorIndex(j,k,i);
sum += (histo[index] || 0);
}
}
total += sum;
partialsum[i] = total;
}
}
partialsum.forEach(function(d,i) {
lookaheadsum[i] = total-d;
});
function doCut(color) {
var dim1 = color + '1',
dim2 = color + '2',
left, right, vbox1, vbox2, d2, count2=0;
for (i = vbox[dim1]; i <= vbox[dim2]; i++) {
if (partialsum[i] > total / 2) {
vbox1 = vbox.copy();
vbox2 = vbox.copy();
left = i - vbox[dim1];
right = vbox[dim2] - i;
if (left <= right)
d2 = Math.min(vbox[dim2] - 1, ~~(i + right / 2));
else d2 = Math.max(vbox[dim1], ~~(i - 1 - left / 2));
// avoid 0-count boxes
while (!partialsum[d2]) d2++;
count2 = lookaheadsum[d2];
while (!count2 && partialsum[d2-1]) count2 = lookaheadsum[--d2];
// set dimensions
vbox1[dim2] = d2;
vbox2[dim1] = vbox1[dim2] + 1;
// console.log('vbox counts:', vbox.count(), vbox1.count(), vbox2.count());
return [vbox1, vbox2];
}
}
}
// determine the cut planes
return maxw == rw ? doCut('r') :
maxw == gw ? doCut('g') :
doCut('b');
}
function quantize(pixels, maxcolors) {
maxcolors++;
if (!pixels.length || maxcolors < 2 || maxcolors > 256) {return false;}
// XXX: check color content and convert to grayscale if insufficient
var histo = getHisto(pixels),
histosize = 1 << (3 * sigbits);
// check that we aren't below maxcolors already
var nColors = 0;
histo.forEach(function() { nColors++; });
if (nColors <= maxcolors) {
// XXX: generate the new colors from the histo and return
}
// get the beginning vbox from the colors
var vbox = vboxFromPixels(pixels, histo),
pq = new PQueue(function(a,b) { return pv.naturalOrder(a.count(), b.count()); });
pq.push(vbox);
// inner function to do the iteration
function iter(lh, target) {
var ncolors = 1,
niters = 0,
vbox;
while (niters < maxIterations) {
vbox = lh.pop();
if (!vbox.count()) { /* just put it back */
lh.push(vbox);
niters++;
continue;
}
// do the cut
var vboxes = medianCutApply(histo, vbox),
vbox1 = vboxes[0],
vbox2 = vboxes[1];
if (!vbox1) {
// console.log("vbox1 not defined; shouldn't happen!");
return;
}
lh.push(vbox1);
if (vbox2) { /* vbox2 can be null */
lh.push(vbox2);
ncolors++;
}
if (ncolors >= target) return;
if (niters++ > maxIterations) {
// console.log("infinite loop; perhaps too few pixels!");
return;
}
}
}
// first set of colors, sorted by population
iter(pq, fractByPopulations * maxcolors);
// Re-sort by the product of pixel occupancy times the size in color space.
var pq2 = new PQueue(function(a,b) {
return pv.naturalOrder(a.count()*a.volume(), b.count()*b.volume());
});
while (pq.size()) {
pq2.push(pq.pop());
}
// next set - generate the median cuts using the (npix * vol) sorting.
iter(pq2, maxcolors - pq2.size());
// calculate the actual colors
var cmap = new CMap();
while (pq2.size()) {cmap.push(pq2.pop());}
return cmap;
}
return {
quantize: quantize
};
})();

1167
main.js Normal file

File diff suppressed because it is too large Load diff

486
modules/burgs-and-states.js Normal file
View file

@ -0,0 +1,486 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global.BurgsAndStates = factory());
}(this, (function () { 'use strict';
const generate = function() {
console.time("generateBurgsAndStates");
const cells = pack.cells, vertices = pack.vertices, features = pack.features, cultures = pack.cultures, n = cells.i.length;
cells.burg = new Uint16Array(n); // cell burg
cells.road = new Uint16Array(n); // cell road power
const burgs = pack.burgs = placeCapitals();
pack.states = createStates();
const capitalRoutes = Routes.getRoads();
placeTowns();
const townRoutes = Routes.getTrails();
specifyBurgs();
const oceanRoutes = Routes.getSearoutes();
expandStates();
normalizeStates();
Routes.draw(capitalRoutes, townRoutes, oceanRoutes);
drawBurgsWithLabels();
function placeCapitals() {
console.time('placeCapitals');
let count = +regionsInput.value;
let burgs = [0];
const score = new Int16Array(cells.s.map(s => s * Math.random())); // cell score for capitals placement
const sorted = cells.i.filter(i => score[i] > 0 && cells.culture[i]).sort((a, b) => score[b] - score[a]); // filtered and sorted array of indexes
if (sorted.length < count * 10) {
count = Math.floor(sorted.length / 10);
if (!count) {
console.error(`There is no populated cells. Cannot generate states`);
return burgs;
} else {
console.error(`Not enought populated cells (${sorted.length}). Will generate only ${count} states`);
}
}
let burgsTree = d3.quadtree();
let spacing = (graphWidth + graphHeight) / 2 / count; // min distance between capitals
for (let i = 0; burgs.length <= count; i++) {
const cell = sorted[i], x = cells.p[cell][0], y = cells.p[cell][1];
if (burgsTree.find(x, y, spacing) === undefined) {
burgs.push({cell, x, y});
burgsTree.add([x, y]);
}
if (i === sorted.length - 1) {
console.error("Cannot place capitals with current spacing. Trying again with reduced spacing");
burgsTree = d3.quadtree();
i = -1, burgs = [0], spacing /= 1.2;
}
}
burgs[0] = burgsTree;
console.timeEnd('placeCapitals');
return burgs;
}
// For each capital create a state
function createStates() {
console.time('createStates');
const states = [{i:0, name: "Neutrals"}];
const colors = getColors(burgs.length-1);
burgs.forEach(function(b, i) {
if (!i) return; // skip first element
// burgs data
b.i = b.state = i;
b.culture = cells.culture[b.cell];
const base = cultures[b.culture].base;
const min = nameBases[base].min-1;
const max = Math.max(nameBases[base].max-2, min);
b.name = Names.getCulture(b.culture, min, max, "", 0);
b.feature = cells.f[b.cell];
b.capital = true;
// states data
const expansionism = rn(Math.random() * powerInput.value / 2 + 1, 1);
const basename = b.name.length < 9 && b.cell%5 === 0 ? b.name : Names.getCulture(b.culture, min, 6, "", 0);
const name = Names.getState(basename, b.culture);
const type = cultures[b.culture].type;
states.push({i, color: colors[i-1], name, expansionism, capital: i, type, center: b.cell, culture: b.culture});
cells.burg[b.cell] = i;
});
console.timeEnd('createStates');
return states;
}
// place secondary settlements based on geo and economical evaluation
function placeTowns() {
console.time('placeTowns');
const score = new Int16Array(cells.s.map(s => s * Math.random())); // cell score for towns placement
const sorted = cells.i.filter(i => score[i] > 0 && cells.culture[i]).sort((a, b) => score[b] - score[a]); // filtered and sorted array of indexes
// burgs number depends on ratio between populated and all cells and burgsDensity input (expected mean ~300))
const burgsCount = rn(sorted.length / grid.points.length * manorsInput.value * 1000);
const spacing = (graphWidth + graphHeight) * 9 / burgsCount; // base min distance between towns
const burgsTree = burgs[0];
for (let i = 0; burgs.length <= burgsCount && i < sorted.length; i++) {
const id = sorted[i], x = cells.p[id][0], y = cells.p[id][1];
const s = spacing * Math.random() + 0.5; // randomize to make the placement not uniform
if (burgsTree.find(x, y, s) !== undefined) continue; // to close to existing burg
const burg = burgs.length;
const culture = cells.culture[id];
const name = Names.getCulture(culture);
const feature = cells.f[id];
burgs.push({cell: id, x, y, state: 0, i: burg, culture, name, capital: false, feature});
burgsTree.add([x, y]);
cells.burg[id] = burg;
}
if (burgs.length <= burgsCount) console.error(`Cannot place all burgs. Requested ${burgsCount}, placed ${burgs.length-1}`);
//const min = d3.min(score.filter(s => s)), max = d3.max(score);
//terrs.selectAll("polygon").data(sorted).enter().append("polygon").attr("points", d => getPackPolygon(d)).attr("fill", d => color(1 - normalize(score[d], min, max)));
//labels.selectAll("text").data(sorted).enter().append("text").attr("x", d => cells.p[d][0]).attr("y", d => cells.p[d][1]).text(d => score[d]).attr("font-size", 2);
burgs[0] = {name:undefined};
console.timeEnd('placeTowns');
}
// define burg coordinates and define details
function specifyBurgs() {
console.time("specifyBurgs");
for (const b of burgs) {
if (!b.i) continue;
const i = b.cell;
// asign port status: capital with any harbor and towns with good harbors
const port = (b.capital && cells.harbor[i]) || cells.harbor[i] === 1;
b.port = port ? cells.f[cells.haven[i]] : 0; // port is defined by feature id it lays on
// define burg population (keep urbanization at about 10% rate)
b.population = rn(Math.max((cells.s[i] + cells.road[i]) / 3 + b.i / 1000 + i % 100 / 1000, .1), 3);
if (b.capital) b.population = rn(b.population * 1.3, 3); // increase capital population
if (port) {
b.population = rn(b.population * 1.3, 3); // increase port population
const e = cells.v[i].filter(v => vertices.c[v].some(c => c === cells.haven[i])); // vertices of common edge
b.x = rn((vertices.p[e[0]][0] + vertices.p[e[1]][0]) / 2, 2);
b.y = rn((vertices.p[e[0]][1] + vertices.p[e[1]][1]) / 2, 2);
continue;
}
// shift burgs on rivers semi-randomly and just a bit
if (cells.r[i]) {
const shift = Math.min(cells.fl[i]/150, 1);
if (i%2) b.x = rn(b.x + shift, 2); else b.x = rn(b.x - shift, 2);
if (cells.r[i]%2) b.y = rn(b.y + shift, 2); else b.y = rn(b.y - shift, 2);
}
}
// de-assign port status if it's the only one on feature
for (const f of features) {
if (!f.i || f.land) continue;
const onFeature = burgs.filter(b => b.port === f.i);
if (onFeature.length === 1) {
onFeature[0].port = 0;
}
}
console.timeEnd("specifyBurgs");
}
function drawBurgsWithLabels() {
console.time("drawBurgs");
// remove old data
burgIcons.selectAll("circle").remove();
burgLabels.selectAll("text").remove();
icons.selectAll("use").remove();
// capitals
const capitals = burgs.filter(b => b.capital);
const capitalIcons = burgIcons.select("#cities");
const capitalLabels = burgLabels.select("#cities");
const capitalSize = capitalIcons.attr("size") || 1;
const capitalAnchors = anchors.selectAll("#cities");
const caSize = capitalAnchors.attr("size") || 2;
capitalIcons.selectAll("circle").data(capitals).enter()
.append("circle").attr("id", d => "burg"+d.i).attr("data-id", d => d.i)
.attr("cx", d => d.x).attr("cy", d => d.y).attr("r", capitalSize);
capitalLabels.selectAll("text").data(capitals).enter()
.append("text").attr("id", d => "burgLabel"+d.i).attr("data-id", d => d.i)
.attr("x", d => d.x).attr("y", d => d.y).attr("dy", `${capitalSize * -1.5}px`).text(d => d.name);
capitalAnchors.selectAll("use").data(capitals.filter(c => c.port)).enter()
.append("use").attr("xlink:href", "#icon-anchor").attr("data-id", d => d.i)
.attr("x", d => rn(d.x - caSize * .47, 2)).attr("y", d => rn(d.y - caSize * .47, 2))
.attr("width", caSize).attr("height", caSize);
// towns
const towns = burgs.filter(b => b.capital === false);
const townIcons = burgIcons.select("#towns");
const townLabels = burgLabels.select("#towns");
const townSize = townIcons.attr("size") || 0.5;
const townsAnchors = anchors.selectAll("#towns");
const taSize = townsAnchors.attr("size") || 1;
townIcons.selectAll("circle").data(towns).enter()
.append("circle").attr("id", d => "burg"+d.i).attr("data-id", d => d.i)
.attr("cx", d => d.x).attr("cy", d => d.y).attr("r", townSize);
townLabels.selectAll("text").data(towns).enter()
.append("text").attr("id", d => "burgLabel"+d.i).attr("data-id", d => d.i)
.attr("x", d => d.x).attr("y", d => d.y).attr("dy", `${townSize * -1.5}px`).text(d => d.name);
townsAnchors.selectAll("use").data(towns.filter(c => c.port)).enter()
.append("use").attr("xlink:href", "#icon-anchor").attr("data-id", d => d.i)
.attr("x", d => rn(d.x - taSize * .47, 2)).attr("y", d => rn(d.y - taSize * .47, 2))
.attr("width", taSize).attr("height", taSize);
console.timeEnd("drawBurgs");
}
console.timeEnd("generateBurgsAndStates");
}
// growth algorithm to assign cells to states like we did for cultures
const expandStates = function() {
console.time("expandStates");
const cells = pack.cells, states = pack.states, cultures = pack.cultures, burgs = pack.burgs;
cells.state = new Uint8Array(cells.i.length); // cell state
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
const cost = [];
states.filter(s => s.i && !s.removed).forEach(function(s) {
cells.state[burgs[s.capital].cell] = s.i;
const b = cells.biome[cultures[s.culture].center]; // native biome
queue.queue({e:s.center, p:0, s:s.i, b});
cost[s.center] = 1;
});
const neutral = cells.i.length / 5000 * 2000 * neutralInput.value * statesNeutral.value; // limit cost for state growth
while (queue.length) {
const next = queue.dequeue(), n = next.e, p = next.p, s = next.s, b = next.b;
const type = states[s].type;
cells.c[n].forEach(function(e) {
const biome = cells.biome[e];
const cultureCost = states[s].culture === cells.culture[e] ? 10 : 100;
const biomeCost = getBiomeCost(cells.road[e], b, biome, type);
const heightCost = getHeightCost(cells.h[e], type);
const riverCost = getRiverCost(cells.r[e], e, type);
const typeCost = getTypeCost(cells.t[e], type);
const totalCost = p + (cultureCost + biomeCost + heightCost + riverCost + typeCost) / states[s].expansionism;
if (totalCost > neutral) return;
if (!cost[e] || totalCost < cost[e]) {
if (cells.h[e] >= 20) {
cells.state[e] = s; // assign state to cell
if (cells.burg[e]) burgs[cells.burg[e]].state = s;
}
cost[e] = totalCost;
queue.queue({e, p:totalCost, s, b});
//const points = [cells.p[n][0], cells.p[n][1], (cells.p[n][0]+cells.p[e][0])/2, (cells.p[n][1]+cells.p[e][1])/2, cells.p[e][0], cells.p[e][1]];
//debug.append("text").attr("x", (cells.p[n][0]+cells.p[e][0])/2 - 1).attr("y", (cells.p[n][1]+cells.p[e][1])/2 - 1).text(rn(totalCost-p)).attr("font-size", .8);
//debug.append("polyline").attr("points", points).attr("marker-mid", "url(#arrow)").attr("opacity", .6);
}
});
}
//debug.selectAll(".text").data(cost).enter().append("text").attr("x", (d, e) => cells.p[e][0]-1).attr("y", (d, e) => cells.p[e][1]-1).text(d => d ? rn(d) : "").attr("font-size", 2);
function getBiomeCost(r, b, biome, type) {
if (r > 5) return 0; // no penalty if there is a road;
if (b === biome) return 10; // tiny penalty for native biome
if (type === "Hunting") return biomesData.cost[biome] * 2; // non-native biome penalty for hunters
if (type === "Nomadic" && biome > 4 && biome < 10) return biomesData.cost[biome] * 3; // forest biome penalty for nomads
return biomesData.cost[biome]; // general non-native biome penalty
}
function getHeightCost(h, type) {
if ((type === "Naval" || type === "Lake") && h < 20) return 200; // low sea crossing penalty for Navals
if (type === "Nomadic" && h < 20) return 10000; // giant sea crossing penalty for Navals
if (h < 20) return 1000; // general sea crossing penalty
if (type === "Highland" && h < 50) return 30; // penalty for highlanders on lowlands
if (type === "Highland") return 0; // no penalty for highlanders on highlands
if (h >= 70) return 100; // general mountains crossing penalty
if (h >= 50) return 30; // general hills crossing penalty
return 0;
}
function getRiverCost(r, i, type) {
if (type === "River") return r ? 0 : 50; // penalty for river cultures
if (!r) return 0; // no penalty for others if there is no river
return Math.min(Math.max(cells.fl[i] / 10, 20), 100) // river penalty from 20 to 100 based on flux
}
function getTypeCost(ctype, type) {
if (ctype === 1) return type === "Naval" || type === "Lake" ? 0 : type === "Nomadic" ? 60 : 20; // penalty for coastline
if (ctype === 2) return type === "Naval" || type === "Nomadic" ? 30 : 0; // low penalty for land level 2 for Navals and nomads
if (ctype !== -1) return type === "Naval" || type === "Lake" ? 100 : 0; // penalty for mainland for navals
return 0;
}
console.timeEnd("expandStates");
}
const normalizeStates = function() {
console.time("normalizeStates");
const cells = pack.cells;
const burgs = pack.burgs;
for (const i of cells.i) {
if (cells.h[i] < 20) continue;
const adversaries = cells.c[i].filter(c => cells.h[c] >= 20 && cells.state[c] !== cells.state[i]);
const buddies = cells.c[i].filter(c => cells.h[c] >= 20 && cells.state[c] === cells.state[i]);
if (adversaries.length <= buddies.length) continue;
if (cells.c[i].some(c => burgs[cells.burg[c]].capital)) continue; // do not overwrite near capital
if (burgs[cells.burg[i]].capital) continue; // do not overwrite capital
const newState = cells.state[adversaries[0]];
cells.state[i] = newState;
if (cells.burg[i]) burgs[cells.burg[i]].state = newState;
}
console.timeEnd("normalizeStates");
}
// calculate and draw curved state labels
const drawStateLabels = function() {
console.time("drawStateLabels");
const cells = pack.cells, features = pack.features, states = pack.states;
const paths = []; // text paths
lineGen.curve(d3.curveBundle.beta(1));
for (const s of states) {
if (!s.i || s.removed) continue;
const used = [];
const hull = getHull(s.center, s.i);
const points = [...hull].map(v => pack.vertices.p[v]);
//const poly = polylabel([points], 1.0); // pole of inaccessibility
//debug.append("circle").attr("r", 3).attr("cx", poly[0]).attr("cy", poly[1]);
const delaunay = Delaunator.from(points);
const voronoi = Voronoi(delaunay, points, points.length);
const c = voronoi.vertices;
const chain = connectCenters(c, s.i);
const relaxed = chain.map(i => c.p[i]).filter((p, i) => i%8 === 0 || i+1 === chain.length);
paths.push([s.i, relaxed]);
// if (s.i == 13) debug.selectAll(".circle").data(points).enter().append("circle").attr("cx", d => d[0]).attr("cy", d => d[1]).attr("r", .5).attr("fill", "red");
// if (s.i == 13) d3.select("#cells").selectAll(".polygon").data(d3.range(voronoi.cells.v.length)).enter().append("polygon").attr("points", d => voronoi.cells.v[d] ? voronoi.cells.v[d].map(v => c.p[v]) : "");
// if (s.i == 13) debug.append("path").attr("d", round(lineGen(relaxed))).attr("fill", "none").attr("stroke", "blue").attr("stroke-width", .5);
// if (s.i == 13) debug.selectAll(".circle").data(chain).enter().append("circle").attr("cx", d => c.p[d][0]).attr("cy", d => c.p[d][1]).attr("r", 1);
function getHull(start, state) {
const queue = [start], hull = new Set();
while (queue.length) {
const q = queue.pop();
const nQ = cells.c[q].filter(c => cells.state[c] === state);
cells.c[q].forEach(function(c, d) {
if (features[cells.f[c]].type === "lake" && features[cells.f[c]].cells < 10) return; // ignore small lakes
if (cells.b[c]) {hull.add(cells.v[q][d]); return;}
if (cells.state[c] !== state) {hull.add(cells.v[q][d]); return;}
const nC = cells.c[c].filter(n => cells.state[n] === state);
const intersected = intersect(nQ, nC).length
if (hull.size > 20 && !intersected) {hull.add(cells.v[q][d]); return;}
if (used[c]) return;
used[c] = 1;
queue.push(c);
});
}
return hull;
}
function connectCenters(c, state) {
// check if vertex is inside the area
const inside = c.p.map(function(p) {
if (p[0] <= 0 || p[1] <= 0 || p[0] >= graphWidth || p[1] >= graphHeight) return false; // out of the screen
return used[findCell(p[0], p[1])];
});
//if (state == 13) debug.selectAll(".circle").data(c.p).enter().append("circle").attr("cx", d => d[0]).attr("cy", d => d[1]).attr("r", .5).attr("fill", (d, i) => inside[i] ? "green" : "blue");
const sorted = d3.range(c.p.length).filter(i => inside[i]).sort((a, b) => c.p[a][0] - c.p[b][0]);
const left = sorted[0] || 0, right = sorted.pop() || 0;
// connect leftmost and rightmost points with shortest path
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
const cost = [], from = [];
queue.queue({e: right, p: 0});
while (queue.length) {
const next = queue.dequeue(), n = next.e, p = next.p;
if (n === left) break;
for (const v of c.v[n]) {
if (v === -1) continue;
const totalCost = p + (inside[v] ? 1 : 100);
if (from[v] || totalCost >= cost[v]) continue;
cost[v] = totalCost;
from[v] = n;
queue.queue({e: v, p: totalCost});
}
}
// restore path
const chain = [left];
let cur = left;
while (cur !== right) {
cur = from[cur];
if (inside[cur]) chain.push(cur);
}
return chain;
}
}
void function drawLabels() {
const g = labels.select("#states"), p = defs.select("#textPaths");
g.selectAll("text").remove();
p.selectAll("path").remove();
const data = paths.map(p => [round(lineGen(p[1])), "stateLabel"+p[0], states[p[0]].name, p[1]]);
p.selectAll("path").data(data).enter().append("path").attr("d", d => d[0]).attr("id", d => "textPath_"+d[1]);
g.selectAll("text").data(data).enter()
.append("text").attr("id", d => d[1])
.append("textPath").attr("xlink:href", d => "#textPath_"+d[1])
.attr("startOffset", "50%").text(d => d[2]);
// resize label based on its length
g.selectAll("text").each(function(e) {
const textPath = document.getElementById("textPath_"+e[1])
const pathLength = textPath.getTotalLength();
// if area is too small to get a path and length is 0
if (pathLength === 0) {
const x = e[3][0][0], y = e[3][0][1];
textPath.setAttribute("d", `M${x-50},${y}h${100}`);
this.firstChild.setAttribute("font-size", "60%");
return;
}
const copy = g.append("text").text(this.textContent);
const textLength = copy.node().getComputedTextLength();
copy.remove();
const size = Math.max(Math.min(rn(pathLength / textLength * 60), 175), 60);
this.firstChild.setAttribute("font-size", size+"%");
// prolongate textPath to not trim labels
if (pathLength < 100) {
const mod = 25 / pathLength;
const points = e[3];
const f = points[0], l = points[points.length-1];
const dx = l[0] - f[0], dy = l[1] - f[1];
points[0] = [rn(f[0] - dx * mod), rn(f[1] - dy * mod)];
points[points.length-1] = [rn(l[0] + dx * mod), rn(l[1] + dy * mod)];
textPath.setAttribute("d", round(lineGen(points)));
//debug.append("path").attr("d", round(lineGen(points))).attr("fill", "none").attr("stroke", "red");
}
});
}()
console.timeEnd("drawStateLabels");
}
return {generate, expandStates, normalizeStates, drawStateLabels};
})));

View file

@ -0,0 +1,210 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global.Cultures = factory());
}(this, (function () {'use strict';
let cells;
const generate = function() {
console.time('generateCultures');
cells = pack.cells;
cells.culture = new Int8Array(cells.i.length); // cell cultures
let count = +culturesInput.value;
const populated = cells.i.filter(i => cells.s[i]).sort((a, b) => cells.s[b] - cells.s[a]); // cells sorted by population
if (populated.length < count * 25) {
count = Math.floor(populated.length / 50);
if (!count) {
console.error(`There is no populated cells. Cannot generate cultures`);
pack.cultures = [{name:"Wildlands", i:0, base:1}];
alertMessage.innerHTML = `
The climate is harsh and people cannot live in this world.<br>
No cultures, states and burgs will be created.<br>
Please consider changing the World Configurator settings`;
$("#alert").dialog({resizable: false, title: "Extreme climate warning",
buttons: {Ok: function() {$(this).dialog("close");}}
});
return;
} else {
console.error(`Not enought populated cells (${populated.length}). Will generate only ${count} cultures`);
alertMessage.innerHTML = `
There is only ${populated.length} populated cells and it's insufficient livable area.<br>
Only ${count} out of ${culturesInput.value} requiested cultures will be generated.<br>
Please consider changing the World Configurator settings`;
$("#alert").dialog({resizable: false, title: "Extreme climate warning",
buttons: {Ok: function() {$(this).dialog("close");}}
});
}
}
pack.cultures = d3.shuffle(getDefault()).slice(0, count);
const centers = d3.quadtree();
const colors = getColors(count);
pack.cultures.forEach(function(culture, i) {
const c = culture.center = placeCultureCenter();
centers.add(cells.p[c]);
culture.i = i+1;
culture.color = colors[i];
culture.type = defineCultureType(c);
culture.expansionism = defineCultureExpansionism(culture.type);
cells.culture[c] = i+1;
//debug.append("text").attr("stroke", "#000").attr("font-size", "10").attr("font-family", "Almendra SC").attr("x", cells.p[c][0]).attr("y", cells.p[c][1]).text(culture.type);
});
// the first culture with id 0 is for wildlands
pack.cultures.unshift({name:"Wildlands", i:0, base:1});
// check whether all bases are valid. If not, load default namesbase
const invalidBase = pack.cultures.some(c => !nameBase[c.base]);
if (invalidBase) applyDefaultNamesData();
// culture center tends to be placed in a density populated cell
function placeCultureCenter() {
let center, spacing = (graphWidth + graphHeight) / count;
do {
center = populated[biased(0, populated.length-1, 3)];
spacing = spacing * .8;
}
while (centers.find(cells.p[center][0], cells.p[center][1], spacing) !== undefined);
return center;
}
function defineCultureType(i) {
if (cells.h[i] > 50) return "Highland"; // no penalty for hills and moutains, high for other elevations
const f = cells.f[cells.haven[i]];
if (pack.features[f].type === "lake" && pack.features[f].cells > 5) return "Lake" // low water cross penalty and high for non-along-coastline growth
if (cells.harbor[i] === 1) return "Naval"; // low water cross penalty and high for non-along-coastline growth
if (cells.r[i] && cells.fl[i] > 100) return "River"; // no River cross penalty, penalty for non-River growth
const b = cells.biome[i];
if (b === 4 || b === 1 || b === 2) return "Nomadic"; // high penalty in forest biomes and near coastline
if (b === 3 || b === 9 || b === 10) return "Hunting"; // high penalty in non-native biomes
return "Generic";
}
function defineCultureExpansionism(type) {
let base = 1; // Generic
if (type === "Lake") base = .8; else
if (type === "Naval") base = 1.5; else
if (type === "River") base = .9; else
if (type === "Nomadic") base = 1.8; else
if (type === "Hunting") base = .7; else
if (type === "Highland") base = .5;
return rn((Math.random() * powerInput.value / 2 + 1) * base, 1);
}
console.timeEnd('generateCultures');
}
const getDefault = function() {
return [
{name:"Shwazen", base:0},
{name:"Angshire", base:1},
{name:"Luari", base:2},
{name:"Tallian", base:3},
{name:"Astellian", base:4},
{name:"Slovan", base:5},
{name:"Norse", base:6},
{name:"Elladan", base:7},
{name:"Romian", base:8},
{name:"Soumi", base:9},
{name:"Koryo", base:10},
{name:"Hantzu", base:11},
{name:"Yamoto", base:12},
{name:"Portuzian", base:13},
{name:"Nawatli", base:14},
{name:"Vengrian", base: 15},
{name:"Turchian", base: 16},
{name:"Berberan", base: 17},
{name:"Eurabic", base: 18},
{name:"Inuk", base: 19},
{name:"Euskati", base: 20},
{name:"Negarian", base: 21},
{name:"Keltan", base: 22},
{name:"Efratic", base: 23},
{name:"Tehrani", base: 24},
{name:"Maui", base: 25},
{name:"Carnatic", base: 26},
{name:"Inqan", base: 27},
{name:"Kiswaili", base: 28},
{name:"Vietic", base: 29}
];
}
// expand cultures across the map (Dijkstra-like algorithm)
const expand = function() {
console.time('expandCultures');
cells = pack.cells;
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
pack.cultures.forEach(function(c) {
if (!c.i || c.removed) return;
queue.queue({e:c.center, p:0, c:c.i});
});
const neutral = cells.i.length / 5000 * 3000 * neutralInput.value; // limit cost for culture growth
const cost = [];
while (queue.length) {
const next = queue.dequeue(), n = next.e, p = next.p, c = next.c;
const type = pack.cultures[c].type;
cells.c[n].forEach(function(e) {
const biome = cells.biome[e];
const biomeCost = getBiomeCost(c, biome, type);
const biomeChangeCost = biome === cells.biome[n] ? 0 : 5 * Math.abs(biome - cells.biome[n]); // penalty on biome change
const heightCost = getHeightCost(e, cells.h[e], type);
const riverCost = getRiverCost(cells.r[e], e, type);
const typeCost = getTypeCost(cells.t[e], type);
const totalCost = p + (biomeCost + biomeChangeCost + heightCost + riverCost + typeCost) / pack.cultures[c].expansionism;
if (totalCost > neutral) return;
if (!cost[e] || totalCost < cost[e]) {
if (cells.s[e] > 0) cells.culture[e] = c; // assign culture to populated cell
cost[e] = totalCost;
queue.queue({e, p:totalCost, c});
//debug.append("text").attr("x", (cells.p[n][0]+cells.p[e][0])/2 - 1).attr("y", (cells.p[n][1]+cells.p[e][1])/2 - 1).text(rn(totalCost-p)).attr("font-size", .8);
//const points = [cells.p[n][0], cells.p[n][1], (cells.p[n][0]+cells.p[e][0])/2, (cells.p[n][1]+cells.p[e][1])/2, cells.p[e][0], cells.p[e][1]];
//debug.append("polyline").attr("points", points.toString()).attr("marker-mid", "url(#arrow)").attr("opacity", .6);
}
});
}
//debug.selectAll(".text").data(cost).enter().append("text").attr("x", (d, e) => cells.p[e][0]-1).attr("y", (d, e) => cells.p[e][1]-1).text(d => d ? rn(d) : "").attr("font-size", 2);
console.timeEnd('expandCultures');
}
function getBiomeCost(c, biome, type) {
if (cells.biome[pack.cultures[c].center] === biome) return biomesData.cost[biome] / 2; // tiny penalty for native biome
if (type === "Hunting") return biomesData.cost[biome] * 5; // non-native biome penalty for hunters
if (type === "Nomadic" && biome > 4 && biome < 10) return biomesData.cost[biome] * 10; // forest biome penalty for nomads
return biomesData.cost[biome] * 2; // general non-native biome penalty
}
function getHeightCost(i, h, type) {
if ((type === "Naval" || type === "Lake") && h < 20) return cells.area[i]; // low sea crossing penalty for Navals
if (type === "Nomadic" && h < 20) return cells.area[i] * 50; // giant sea crossing penalty for Navals
if (h < 20) return cells.area[i] * 5; // general sea crossing penalty
if (type === "Highland" && h < 50) return 30; // penalty for highlanders on lowlands
if (type === "Highland") return 0; // no penalty for highlanders on highlands
if (h >= 70) return 100; // general mountains crossing penalty
if (h >= 50) return 30; // general hills crossing penalty
return 0;
}
function getRiverCost(r, i, type) {
if (type === "River") return r ? 0 : 50; // penalty for river cultures
if (!r) return 0; // no penalty for others if there is no river
return Math.min(Math.max(cells.fl[i] / 10, 20), 100) // river penalty from 20 to 100 based on flux
}
function getTypeCost(ctype, type) {
if (ctype === 1) return type === "Naval" || type === "Lake" ? 0 : type === "Nomadic" ? 60 : 20; // penalty for coastline
if (ctype === 2) return type === "Naval" || type === "Nomadic" ? 30 : 0; // low penalty for land level 2 for Navals and nomads
if (ctype !== -1) return type === "Naval" || type === "Lake" ? 100 : 0; // penalty for mainland for navals
return 0;
}
return {generate, expand, getDefault};
})));

View file

@ -0,0 +1,475 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global.HeightmapGenerator = factory());
}(this, (function () { 'use strict';
let cells, p;
const generate = function() {
console.time('generateHeightmap');
cells = grid.cells;
p = grid.points;
cells.h = new Uint8Array(grid.points.length);
const input = document.getElementById("templateInput");
if (!locked("template")) {
const rnd = Math.random();
if (rnd < .05) input.value = "Volcano"; else // 5%
if (rnd < .25) input.value = "High Island"; else // 20%
if (rnd < .35) input.value = "Low Island"; else // 10%
if (rnd < .55) input.value = "Continents"; else // 20%
if (rnd < .85) input.value = "Archipelago"; else // 30%
if (rnd < .90) input.value = "Mediterranean"; else // 5%
if (rnd < .95) input.value = "Peninsula"; else // 5%
if (rnd < .99) input.value = "Pangea"; else // 4%
input.value = "Atoll"; // 1%
}
switch (input.value) {
case "Volcano": templateVolcano(); break;
case "High Island": templateHighIsland(); break;
case "Low Island": templateLowIsland(); break;
case "Continents": templateContinents(); break;
case "Archipelago": templateArchipelago(); break;
case "Atoll": templateAtoll(); break;
case "Mediterranean": templateMediterranean(); break;
case "Peninsula": templatePeninsula(); break;
case "Pangea": templatePangea(); break;
}
console.timeEnd('generateHeightmap');
}
// parse template step
function addStep(a1, a2, a3, a4, a5) {
if (a1 === "Hill") addHill(a2, a3, a4, a5); else
if (a1 === "Pit") addPit(a2, a3, a4, a5); else
if (a1 === "Range") addRange(a2, a3, a4, a5); else
if (a1 === "Trough") addTrough(a2, a3, a4, a5); else
if (a1 === "Strait") addStrait(a2, a3); else
if (a1 === "Add") modify(a3, a2, 1); else
if (a1 === "Multiply") modify(a3, 0, a2); else
if (a1 === "Smooth") smooth(a2);
}
// Heighmap Template: Volcano
function templateVolcano() {
addStep("Hill", "1", "90-100", "44-56", "40-60");
addStep("Multiply", .8, "50-100");
addStep("Range", "1.5", "30-55", "45-55", "40-60");
addStep("Smooth", 2);
addStep("Hill", "1.5", "25-35", "25-30", "20-75");
addStep("Hill", "1", "25-35", "75-80", "25-75");
addStep("Hill", "0.5", "20-25", "10-15", "20-25");
}
// Heighmap Template: High Island
function templateHighIsland() {
addStep("Hill", "1", "90-100", "65-75", "47-53");
addStep("Add", 5, "all");
addStep("Hill", "6", "20-23", "25-55", "45-55");
addStep("Range", "1", "40-50", "45-55", "45-55");
addStep("Smooth", 2);
addStep("Trough", "2-3", "20-30", "20-30", "20-30");
addStep("Trough", "2-3", "20-30", "60-80", "70-80");
addStep("Hill", "1", "10-15", "60-60", "50-50");
addStep("Hill", "1.5", "13-16", "15-20", "20-75");
addStep("Multiply", .8, "20-100");
addStep("Range", "1.5", "30-40", "15-85", "30-40");
addStep("Range", "1.5", "30-40", "15-85", "60-70");
addStep("Pit", "2-3", "10-15", "15-85", "20-80");
}
// Heighmap Template: Low Island
function templateLowIsland() {
addStep("Hill", "1", "90-99", "60-80", "45-55");
addStep("Hill", "4-5", "25-35", "20-65", "40-60");
addStep("Range", "1", "40-50", "45-55", "45-55");
addStep("Smooth", 3);
addStep("Trough", "1.5", "20-30", "15-85", "20-30");
addStep("Trough", "1.5", "20-30", "15-85", "70-80");
addStep("Hill", "1.5", "10-15", "5-15", "20-80");
addStep("Hill", "1", "10-15", "85-95", "70-80");
addStep("Pit", "3-5", "10-15", "15-85", "20-80");
addStep("Multiply", .4, "20-100");
}
// Heighmap Template: Continents
function templateContinents() {
addStep("Hill", "1", "80-85", "75-80", "40-60");
addStep("Hill", "1", "80-85", "20-25", "40-60");
addStep("Multiply", .22, "20-100");
addStep("Hill", "5-6", "15-20", "25-75", "20-82");
addStep("Range", ".8", "30-60", "5-15", "20-45");
addStep("Range", ".8", "30-60", "5-15", "55-80");
addStep("Range", "0-3", "30-60", "80-90", "20-80");
addStep("Trough", "3-4", "15-20", "15-85", "20-80");
addStep("Strait", "2", "vertical");
addStep("Smooth", 2);
addStep("Trough", "1-2", "5-10", "45-55", "45-55");
addStep("Pit", "3-4", "10-15", "15-85", "20-80");
addStep("Hill", "1", "5-10", "40-60", "40-60");
}
// Heighmap Template: Archipelago
function templateArchipelago() {
addStep("Add", 11, "all");
addStep("Range", "2-3", "40-60", "20-80", "20-80");
addStep("Hill", "5", "15-20", "10-90", "30-70");
addStep("Hill", "2", "10-15", "10-30", "20-80");
addStep("Hill", "2", "10-15", "60-90", "20-80");
addStep("Smooth", 3);
addStep("Trough", "10", "20-30", "5-95", "5-95");
addStep("Strait", "2", "vertical");
addStep("Strait", "2", "horizontal");
}
// Heighmap Template: Atoll
function templateAtoll() {
addStep("Hill", "1", "75-80", "50-60", "45-55");
addStep("Hill", "1.5", "30-50", "25-75", "30-70");
addStep("Hill", ".5", "30-50", "25-35", "30-70");
addStep("Smooth", 1);
addStep("Multiply", .2, "25-100");
addStep("Hill", ".5", "10-20", "50-55", "48-52");
}
// Heighmap Template: Mediterranean
function templateMediterranean() {
addStep("Range", "3-4", "30-50", "0-100", "0-10");
addStep("Range", "3-4", "30-50", "0-100", "90-100");
addStep("Hill", "5-6", "30-70", "0-100", "0-5");
addStep("Hill", "5-6", "30-70", "0-100", "95-100");
addStep("Smooth", 1);
addStep("Hill", "2-3", "30-70", "0-5", "20-80");
addStep("Hill", "2-3", "30-70", "95-100", "20-80");
addStep("Multiply", .8, "land");
addStep("Trough", "3-5", "40-50", "0-100", "0-10");
addStep("Trough", "3-5", "40-50", "0-100", "90-100");
}
// Heighmap Template: Peninsula
function templatePeninsula() {
addStep("Range", "2-3", "20-35", "40-50", "0-15");
addStep("Add", 5, "all");
addStep("Hill", "1", "90-100", "10-90", "0-5");
addStep("Add", 13, "all");
addStep("Hill", "3-4", "3-5", "5-95", "80-100");
addStep("Hill", "1-2", "3-5", "5-95", "40-60");
addStep("Trough", "5-6", "10-25", "5-95", "5-95");
addStep("Smooth", 3);
}
// Heighmap Template: Pangea
function templatePangea() {
addStep("Hill", "1-2", "25-40", "15-50", "0-10");
addStep("Hill", "1-2", "5-40", "50-85", "0-10");
addStep("Hill", "1-2", "25-40", "50-85", "90-100");
addStep("Hill", "1-2", "5-40", "15-50", "90-100");
addStep("Hill", "8-12", "20-40", "20-80", "48-52");
addStep("Smooth", 2);
addStep("Multiply", .7, "land");
addStep("Trough", "3-4", "25-35", "5-95", "10-20");
addStep("Trough", "3-4", "25-35", "5-95", "80-90");
addStep("Range", "5-6", "30-40", "10-90", "35-65");
}
const addHill = function(count, height, rangeX, rangeY) {
count = getNumberInRange(count);
while (count >= 1 || Math.random() < count) {addOneHill(); count--;}
function addOneHill() {
const change = new Uint8Array( cells.h.length);
let limit = 0, start;
let h = lim(getNumberInRange(height));
do {
const x = getPointInRange(rangeX, graphWidth);
const y = getPointInRange(rangeY, graphHeight);
start = findGridCell(x, y);
limit++;
} while (cells.h[start] + h > 90 && limit < 50)
change[start] = h;
const queue = [start];
while (queue.length) {
const q = queue.shift();
for (const c of cells.c[q]) {
if (change[c]) continue;
change[c] = change[q] ** .98 * (Math.random() * .2 + .9);
if (change[c] > 1) queue.push(c);
}
}
cells.h = cells.h.map((h, i) => lim(h + change[i]));
}
}
const addPit = function(count, height, rangeX, rangeY) {
count = getNumberInRange(count);
while (count >= 1 || Math.random() < count) {addOnePit(); count--;}
function addOnePit() {
const used = new Uint8Array(cells.h.length);
let limit = 0, start;
let h = lim(getNumberInRange(height));
do {
const x = getPointInRange(rangeX, graphWidth);
const y = getPointInRange(rangeY, graphHeight);
start = findGridCell(x, y);
limit++;
} while (cells.h[start] < 20 && limit < 50)
const queue = [start];
while (queue.length) {
const q = queue.shift();
h = h ** .98 * (Math.random() * .2 + .9);
if (h < 1) return;
cells.c[q].forEach(function(c, i) {
if (used[c]) return;
cells.h[c] = lim(cells.h[c] - h * (Math.random() * .2 + .9));
used[c] = 1;
queue.push(c);
});
}
}
}
const addRange = function(count, height, rangeX, rangeY) {
count = getNumberInRange(count);
while (count >= 1 || Math.random() < count) {addOneRange(); count--;}
function addOneRange() {
const used = new Uint8Array(cells.h.length);
let h = lim(getNumberInRange(height));
// find start and end points
const startX = getPointInRange(rangeX, graphWidth);
const startY = getPointInRange(rangeY, graphHeight);
let dist = 0, limit = 0, endX, endY;
do {
endX = Math.random() * graphWidth * .8 + graphWidth * .1;
endY = Math.random() * graphHeight * .7 + graphHeight * .15;
dist = Math.abs(endY - startY) + Math.abs(endX - startX);
limit++;
} while ((dist < graphWidth / 8 || dist > graphWidth / 3) && limit < 50)
let range = getRange(findGridCell(startX, startY), findGridCell(endX, endY));
// get main ridge
function getRange(cur, end) {
const range = [cur];
used[cur] = 1;
while (cur !== end) {
let min = Infinity;
cells.c[cur].forEach(function(e) {
if (used[e]) return;
let diff = (p[end][0] - p[e][0]) ** 2 + (p[end][1] - p[e][1]) ** 2;
if (Math.random() > .85) diff = diff / 2;
if (diff < min) {min = diff; cur = e;}
});
if (min === Infinity) return range;
range.push(cur);
used[cur] = 1;
}
return range;
}
// add height to ridge and cells around
let queue = range.slice(), i = 0;
while (queue.length) {
const frontier = queue.slice();
queue = [], i++;
frontier.forEach(i => {
cells.h[i] = lim(cells.h[i] + h * (Math.random() * .3 + .85));
});
h = h ** .82 - 1;
if (h < 2) break;
frontier.forEach(f => {
cells.c[f].forEach(i => {
if (!used[i]) {queue.push(i); used[i] = 1;}
});
});
}
// generate prominences
range.forEach((cur, d) => {
if (d%6 !== 0) return;
for (const l of d3.range(i)) {
const min = cells.c[cur][d3.scan(cells.c[cur], (a, b) => cells.h[a] - cells.h[b])]; // downhill cell
//debug.append("circle").attr("cx", p[min][0]).attr("cy", p[min][1]).attr("r", 1);
cells.h[min] = (cells.h[cur] * 2 + cells.h[min]) / 3;
cur = min;
}
});
}
}
const addTrough = function(count, height, rangeX, rangeY) {
count = getNumberInRange(count);
while (count >= 1 || Math.random() < count) {addOneTrough(); count--;}
function addOneTrough() {
const used = new Uint8Array(cells.h.length);
let h = lim(getNumberInRange(height));
// find start and end points
let limit = 0, startX, startY, start, dist = 0, endX, endY;
do {
startX = getPointInRange(rangeX, graphWidth);
startY = getPointInRange(rangeY, graphHeight);
start = findGridCell(startX, startY);
limit++;
} while (cells.h[start] < 20 && limit < 50)
limit = 0;
do {
endX = Math.random() * graphWidth * .8 + graphWidth * .1;
endY = Math.random() * graphHeight * .7 + graphHeight * .15;
dist = Math.abs(endY - startY) + Math.abs(endX - startX);
limit++;
} while ((dist < graphWidth / 8 || dist > graphWidth / 2) && limit < 50)
let range = getRange(start, findGridCell(endX, endY));
// get main ridge
function getRange(cur, end) {
const range = [cur];
used[cur] = 1;
while (cur !== end) {
let min = Infinity;
cells.c[cur].forEach(function(e) {
if (used[e]) return;
let diff = (p[end][0] - p[e][0]) ** 2 + (p[end][1] - p[e][1]) ** 2;
if (Math.random() > .8) diff = diff / 2;
if (diff < min) {min = diff; cur = e;}
});
if (min === Infinity) return range;
range.push(cur);
used[cur] = 1;
}
return range;
}
// add height to ridge and cells around
let queue = range.slice(), i = 0;
while (queue.length) {
const frontier = queue.slice();
queue = [], i++;
frontier.forEach(i => {
cells.h[i] = lim(cells.h[i] - h * (Math.random() * .3 + .85));
});
h = h ** .8 - 1;
if (h < 2) break;
frontier.forEach(f => {
cells.c[f].forEach(i => {
if (!used[i]) {queue.push(i); used[i] = 1;}
});
});
}
// generate prominences
range.forEach((cur, d) => {
if (d%6 !== 0) return;
for (const l of d3.range(i)) {
const min = cells.c[cur][d3.scan(cells.c[cur], (a, b) => cells.h[a] - cells.h[b])]; // downhill cell
//debug.append("circle").attr("cx", p[min][0]).attr("cy", p[min][1]).attr("r", 1);
cells.h[min] = (cells.h[cur] * 2 + cells.h[min]) / 3;
cur = min;
}
});
}
}
const addStrait = function(width, direction = "vertical") {
width = Math.min(getNumberInRange(width), grid.cellsX/3);
if (width < 1 && Math.random() < width) return;
const used = new Uint8Array(cells.h.length);
const vert = direction === "vertical";
const startX = vert ? Math.floor(Math.random() * graphWidth * .4 + graphWidth * .3) : 5;
const startY = vert ? 5 : Math.floor(Math.random() * graphHeight * .4 + graphHeight * .3);
const endX = vert ? Math.floor((graphWidth - startX) - (graphWidth * .1) + (Math.random() * graphWidth * .2)) : graphWidth - 5;
const endY = vert ? graphHeight - 5 : Math.floor((graphHeight - startY) - (graphHeight * .1) + (Math.random() * graphHeight * .2));
const start = findGridCell(startX, startY), end = findGridCell(endX, endY);
let range = getRange(start, end);
const query = [];
function getRange(cur, end) {
const range = [];
while (cur !== end) {
let min = Infinity;
cells.c[cur].forEach(function(e) {
let diff = (p[end][0] - p[e][0]) ** 2 + (p[end][1] - p[e][1]) ** 2;
if (Math.random() > 0.8) diff = diff / 2;
if (diff < min) {min = diff; cur = e;}
});
range.push(cur);
}
return range;
}
const step = .1 / width;
while (width > 0) {
const exp = .9 - step * width;
range.forEach(function(r) {
cells.c[r].forEach(function(e) {
if (used[e]) return;
used[e] = 1;
query.push(e);
cells.h[e] **= exp;
if (cells.h[e] > 100) cells.h[e] = 5;
});
range = query.slice();
});
width--;
}
}
const modify = function(range, add, mult, power) {
const min = range === "land" ? 20 : range === "all" ? 0 : +range.split("-")[0];
const max = range === "land" || range === "all" ? 100 : +range.split("-")[1];
grid.cells.h = grid.cells.h.map(h => h >= min && h <= max ? mod(h) : h);
function mod(v) {
if (add) v = min === 20 ? Math.max(v + add, 20) : v + add;
if (mult !== 1) v = min === 20 ? (v-20) * mult + 20 : v * mult;
if (power) v = min === 20 ? (v-20) ** power + 20 : v ** power;
return lim(v);
}
}
const smooth = function(fr = 2) {
cells.h = cells.h.map((h, i) => {
const a = [h];
cells.c[i].forEach(c => a.push(cells.h[c]));
return lim((h * (fr-1) + d3.mean(a)) / fr);
});
}
function getPointInRange(range, length) {
if (typeof range !== "string") {console.error("Range should be a string"); return;}
const min = range.split("-")[0]/100 || 0;
const max = range.split("-")[1]/100 || 100;
return rand(min * length, max * length);
}
return {generate, addHill, addRange, addTrough, addStrait, addPit, smooth, modify};
})));

152
modules/names-generator.js Normal file
View file

@ -0,0 +1,152 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global.Names = factory());
}(this, (function () { 'use strict';
const chains = [];
// calculate Markov chain for a namesbase
const calculateChain = function(b) {
const chain = [];
const d = nameBase[b].join(" ").toLowerCase();
for (let i = -1, prev = " ", str = ""; i < d.length - 2; prev = str, i += str.length, str = "") {
let v = 0, f = " ";
for (let c=i+1; str.length < 5; c++) {
if (d[c] === undefined) break;
str += d[c];
if (str === " ") break;
if (d[c] !== "o" && d[c] !== "e" && vowel(d[c]) && d[c+1] === d[c]) break;
if (d[c+2] === " ") {str += d[c+1]; break;}
if (vowel(d[c])) v++;
if (v && vowel(d[c+2])) break;
}
if (i >= 0) f = d[i];
if (chain[f] === undefined) chain[f] = [];
chain[f].push(str);
}
return chain;
}
// update chain for specific base
const updateChain = (b) => chains[b] = nameBase[b] ? calculateChain(b) : null;
// update chains for all used bases
const updateChains = () => chains.forEach((c, i) => chains[i] = nameBase[i] ? calculateChain(i) : null);
// generate name using Markov's chain
const getBase = function(base, min, max, dupl, multi) {
if (base === undefined) {console.error("Please define a base"); return;}
if (!chains[base]) chains[base] = nameBase[base] ? calculateChain(base) : null;
const data = chains[base];
if (!data || data[" "] === undefined) {
tip("Namesbase " + base + " is incorrect. Please checl in namesbase editor", false, "error");
console.error("nameBase " + base + " is incorrect!");
return "ERROR";
}
if (!min) min = nameBases[base].min;
if (!max) max = nameBases[base].max;
if (!dupl) dupl = nameBases[base].d;
if (!multi) multi = nameBases[base].m;
let v = data[" "], cur = v[rand(v.length-1)], w = "";
for (let i=0; i < 21; i++) {
if (cur === " " && Math.random() > multi) {
if (w.length < min) {cur = ""; w = ""; v = data[" "];} else break;
} else {
if ((w+cur).length > max) {
if (w.length < min) w += cur;
break;
} else if (cur === " " && w.length+1 < min) {
cur = "";
v = data[" "];
} else {
v = data[cur.slice(-1)];
}
}
w += cur;
cur = v[rand(v.length - 1)];
}
// parse word to get a final name
let name = [...w].reduce(function(r, c, i, d) {
if (c === d[i+1] && !dupl.includes(c)) return r; // duplication is not allowed
if (!r.length) return c.toUpperCase();
if (r.slice(-1) === " ") return r + c.toUpperCase();
if (c === "a" && d[i+1] === "e") return r; // "ae" => "e"
if (c === " " && i+1 === d.length) return r;
// remove consonant before 2 consonants
if (i+2 < d.length && !vowel(c) && !vowel(d[i+1]) && !vowel(d[i+2])) return r;
if (i+2 < d.length && c === d[i+1] && c === d[i+2]) return r; // remove tree same letters in a row
return r + c;
}, "");
if (name.length < 2) name = nameBase[base][rand(nameBase[base].length-1)]; // rare case when no name generated
return name;
}
// generate name for culture
const getCulture = function(culture, min, max, dupl, multi) {
if (culture === undefined) {console.error("Please define a culture"); return;}
const base = pack.cultures[culture].base;
return getBase(base, min, max, dupl, multi);
}
// generate state name based on capital or random name and culture-specific suffix
const getState = function(name, culture) {
if (name === undefined) {console.error("Please define a base name"); return;}
if (culture === undefined) {console.error("Please define a culture"); return;}
const base = pack.cultures[culture].base;
// exclude endings inappropriate for states name
if (name.includes(" ")) name = capitalize(name.replace(/ /g, "").toLowerCase()); // don't allow multiword state names
if (name.length > 6 && name.slice(-4) === "berg") name = name.slice(0,-4); // remove -berg for any
if (base === 5 && ["sk", "ev", "ov"].includes(name.slice(-2))) name = name.slice(0,-2); // remove -sk/-ev/-ov for Ruthenian
else if (base === 1 && name.length > 5 && name.slice(-3) === "ton") name = name.slice(0,-3); // remove -ton for English
else if (base === 12) return vowel(name.slice(-1)) ? name : name + "u"; // Japanese ends on any vowel or -u
else if (base === 18 && Math.random() < .4) name = vowel(name.slice(0,1).toLowerCase()) ? "Al" + name.toLowerCase() : "Al " + name; // Arabic starts with -Al
// define if suffix should be used
if (name.length > 3 && vowel(name.slice(-1))) {
if (vowel(name.slice(-2,-1)) && Math.random() < .85) name = name.slice(0,-2); // 85% for vv
else if (Math.random() < .7) name = name.slice(0,-1); // ~60% for cv
else return name;
} else if (Math.random() < .4) return name; // 60% for cc and vc
// define suffix
let suffix = "";
const rnd = Math.random(), l = name.length;
if (base === 3) suffix = rnd < .03 && l < 7 ? "terra" : "ia"; // Italian
else if (base === 4) suffix = rnd < .03 && l < 7 ? "terra" : "ia"; // Spanish
else if (base === 13) suffix = rnd < .03 && l < 7 ? "terra" : "ia"; // Portuguese
else if (base === 2) suffix = rnd < .03 && l < 7 ? "terre" : "ia"; // French
else if (base === 0) suffix = rnd < .5 && l < 7 ? "land" : "ia"; // German
else if (base === 1) suffix = rnd < .4 && l < 7 ? "land" : "ia"; // English
else if (base === 6) suffix = rnd < .3 && l < 7 ? "land" : "ia"; // Nordic
else if (base === 7) suffix = rnd < .1 ? "eia" : "ia"; // Greek
else if (base === 9) suffix = rnd < .35 ? "maa" : "ia"; // Finnic
else if (base === 15) suffix = rnd < .6 && l < 6 ? "orszag" : "ia"; // Hungarian
else if (base === 16) suffix = rnd < .5 ? "stan" : "ya"; // Turkish
else if (base === 10) suffix = "guk"; // Korean
else if (base === 11) suffix = " Guo"; // Chinese
else if (base === 14) suffix = rnd < .6 && l < 7 ? "tlan" : "co"; // Nahuatl
else if (base === 17) suffix = rnd < .8 ? "a" : "ia"; // Berber
else if (base === 18) suffix = rnd < .8 ? "a" : "ia"; // Arabic
else suffix = "ia" // other
if (name.slice(-1 * suffix.length) === suffix) return name; // no suffix if name already ends with it
const s1 = suffix.charAt(0);
if (name.slice(-1) === s1) name = name.slice(0, -1); // remove name last letter if it's a suffix first letter
if (vowel(s1) === vowel(name.slice(-1)) && vowel(s1) === vowel(name.slice(-2,-1))) name = name.slice(0, -1); // remove name last char if 2 last chars are the same type as suffix's 1st
return name + suffix;
}
return {getBase, getCulture, getState, updateChain, updateChains};
})));

95
modules/ocean-layers.js Normal file
View file

@ -0,0 +1,95 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global.OceanLayers = factory());
}(this, (function () { 'use strict';
let cells, vertices, pointsN, used;
var OceanLayers = function OceanLayers() {
const outline = outlineLayersInput.value;
if (outline === "none") return;
console.time("drawOceanLayers");
cells = grid.cells, pointsN = grid.cells.i.length, vertices = grid.vertices;
const limits = outline === "random" ? randomizeOutline() : outline.split(",").map(s => +s);
markupOcean(limits);
const chains = [];
const opacity = rn(0.4 / limits.length, 2);
used = new Uint8Array(pointsN); // to detect already passed cells
for (const i of cells.i) {
const t = cells.t[i];
if (used[i] || !limits.includes(t)) continue;
const start = findStart(i, t);
if (!start) continue;
used[i] = 1;
//debug.append("circle").attr("r", 3).attr("cx", vertices.p[start.c][0]).attr("cy", vertices.p[start.c][1]).attr("fill", "red").attr("stroke", "black").attr("stroke-width", .3);
const chain = connectVertices(start, t); // vertices chain to form a path
const relaxation = 1 + t * -2; // select only n-th point
const relaxed = chain.filter((v, i) => i % relaxation === 0 || vertices.c[v].some(c => c >= pointsN));
if (relaxed.length >= 3) chains.push([t, relaxed.map(v => vertices.p[v])]);
}
//debug.selectAll("text").data(cells.i).enter().append("text").attr("font-size", 2).attr("x", d => grid.points[d][0]).attr("y", d => grid.points[d][1]).text(d => cells.t[d]+","+used[d]);
for (const t of limits) {
const path = chains.filter(c => c[0] === t).map(c => round(lineGen(c[1]))).join();
if (path) oceanLayers.append("path").attr("d", path).attr("fill", "#ecf2f9").style("opacity", opacity);
// For each layer there should outer ring. If no, layer will be upside down. Need to fix it in the future
}
// find eligible cell vertex to start path detection
function findStart(i, t) {
if (cells.b[i]) return cells.v[i].find(v => vertices.c[v].some(c => c >= pointsN)); // map border cell
return cells.v[i][cells.c[i].findIndex(c => cells.t[c] < t || !cells.t[c])];
}
console.timeEnd("drawOceanLayers");
}
function randomizeOutline() {
const limits = [];
let odd = 0.2
for (let l = -9; l < 0; l++) {
if (Math.random() < odd) {odd = 0.2; limits.push(l);}
else {odd *= 2;}
}
return limits;
}
function markupOcean(limits) {
// Define ocean cells type based on distance form land
for (let t = -2; t >= limits[0]-1; t--) {
for (let i = 0; i < pointsN; i++) {
if (cells.t[i] !== t+1) continue;
cells.c[i].forEach(function(e) {if (!cells.t[e]) cells.t[e] = t;});
}
}
}
// connect vertices to chain
function connectVertices(start, t) {
const chain = []; // vertices chain to form a path
for (let i=0, current = start; i === 0 || current !== start && i < 10000; i++) {
const prev = chain[chain.length - 1]; // previous vertex in chain
chain.push(current); // add current vertex to sequence
const c = vertices.c[current]; // cells adjacent to vertex
c.filter(c => cells.t[c] === t).forEach(c => used[c] = 1);
const v = vertices.v[current]; // neighboring vertices
const c0 = !cells.t[c[0]] || cells.t[c[0]] === t-1;
const c1 = !cells.t[c[1]] || cells.t[c[1]] === t-1;
const c2 = !cells.t[c[2]] || cells.t[c[2]] === t-1;
if (v[0] !== undefined && v[0] !== prev && c0 !== c1) current = v[0];
else if (v[1] !== undefined && v[1] !== prev && c1 !== c2) current = v[1];
else if (v[2] !== undefined && v[2] !== prev && c0 !== c2) current = v[2];
if (current === chain[chain.length - 1]) {console.error("Next vertex is not found"); break;}
}
chain.push(chain[0]); // push first vertex as the last one
return chain;
}
return OceanLayers;
})));

80
modules/relief-icons.js Normal file
View file

@ -0,0 +1,80 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global.ReliefIcons = factory());
}(this, (function () {'use strict';
var ReliefIcons = function ReliefIcons() {
console.time('drawRelief');
terrain.selectAll("*").remove();
const density = +styleReliefDensityInput.value;
if (!density) return;
const size = 1.6, mod = .2 * size; // size modifier;s
const relief = []; // t: type, c: cell, x: centerX, y: centerY, s: size;
const cells = pack.cells;
for (const i of cells.i) {
const height = cells.h[i];
if (height < 20) continue; // no icons on water
if (cells.r[i]) continue; // no icons on rivers
const b = cells.biome[i];
if (height < 50 && biomesData.iconsDensity[b] === 0) continue; // no icons for this biome
const polygon = getPackPolygon(i);
const x = d3.extent(polygon, p => p[0]), y = d3.extent(polygon, p => p[1]);
const e = [Math.ceil(x[0]), Math.ceil(y[0]), Math.floor(x[1]), Math.floor(y[1])]; // polygon box
if (height < 50) placeBiomeIcons(i, b); else placeReliefIcons(i);
function placeBiomeIcons() {
const iconsDensity = biomesData.iconsDensity[b] / 100;
const radius = 2 / iconsDensity / density;
if (Math.random() > iconsDensity * 10) return;
for (const [cx, cy] of poissonDiscSampler(e[0], e[1], e[2], e[3], radius)) {
if (!d3.polygonContains(polygon, [cx, cy])) continue;
let h = rn((4 + Math.random()) * size, 2);
const icon = getBiomeIcon(biomesData.icons[b]);
if (icon === "#relief-grass-1") h *= 1.3;
relief.push({t: icon, c: i, x: rn(cx-h, 2), y: rn(cy-h, 2), s: h*2});
}
}
function placeReliefIcons() {
const radius = 2 / density;
const [icon, h] = getReliefIcon(height);
for (const [cx, cy] of poissonDiscSampler(e[0], e[1], e[2], e[3], radius)) {
if (!d3.polygonContains(polygon, [cx, cy])) continue;
relief.push({t: icon, c: i, x: rn(cx-h, 2), y: rn(cy-h, 2), s: h*2});
}
}
function getReliefIcon(h) {
const type = h > 70 ? "mount" : "hill";
const size = h > 70 ? (h - 45) * mod : Math.min(Math.max((h - 40) * mod, 3), 6);
return ["#relief-" + type + "-1", size];
}
}
// sort relief icons by y+size
relief.sort((a, b) => (a.y + a.s) - (b.y + b.s));
// append relief icons at once using pure js
void function renderRelief() {
let reliefHTML = "";
for (const r of relief) {reliefHTML += `<use xlink:href="${r.t}" data-type="${r.t}" x=${r.x} y=${r.y} data-size=${r.s} width=${r.s} height=${r.s}></use>`;}
terrain.html(reliefHTML);
}()
console.timeEnd('drawRelief');
}
function getBiomeIcon(i) {
return "#relief-" + i[Math.floor(Math.random() * i.length)] + "-1";
}
return ReliefIcons;
})));

204
modules/river-generator.js Normal file
View file

@ -0,0 +1,204 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global.Rivers = factory());
}(this, (function () {'use strict';
const generate = function Rivers() {
console.time('generateRivers');
Math.seedrandom(seed);
const cells = pack.cells, p = cells.p, features = pack.features;
features.forEach(f => {delete f.river; delete f.flux;});
const riversData = []; // rivers data
cells.fl = new Uint16Array(cells.i.length); // water flux array
cells.r = new Uint16Array(cells.i.length); // rivers array
cells.conf = new Uint8Array(cells.i.length); // confluences array
let riverNext = 1; // first river id is 1, not 0
void function drainWater() {
const land = cells.i.filter(isLand).sort(highest);
land.forEach(function(i) {
cells.fl[i] += grid.cells.prec[cells.g[i]]; // flux from precipitation
const x = p[i][0], y = p[i][1];
// near-border cell: pour out of the screen
if (cells.b[i]) {
if (cells.r[i]) {
const to = [];
const min = Math.min(y, graphHeight - y, x, graphWidth - x);
if (min === y) {to[0] = x; to[1] = 0;} else
if (min === graphHeight - y) {to[0] = x; to[1] = graphHeight;} else
if (min === x) {to[0] = 0; to[1] = y;} else
if (min === graphWidth - x) {to[0] = graphWidth; to[1] = y;}
riversData.push({river: cells.r[i], cell: i, x: to[0], y: to[1]});
}
return;
}
const min = cells.c[i][d3.scan(cells.c[i], (a, b) => cells.h[a] - cells.h[b])]; // downhill cell
// allow only one river can flow thought a lake
const cf = features[cells.f[i]]; // current cell feature
if (cf.river && cf.river !== cells.r[i]) {
cells.fl[i] = 0;
}
if (cells.fl[i] < 30) {
if (cells.h[min] >= 20) cells.fl[min] += cells.fl[i];
return; // flux is too small to operate as river
}
// Proclaim a new river
if (!cells.r[i]) {
cells.r[i] = riverNext;
riversData.push({river: riverNext, cell: i, x, y});
riverNext++;
}
if (cells.r[min]) { // downhill cell already has river assigned
if (cells.fl[min] < cells.fl[i]) {
cells.conf[min] = cells.fl[min]; // confluence
cells.r[min] = cells.r[i]; // re-assign river if downhill part has less flux
} else cells.conf[min] += cells.fl[i]; // confluence
} else cells.r[min] = cells.r[i]; // assign the river to the downhill cell
const nx = p[min][0], ny = p[min][1];
if (cells.h[min] < 20) {
// pour water to the sea haven
riversData.push({river: cells.r[i], cell: cells.haven[i], x: nx, y: ny});
} else {
const mf = features[cells.f[min]]; // feature of min cell
if (mf.type === "lake") {
if (!mf.river || cells.fl[i] > mf.flux) {
mf.river = cells.r[i]; // pour water to temporaly elevated lake
mf.flux = cells.fl[i]; // entering flux
}
}
cells.fl[min] += cells.fl[i]; // propagate flux
riversData.push({river: cells.r[i], cell: min, x: nx, y: ny}); // add next River segment
}
});
}()
void function drawRivers() {
const riverPaths = []; // to store data for all rivers
for (let r = 1; r <= riverNext; r++) {
const riverSegments = riversData.filter(d => d.river === r);
if (riverSegments.length > 2) {
const riverEnhanced = addMeandring(riverSegments);
const width = rn(0.8 + Math.random() * 0.4, 1); // river width modifier
const increment = rn(0.8 + Math.random() * 0.6, 1); // river bed widening modifier
const path = getPath(riverEnhanced, width, increment);
riverPaths.push([r, path, width, increment]);
} else {
// remove too short rivers
riverSegments.filter(s => cells.r[s.cell] === r).forEach(s => cells.r[s.cell] = 0);
}
}
rivers.selectAll("path").remove();
rivers.selectAll("path").data(riverPaths).enter()
.append("path").attr("d", d => d[1]).attr("id", d => "river"+d[0])
.attr("data-width", d => d[2]).attr("data-increment", d => d[3]);
}()
console.timeEnd('generateRivers');
}
// add more river points on 1/3 and 2/3 of length
const addMeandring = function(segments, rndFactor = 0.3) {
const riverEnhanced = []; // to store enhanced segments
let side = 1; // to control meandring direction
for (let s = 0; s < segments.length; s++) {
const sX = segments[s].x, sY = segments[s].y; // segment start coordinates
const c = pack.cells.conf[segments[s].cell] || 0; // if segment is river confluence
riverEnhanced.push([sX, sY, c]);
if (s+1 === segments.length) break; // do not enhance last segment
const eX = segments[s+1].x, eY = segments[s+1].y; // segment end coordinates
const angle = Math.atan2(eY - sY, eX - sX);
const sin = Math.sin(angle), cos = Math.cos(angle);
const serpentine = 1 / (s + 1) + 0.3;
const meandr = serpentine + Math.random() * rndFactor;
if (Math.random() < 0.5) side *= -1; // change meandring direction in 50%
const dist2 = (eX - sX) ** 2 + (eY - sY) ** 2;
// if dist2 is big or river is small add extra points at 1/3 and 2/3 of segment
if (dist2 > 64 || (dist2 > 16 && segments.length < 6)) {
const p1x = (sX * 2 + eX) / 3 + side * -sin * meandr;
const p1y = (sY * 2 + eY) / 3 + side * cos * meandr;
if (Math.random() < 0.2) side *= -1; // change 2nd extra point meandring direction in 20%
const p2x = (sX + eX * 2) / 3 + side * sin * meandr;
const p2y = (sY + eY * 2) / 3 + side * cos * meandr;
riverEnhanced.push([p1x, p1y], [p2x, p2y]);
// if dist is medium or river is small add 1 extra middlepoint
} else if (dist2 > 16 || segments.length < 6) {
const p1x = (sX + eX) / 2 + side * -sin * meandr;
const p1y = (sY + eY) / 2 + side * cos * meandr;
riverEnhanced.push([p1x, p1y]);
}
}
return riverEnhanced;
}
const getPath = function(points, width = 1, increment = 1) {
let offset, extraOffset = .1; // starting river width (to make river source visible)
const riverLength = points.reduce((s, v, i, p) => s + (i ? Math.hypot(v[0] - p[i-1][0], v[1] - p[i-1][1]) : 0), 0); // summ of segments length
const widening = rn((1000 + (riverLength * 30)) * increment);
const riverPointsLeft = [], riverPointsRight = []; // store points on both sides to build a valid polygon
const last = points.length - 1;
const factor = riverLength / points.length;
// first point
let x = points[0][0], y = points[0][1], c;
let angle = Math.atan2(y - points[1][1], x - points[1][0]);
let sin = Math.sin(angle), cos = Math.cos(angle);
let xLeft = x + -sin * extraOffset, yLeft = y + cos * extraOffset;
riverPointsLeft.push([xLeft, yLeft]);
let xRight = x + sin * extraOffset, yRight = y + -cos * extraOffset;
riverPointsRight.unshift([xRight, yRight]);
// middle points
for (let p = 1; p < last; p++) {
x = points[p][0], y = points[p][1], c = points[p][2] || 0;
const xPrev = points[p-1][0], yPrev = points[p - 1][1];
const xNext = points[p+1][0], yNext = points[p + 1][1];
angle = Math.atan2(yPrev - yNext, xPrev - xNext);
sin = Math.sin(angle), cos = Math.cos(angle);
offset = (Math.atan(Math.pow(p * factor, 2) / widening) / 2 * width) + extraOffset;
const confOffset = Math.atan(c * 5 / widening);
extraOffset += confOffset;
xLeft = x + -sin * offset, yLeft = y + cos * (offset + confOffset);
riverPointsLeft.push([xLeft, yLeft]);
xRight = x + sin * offset, yRight = y + -cos * offset;
riverPointsRight.unshift([xRight, yRight]);
}
// end point
x = points[last][0], y = points[last][1], c = points[last][2];
if (c) extraOffset += Math.atan(c * 10 / widening); // add extra width on river confluence
angle = Math.atan2(points[last-1][1] - y, points[last-1][0] - x);
sin = Math.sin(angle), cos = Math.cos(angle);
xLeft = x + -sin * offset, yLeft = y + cos * offset;
riverPointsLeft.push([xLeft, yLeft]);
xRight = x + sin * offset, yRight = y + -cos * offset;
riverPointsRight.unshift([xRight, yRight]);
// generate polygon path and return
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
const right = lineGen(riverPointsRight);
let left = lineGen(riverPointsLeft);
left = left.substring(left.indexOf("C"));
return round(right + left, 2);
}
return {generate, addMeandring, getPath};
})));

252
modules/routes-generator.js Normal file
View file

@ -0,0 +1,252 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global.Routes = factory());
}(this, (function () {'use strict';
const getRoads = function() {
console.time("generateMainRoads");
const cells = pack.cells, burgs = pack.burgs.filter(b => b.i && !b.removed);
const capitals = burgs.filter(b => b.capital);
if (capitals.length < 2) return []; // not enought capitals to build main roads
const paths = []; // array to store path segments
for (const b of capitals) {
const connect = capitals.filter(c => c.i > b.i && c.feature === b.feature);
if (!connect.length) continue;
const farthest = d3.scan(connect, (a, c) => ((c.y - b.y) ** 2 + (c.x - b.x) ** 2) - ((a.y - b.y) ** 2 + (a.x - b.x) ** 2));
const [from, exit] = findLandPath(b.cell, connect[farthest].cell, null);
const segments = restorePath(b.cell, exit, "main", from);
segments.forEach(s => paths.push(s));
}
cells.i.forEach(i => cells.s[i] += cells.road[i] / 2); // add roads to suitability score
console.timeEnd("generateMainRoads");
return paths;
}
const getTrails = function() {
console.time("generateTrails");
const cells = pack.cells, burgs = pack.burgs.filter(b => b.i && !b.removed);
if (burgs.length < 2) return []; // not enought capitals to build main roads
let paths = []; // array to store path segments
for (const f of pack.features.filter(f => f.land)) {
const isle = burgs.filter(b => b.feature === f.i); // burgs on island
if (isle.length < 2) continue;
isle.forEach(function(b, i) {
let path = [];
if (!i) {
const farthest = d3.scan(isle, (a, c) => ((c.y - b.y) ** 2 + (c.x - b.x) ** 2) - ((a.y - b.y) ** 2 + (a.x - b.x) ** 2));
const to = isle[farthest].cell;
if (cells.road[to]) return;
const [from, exit] = findLandPath(b.cell, to, null);
path = restorePath(b.cell, exit, "small", from);
} else {
if (cells.road[b.cell]) return;
const [from, exit] = findLandPath(b.cell, null, true);
if (exit === null) return;
path = restorePath(b.cell, exit, "small", from);
}
if (path) paths = paths.concat(path);
});
}
console.timeEnd("generateTrails");
return paths;
}
const getSearoutes = function() {
console.time("generateSearoutes");
const cells = pack.cells, allPorts = pack.burgs.filter(b => b.port != 0 && !b.removed);
if (allPorts.length < 2) return [];
const bodies = new Set(allPorts.map(b => b.port)); // features with ports
let from = [], exit = null, path = [], paths = []; // array to store path segments
bodies.forEach(function(f) {
const ports = allPorts.filter(b => b.port === f);
if (ports.length < 2) return;
const first = ports[0].cell;
// directly connect first port with the farthest one on the same island to remove gap
if (pack.features[f].type !== "lake") {
const portsOnIsland = ports.filter(b => cells.f[b.cell] === cells.f[first]);
if (portsOnIsland.length > 3) {
const opposite = ports[d3.scan(portsOnIsland, (a, b) => ((b.y - ports[0].y) ** 2 + (b.x - ports[0].x) ** 2) - ((a.y - ports[0].y) ** 2 + (a.x - ports[0].x) ** 2))].cell;
//debug.append("circle").attr("r", 1).attr("fill", "blue").attr("cx", pack.cells.p[first][0]).attr("cy", pack.cells.p[first][1])
//debug.append("circle").attr("r", 1).attr("fill", "green").attr("cx", pack.cells.p[opposite][0]).attr("cy", pack.cells.p[opposite][1])
[from, exit] = findOceanPath(opposite, first);
from[first] = cells.haven[first];
path = restorePath(opposite, first, "ocean", from);
paths = paths.concat(path);
}
}
// directly connect first port with the farthest one
const farthest = ports[d3.scan(ports, (a, b) => ((b.y - ports[0].y) ** 2 + (b.x - ports[0].x) ** 2) - ((a.y - ports[0].y) ** 2 + (a.x - ports[0].x) ** 2))].cell;
[from, exit] = findOceanPath(farthest, first);
from[first] = cells.haven[first];
path = restorePath(farthest, first, "ocean", from);
paths = paths.concat(path);
// indirectly connect first port with all other ports
if (ports.length < 3) return;
for (const p of ports) {
if (p.cell === first || p.cell === farthest) continue;
[from, exit] = findOceanPath(p.cell, first, true);
//from[exit] = cells.haven[exit];
const path = restorePath(p.cell, exit, "ocean", from);
paths = paths.concat(path);
}
});
console.timeEnd("generateSearoutes");
return paths;
}
const draw = function(main, small, ocean) {
console.time("drawRoutes");
const cells = pack.cells, burgs = pack.burgs;
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
// main routes
roads.selectAll("path").data(main).enter().append("path")
.attr("id", (d, i) => "road" + i)
.attr("d", d => round(lineGen(d.map(c => {
const b = cells.burg[c];
const x = b ? burgs[b].x : cells.p[c][0];
const y = b ? burgs[b].y : cells.p[c][1];
return [x, y];
})), 1));
// small routes
trails.selectAll("path").data(small).enter().append("path")
.attr("id", (d, i) => "trail" + i)
.attr("d", d => round(lineGen(d.map(c => {
const b = cells.burg[c];
const x = b ? burgs[b].x : cells.p[c][0];
const y = b ? burgs[b].y : cells.p[c][1];
return [x, y];
})), 1));
// ocean routes
lineGen.curve(d3.curveBundle.beta(1));
searoutes.selectAll("path").data(ocean).enter().append("path")
.attr("id", (d, i) => "searoute" + i)
.attr("d", d => round(lineGen(d.map(c => {
const b = cells.burg[c];
const x = b ? burgs[b].x : cells.p[c][0];
const y = b ? burgs[b].y : cells.p[c][1];
return [x, y];
})), 1));
console.timeEnd("drawRoutes");
}
const regenerate = function() {
routes.selectAll("path").remove();
pack.cells.road = new Uint16Array(pack.cells.i.length);
const main = getRoads();
const small = getTrails();
const ocean = getSearoutes();
draw(main, small, ocean);
}
return {getRoads, getTrails, getSearoutes, draw, regenerate};
// Dijkstra's algorithm to find a land path
function findLandPath(start, exit = null, toRoad = null) {
const cells = pack.cells;
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
const cost = [], from = [];
const basicCost = 10;
queue.queue({e: start, p: 0});
while (queue.length) {
const next = queue.dequeue(), n = next.e, p = next.p;
if (toRoad && cells.road[n]) return [from, n];
for (const c of cells.c[n]) {
if (cells.h[c] < 20) continue; // ignore water cells
const habitedCost = 100 - biomesData.habitability[cells.biome[c]];
const heightCost = Math.abs(cells.h[c] - cells.h[n]) * 10;
const cellCoast = basicCost + habitedCost + heightCost;
const totalCost = p + (cells.road[c] || cells.burg[c] ? cellCoast / 3 : cellCoast);
if (from[c] || totalCost >= cost[c]) continue;
from[c] = n;
if (c === exit) return [from, exit];
cost[c] = totalCost;
queue.queue({e: c, p: totalCost});
}
}
return [from, exit];
}
function restorePath(start, end, type, from) {
const cells = pack.cells;
const path = []; // to store all segments;
let segment = [], current = end, prev = end;
const score = type === "main" ? 5 : 1; // to incrade road score at cell
if (type === "ocean" || !cells.road[prev]) segment.push(end);
if (!cells.road[prev]) cells.road[prev] = score;
for (let i = 0, limit = 1000; i < limit; i++) {
if (!from[current]) break;
current = from[current];
if (cells.road[current]) {
if (segment.length) {
segment.push(current);
path.push(segment);
if (segment[0] !== end) cells.road[segment[0]] += score; // crossroad
if (current !== start) cells.road[current] += score; // crossroad
}
segment = [];
prev = current;
} else {
if (prev) segment.push(prev);
prev = null;
segment.push(current);
}
cells.road[current] += score;
if (current === start) break;
}
if (segment.length > 1) path.push(segment);
return path;
}
// find water paths
function findOceanPath(start, exit = null, toRoute = null) {
const cells = pack.cells;
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
const cost = [], from = [];
queue.queue({e: start, p: 0});
while (queue.length) {
const next = queue.dequeue(), n = next.e, p = next.p;
if (toRoute && n !== start && cells.road[n]) return [from, n];
for (const c of cells.c[n]) {
if (cells.h[c] >= 20) continue; // ignore land cells
const dist2 = (cells.p[c][1] - cells.p[n][1]) ** 2 + (cells.p[c][0] - cells.p[n][0]) ** 2;
const totalCost = p + (cells.road[c] ? 1 + dist2 / 2 : dist2 + (cells.t[c] ? 1 : 100));
if (from[c] || totalCost >= cost[c]) continue;
from[c] = n;
if (c === exit) return [from, exit];
cost[c] = totalCost;
queue.queue({e: c, p: totalCost});
}
}
return [from, exit];
}
})));

377
modules/save-and-load.js Normal file
View file

@ -0,0 +1,377 @@
// Functions to save and load the map
"use strict";
// download map as SVG or PNG file
function saveAsImage(type) {
console.time("saveAsImage");
// clone svg
const cloneEl = document.getElementById("map").cloneNode(true);
cloneEl.id = "fantasyMap";
document.getElementsByTagName("body")[0].appendChild(cloneEl);
const clone = d3.select("#fantasyMap");
if (type === "svg") clone.select("#viewbox").attr("transform", null); // reset transform to show whole map
if (layerIsOn("texture") && type === "png") clone.select("#texture").remove(); // no texture for png
// for each g element get inline style
const emptyG = clone.append("g").node();
const defaultStyles = window.getComputedStyle(emptyG);
clone.selectAll("g, #ruler > g > *, #scaleBar > text").each(function(d) {
const compStyle = window.getComputedStyle(this);
let style = "";
for (let i=0; i < compStyle.length; i++) {
const key = compStyle[i];
const value = compStyle.getPropertyValue(key);
// Firefox mask hack
if (key === "mask-image" && value !== defaultStyles.getPropertyValue(key)) {
style += "mask-image: url('#land');";
continue;
}
if (key === "cursor") continue; // cursor should be default
if (this.hasAttribute(key)) continue; // don't add style if there is the same attribute
if (value === defaultStyles.getPropertyValue(key)) continue;
style += key + ':' + value + ';';
}
if (style != "") this.setAttribute('style', style);
});
emptyG.remove();
// load fonts as dataURI so they will be available in downloaded svg/png
GFontToDataURI(getFontsToLoad()).then(cssRules => {
clone.select("defs").append("style").text(cssRules.join('\n'));
const svg_xml = (new XMLSerializer()).serializeToString(clone.node());
clone.remove();
const blob = new Blob([svg_xml], {type: 'image/svg+xml;charset=utf-8'});
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.target = "_blank";
if (type === "png") {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = svgWidth * pngResolutionInput.value;
canvas.height = svgHeight * pngResolutionInput.value;
const img = new Image();
img.src = url;
img.onload = function() {
window.URL.revokeObjectURL(url);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
link.download = "fantasy_map_" + Date.now() + ".png";
canvas.toBlob(function(blob) {
link.href = window.URL.createObjectURL(blob);
document.body.appendChild(link);
link.click();
window.setTimeout(function() {
canvas.remove();
window.URL.revokeObjectURL(link.href);
}, 1000);
});
}
} else {
link.download = "fantasy_map_" + Date.now() + ".svg";
link.href = url;
document.body.appendChild(link);
link.click();
}
window.setTimeout(function() {window.URL.revokeObjectURL(url);}, 2000);
console.timeEnd("saveAsImage");
});
}
// get non-standard fonts used for labels to fetch them from web
function getFontsToLoad() {
const webSafe = ["Georgia", "Times+New+Roman", "Comic+Sans+MS", "Lucida+Sans+Unicode", "Courier+New", "Verdana", "Arial", "Impact"];
const fontsInUse = []; // to store fonts currently in use
labels.selectAll("g").each(function() {
const font = this.dataset.font;
if (!font) return;
if (webSafe.includes(font)) return; // do not fetch web-safe fonts
if (!fontsInUse.includes(font)) fontsInUse.push(font);
});
return "https://fonts.googleapis.com/css?family=" + fontsInUse.join("|");
}
// code from Kaiido's answer https://stackoverflow.com/questions/42402584/how-to-use-google-fonts-in-canvas-when-drawing-dom-objects-in-svg
function GFontToDataURI(url) {
return fetch(url) // first fecth the embed stylesheet page
.then(resp => resp.text()) // we only need the text of it
.then(text => {
let s = document.createElement('style');
s.innerHTML = text;
document.head.appendChild(s);
let styleSheet = Array.prototype.filter.call(
document.styleSheets,
sS => sS.ownerNode === s)[0];
let FontRule = rule => {
let src = rule.style.getPropertyValue('src');
let url = src.split('url(')[1].split(')')[0];
return {rule: rule, src: src, url: url.substring(url.length - 1, 1)};
};
let fontRules = [], fontProms = [];
for (let r of styleSheet.cssRules) {
let fR = FontRule(r);
fontRules.push(fR);
fontProms.push(
fetch(fR.url) // fetch the actual font-file (.woff)
.then(resp => resp.blob())
.then(blob => {
return new Promise(resolve => {
let f = new FileReader();
f.onload = e => resolve(f.result);
f.readAsDataURL(blob);
})
})
.then(dataURL => {
return fR.rule.cssText.replace(fR.url, dataURL);
})
)
}
document.head.removeChild(s); // clean up
return Promise.all(fontProms); // wait for all this has been done
});
}
// Save in .map format
function saveMap() {
if (customization) {tip("Map cannot be saved when is in edit mode, please exit the mode and re-try", false, "error"); return;}
console.time("saveMap");
const date = new Date();
const dateString = date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate();
const license = "File can be loaded in azgaar.github.io/Fantasy-Map-Generator";
const params = [version, license, dateString, seed, graphWidth, graphHeight].join("|");
const options = [distanceUnit.value, distanceScale.value, areaUnit.value, heightUnit.value, heightExponent.value, temperatureScale.value,
barSize.value, barLabel.value, barBackOpacity.value, barBackColor.value, barPosX.value, barPosY.value, populationRate.value, urbanization.value,
equatorOutput.value, equidistanceOutput.value, temperatureEquatorOutput.value, temperaturePoleOutput.value, precOutput.value, JSON.stringify(winds)].join("|");
const coords = JSON.stringify(mapCoordinates);
const biomes = [biomesData.color, biomesData.habitability, biomesData.name].join("|");
const notesData = JSON.stringify(notes);
// set transform values to default
svg.attr("width", graphWidth).attr("height", graphHeight);
const transform = d3.zoomTransform(svg.node());
viewbox.attr("transform", null);
const svg_xml = (new XMLSerializer()).serializeToString(svg.node());
const gridGeneral = JSON.stringify({spacing:grid.spacing, cellsX:grid.cellsX, cellsY:grid.cellsY, boundary:grid.boundary, points:grid.points, features:grid.features});
const features = JSON.stringify(pack.features);
const cultures = JSON.stringify(pack.cultures);
const states = JSON.stringify(pack.states);
const burgs = JSON.stringify(pack.burgs);
const data = [params, options, coords, biomes, notesData, svg_xml,
gridGeneral, grid.cells.h, grid.cells.prec, grid.cells.f, grid.cells.t, grid.cells.temp,
features, cultures, states, burgs,
pack.cells.biome, pack.cells.burg, pack.cells.conf, pack.cells.culture, pack.cells.fl,
pack.cells.pop, pack.cells.r, pack.cells.road, pack.cells.s, pack.cells.state].join("\r\n");
const dataBlob = new Blob([data], {type: "text/plain"});
const dataURL = window.URL.createObjectURL(dataBlob);
const link = document.createElement("a");
link.download = "fantasy_map_" + Date.now() + ".map";
link.href = dataURL;
document.body.appendChild(link);
link.click();
// restore initial values
svg.attr("width", svgWidth).attr("height", svgHeight);
zoom.transform(svg, transform);
window.setTimeout(function() {window.URL.revokeObjectURL(dataURL);}, 2000);
console.timeEnd("saveMap");
}
function uploadFile(file, callback) {
console.time("loadMap");
const fileReader = new FileReader();
fileReader.onload = function(fileLoadedEvent) {
const dataLoaded = fileLoadedEvent.target.result;
const data = dataLoaded.split("\r\n");
const mapVersion = data[0].split("|")[0] || data[0];
if (mapVersion === version) {parseLoadedData(data); return;}
const archive = "<a href='https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Changelog' target='_blank'>archived version</a>";
const parsed = parseFloat(mapVersion);
let message = "", load = false;
if (isNaN(parsed) || data.length < 26 || !data[5]) {
message = `The file you are trying to load is not a valid .map file`;
} else if (parsed < 0.7) {
message = `The map version you are trying to load (${mapVersion}) is too old and cannot be updated to the current version.
<br>Please keep using an ${archive}`;
} else {
load = true;
message = `The map version (${mapVersion}) does not match the Generator version (${version}). The map will be auto-updated.
<br>In case of issues please keep using an ${archive} of the Generator`;
}
alertMessage.innerHTML = message;
$("#alert").dialog({title: "Version conflict", buttons: {
OK: function() {$(this).dialog("close"); if (load) parseLoadedData(data);}
}});
};
fileReader.readAsText(file, "UTF-8");
if (callback) callback();
}
function parseLoadedData(data) {
closeDialogs();
void function parseParameters() {
const params = data[0].split("|");
if (params[3]) {seed = params[3]; optionsSeed.value = seed;}
if (params[4]) graphWidth = +params[4];
if (params[5]) graphHeight = +params[5];
}()
void function parseOptions() {
const options = data[1].split("|");
if (options[0]) distanceUnit.value = distanceUnitOutput.innerHTML = options[0];
if (options[1]) distanceScale.value = distanceScaleSlider.value = options[1];
if (options[2]) areaUnit.value = options[2];
if (options[3]) heightUnit.value= options[3];
if (options[4]) heightExponent.value = heightExponentSlider.value = options[4];
if (options[5]) temperatureScale.value = options[5];
if (options[6]) barSize.value = barSizeSlider.value = options[6];
if (options[7] !== undefined) barLabel.value = options[7];
if (options[8] !== undefined) barBackOpacity.value = options[8];
if (options[9]) barBackColor.value = options[9];
if (options[10]) barPosX.value = options[10];
if (options[11]) barPosY.value = options[11];
if (options[12]) populationRate.value = populationRateSlider.value = options[12];
if (options[13]) urbanization.value = urbanizationSlider.value = options[13];
if (options[14]) equatorInput.value = equatorOutput.value = options[14];
if (options[15]) equidistanceInput.value = equidistanceOutput.value = options[15];
if (options[16]) temperatureEquatorInput.value = temperatureEquatorOutput.value = options[16];
if (options[17]) temperaturePoleInput.value = temperaturePoleOutput.value = options[17];
if (options[18]) precInput.value = precOutput.value = options[18];
if (options[19]) winds = JSON.parse(options[19]);
}()
void function parseConfiguration() {
if (data[2]) mapCoordinates = JSON.parse(data[2]);
if (data[4]) notes = JSON.parse(data[4]);
const biomes = data[3].split("|");
const name = biomes[2].split(",");
if (name.length !== biomesData.name.length) {
console.error("Biomes data is not correct and will not be loaded");
return;
}
biomesData.color = biomes[0].split(",");
biomesData.habitability = biomes[1].split(",");
biomesData.name = name;
}()
void function replaceSVG() {
svg.remove();
document.body.insertAdjacentHTML("afterbegin", data[5]);
}()
void function redefineElements() {
svg = d3.select("#map");
defs = svg.select("#deftemp");
viewbox = svg.select("#viewbox");
scaleBar = svg.select("#scaleBar");
ocean = viewbox.select("#ocean");
oceanLayers = ocean.select("#oceanLayers");
oceanPattern = ocean.select("#oceanPattern");
lakes = viewbox.select("#lakes");
landmass = viewbox.select("#landmass");
texture = viewbox.select("#texture");
terrs = viewbox.select("#terrs");
biomes = viewbox.select("#biomes");
cells = viewbox.select("#cells");
gridOverlay = viewbox.select("#gridOverlay");
coordinates = viewbox.select("#coordinates");
compass = viewbox.select("#compass");
rivers = viewbox.select("#rivers");
terrain = viewbox.select("#terrain");
cults = viewbox.select("#cults");
regions = viewbox.select("#regions");
statesBody = regions.select("#statesBody");
statesHalo = regions.select("#statesHalo");
borders = viewbox.select("#borders");
routes = viewbox.select("#routes");
roads = routes.select("#roads");
trails = routes.select("#trails");
searoutes = routes.select("#searoutes");
temperature = viewbox.select("#temperature");
coastline = viewbox.select("#coastline");
prec = viewbox.select("#prec");
population = viewbox.select("#population");
labels = viewbox.select("#labels");
icons = viewbox.select("#icons");
burgIcons = icons.select("#burgIcons");
anchors = icons.select("#anchors");
markers = viewbox.select("#markers");
ruler = viewbox.select("#ruler");
debug = viewbox.select("#debug");
freshwater = lakes.select("#freshwater");
salt = lakes.select("#salt");
burgLabels = labels.select("#burgLabels");
}()
void function parseGridData() {
grid = JSON.parse(data[6]);
calculateVoronoi(grid, grid.points);
grid.cells.h = Uint8Array.from(data[7].split(","));
grid.cells.prec = Uint8Array.from(data[8].split(","));
grid.cells.f = Uint16Array.from(data[9].split(","));
grid.cells.t = Int8Array.from(data[10].split(","));
grid.cells.temp = Int8Array.from(data[11].split(","));
}()
void function parsePackData() {
pack = {};
reGraph();
reMarkFeatures();
pack.features = JSON.parse(data[12]);
pack.cultures = JSON.parse(data[13]);
pack.states = JSON.parse(data[14]);
pack.burgs = JSON.parse(data[15]);
pack.cells.biome = Uint8Array.from(data[16].split(","));
pack.cells.burg = Uint16Array.from(data[17].split(","));
pack.cells.conf = Uint8Array.from(data[18].split(","));
pack.cells.culture = Uint8Array.from(data[19].split(","));
pack.cells.fl = Uint16Array.from(data[20].split(","));
pack.cells.pop = Uint16Array.from(data[21].split(","));
pack.cells.r = Uint16Array.from(data[22].split(","));
pack.cells.road = Uint16Array.from(data[23].split(","));
pack.cells.s = Uint16Array.from(data[24].split(","));
pack.cells.state = Uint8Array.from(data[25].split(","));
}()
void function restoreLayersState() {
if (texture.style("display") !== "none" && texture.select("image").size()) turnButtonOn("toggleTexture"); else turnButtonOff("toggleTexture");
if (terrs.selectAll("*").size()) turnButtonOn("toggleHeight"); else turnButtonOff("toggleHeight");
if (biomes.selectAll("*").size()) turnButtonOn("toggleBiomes"); else turnButtonOff("toggleBiomes");
if (cells.selectAll("*").size()) turnButtonOn("toggleCells"); else turnButtonOff("toggleCells");
if (gridOverlay.selectAll("*").size()) turnButtonOn("toggleGrid"); else turnButtonOff("toggleGrid");
if (coordinates.selectAll("*").size()) turnButtonOn("toggleCoordinates"); else turnButtonOff("toggleCoordinates");
if (compass.style("display") !== "none" && compass.select("use").size()) turnButtonOn("toggleCompass"); else turnButtonOff("toggleCompass");
if (rivers.style("display") !== "none") turnButtonOn("toggleRivers"); else turnButtonOff("toggleRivers");
if (terrain.style("display") !== "none" && terrain.selectAll("*").size()) turnButtonOn("toggleRelief"); else turnButtonOff("toggleRelief");
if (cults.selectAll("*").size()) turnButtonOn("toggleCultures"); else turnButtonOff("toggleCultures");
if (statesBody.selectAll("*").size()) turnButtonOn("toggleStates"); else turnButtonOff("toggleStates");
if (borders.style("display") !== "none" && borders.selectAll("*").size()) turnButtonOn("toggleBorders"); else turnButtonOff("toggleBorders");
if (routes.style("display") !== "none" && routes.selectAll("path").size()) turnButtonOn("toggleRoutes"); else turnButtonOff("toggleRoutes");
if (temperature.selectAll("*").size()) turnButtonOn("toggleTemp"); else turnButtonOff("toggleTemp");
if (population.select("#rural").selectAll("*").size()) turnButtonOn("togglePopulation"); else turnButtonOff("togglePopulation");
if (prec.selectAll("circle").size()) turnButtonOn("togglePrec"); else turnButtonOff("togglePrec");
if (labels.style("display") !== "none") turnButtonOn("toggleLabels"); else turnButtonOff("toggleLabels");
if (icons.style("display") !== "none") turnButtonOn("toggleIcons"); else turnButtonOff("toggleIcons");
if (markers.style("display") !== "none") turnButtonOn("toggleMarkers"); else turnButtonOff("toggleMarkers");
if (ruler.style("display") !== "none") turnButtonOn("toggleRulers"); else turnButtonOff("toggleRulers");
if (scaleBar.style("display") !== "none") turnButtonOn("toggleScaleBar"); else turnButtonOff("toggleScaleBar");
}()
changeMapSize();
restoreDefaultEvents();
invokeActiveZooming();
tip("Map is loaded");
console.timeEnd("loadMap");
}

303
modules/ui/biomes-editor.js Normal file
View file

@ -0,0 +1,303 @@
"use strict";
function editBiomes() {
if (customization) return;
closeDialogs("#biomesEditor, .stable");
if (!layerIsOn("toggleBiomes")) toggleBiomes();
if (layerIsOn("toggleStates")) toggleStates();
if (layerIsOn("toggleCultures")) toggleCultures();
const body = document.getElementById("biomesBody");
const animate = d3.transition().duration(2000).ease(d3.easeSinIn);
refreshBiomesEditor();
if (modules.editBiomes) return;
modules.editBiomes = true;
$("#biomesEditor").dialog({
title: "Biomes Editor", width: fitContent(), close: closeBiomesEditor,
position: {my: "right top", at: "right-10 top+10", of: "svg"}
});
// add listeners
document.getElementById("biomesEditorRefresh").addEventListener("click", refreshBiomesEditor);
document.getElementById("biomesPercentage").addEventListener("click", togglePercentageMode);
document.getElementById("biomesManually").addEventListener("click", enterBiomesCustomizationMode);
document.getElementById("biomesManuallyApply").addEventListener("click", applyBiomesChange);
document.getElementById("biomesManuallyCancel").addEventListener("click", exitBiomesCustomizationMode);
document.getElementById("biomesRestore").addEventListener("click", restoreInitialBiomes);
document.getElementById("biomesRegenerateReliefIcons").addEventListener("click", regenerateIcons);
document.getElementById("biomesExport").addEventListener("click", downloadBiomesData);
function refreshBiomesEditor() {
biomesCollectStatistics();
biomesEditorAddLines();
}
function biomesCollectStatistics() {
const cells = pack.cells;
biomesData.cells = new Uint32Array(biomesData.i.length);
biomesData.area = new Uint32Array(biomesData.i.length);
biomesData.rural = new Uint32Array(biomesData.i.length);
biomesData.urban = new Uint32Array(biomesData.i.length);
for (const i of cells.i) {
if (cells.h[i] < 20) continue;
const b = cells.biome[i];
biomesData.cells[b] += 1;
biomesData.area[b] += cells.area[i];
biomesData.rural[b] += cells.pop[i];
if (cells.burg[i]) biomesData.urban[b] += pack.burgs[cells.burg[i]].population;
}
}
function biomesEditorAddLines() {
const unit = areaUnit.value === "square" ? " " + distanceUnit.value + "²" : " " + areaUnit.value;
const b = biomesData;
let lines = "", totalArea = 0, totalPopulation = 0;;
for (const i of b.i) {
if (!i) continue; // ignore marine (water) biome
const area = b.area[i] * distanceScale.value ** 2;
const rural = b.rural[i] * populationRate.value;
const urban = b.urban[i] * populationRate.value * urbanization.value;
const population = rural + urban;
const populationTip = `Total population: ${si(population)}; Rural population: ${si(rural)}; Urban population: ${si(urban)}`;
totalArea += area;
totalPopulation += population;
lines += `<div class="states biomes" data-id="${i}" data-name="${b.name[i]}" data-habitability="${b.habitability[i]}"
data-cells=${b.cells[i]} data-area=${area} data-population=${population} data-color=${b.color[i]}>
<input data-tip="Biome color. Click to change" class="stateColor" type="color" value="${b.color[i]}">
<input data-tip="Biome name. Click and type to change" class="biomeName" value="${b.name[i]}" autocorrect="off" spellcheck="false">
<span data-tip="Biome habitability percent">%</span>
<input data-tip="Biome habitability percent. Click and set new value to change" type="number" min=0 max=999 step=1 class="biomeHabitability" value=${b.habitability[i]}>
<span data-tip="Cells count" class="icon-check-empty"></span>
<div data-tip="Cells count" class="biomeCells">${b.cells[i]}</div>
<span data-tip="Biome area" style="padding-right: 4px" class="icon-map-o"></span>
<div data-tip="Biome area" class="biomeArea">${si(area) + unit}</div>
<span data-tip="${populationTip}" class="icon-male"></span>
<div data-tip="${populationTip}" class="biomePopulation">${si(population)}</div>
</div>`;
}
body.innerHTML = lines;
// update footer
biomesFooterBiomes.innerHTML = b.i.length - 1;
biomesFooterCells.innerHTML = pack.cells.h.filter(h => h >= 20).length;
biomesFooterArea.innerHTML = si(totalArea) + unit;
biomesFooterPopulation.innerHTML = si(totalPopulation);
biomesFooterArea.dataset.area = totalArea;
biomesFooterPopulation.dataset.population = totalPopulation;
// add listeners
body.querySelectorAll("div.biomes").forEach(el => el.addEventListener("mouseenter", ev => biomeHighlightOn(ev)));
body.querySelectorAll("div.biomes").forEach(el => el.addEventListener("mouseleave", ev => biomeHighlightOff(ev)));
body.querySelectorAll("div.biomes").forEach(el => el.addEventListener("click", selectBiomeOnLineClick));
body.querySelectorAll("div > input[type='color']").forEach(el => el.addEventListener("input", biomeChangeColor));
body.querySelectorAll("div > input.biomeName").forEach(el => el.addEventListener("input", biomeChangeName));
body.querySelectorAll("div > input.biomeHabitability").forEach(el => el.addEventListener("change", biomeChangeHabitability));
if (body.dataset.type === "percentage") {body.dataset.type = "absolute"; togglePercentageMode();}
applySorting(biomesHeader);
$("#biomesEditor").dialog();
}
function biomeHighlightOn(event) {
if (customization === 6) return;
const biome = +event.target.dataset.id;
biomes.select("#biome"+biome).raise().transition(animate).attr("stroke-width", 2).attr("stroke", "#cd4c11");
}
function biomeHighlightOff(event) {
if (customization === 6) return;
const biome = +event.target.dataset.id;
const color = biomesData.color[biome];
biomes.select("#biome"+biome).transition().attr("stroke-width", .7).attr("stroke", color);
}
function biomeChangeColor() {
const biome = +this.parentNode.dataset.id;
biomesData.color[biome] = this.value;
biomes.select("#biome"+biome).attr("fill", this.value).attr("stroke", this.value);
}
function biomeChangeName() {
const biome = +this.parentNode.dataset.id;
this.parentNode.dataset.name = this.value;
biomesData.name[biome] = this.value;
}
function biomeChangeHabitability() {
const biome = +this.parentNode.dataset.id;
const failed = isNaN(+this.value) || +this.value < 0 || +this.value > 999;
if (failed) {
this.value = biomesData.habitability[biome];
tip("Please provide a valid number in range 0-999", false, "error");
return;
}
biomesData.habitability[biome] = +this.value;
this.parentNode.dataset.habitability = this.value;
recalculatePopulation();
refreshBiomesEditor();
}
function togglePercentageMode() {
if (body.dataset.type === "absolute") {
body.dataset.type = "percentage";
const totalCells = +biomesFooterCells.innerHTML;
const totalArea = +biomesFooterArea.dataset.area;
const totalPopulation = +biomesFooterPopulation.dataset.population;
body.querySelectorAll(":scope> div").forEach(function(el) {
el.querySelector(".biomeCells").innerHTML = rn(+el.dataset.cells / totalCells * 100) + "%";
el.querySelector(".biomeArea").innerHTML = rn(+el.dataset.area / totalArea * 100) + "%";
el.querySelector(".biomePopulation").innerHTML = rn(+el.dataset.population / totalPopulation * 100) + "%";
});
} else {
body.dataset.type = "absolute";
biomesEditorAddLines();
}
}
function regenerateIcons() {
ReliefIcons();
if (!layerIsOn("toggleRelief")) toggleRelief();
}
function downloadBiomesData() {
const unit = areaUnit.value === "square" ? distanceUnit.value + "2" : areaUnit.value;
let data = "Id,Biome,Color,Habitability,Cells,Area "+unit+",Population\n"; // headers
body.querySelectorAll(":scope > div").forEach(function(el) {
data += el.dataset.id + ",";
data += el.dataset.name + ",";
data += el.dataset.color + ",";
data += el.dataset.habitability + "%,";
data += el.dataset.cells + ",";
data += el.dataset.area + ",";
data += el.dataset.population + "\n";
});
const dataBlob = new Blob([data], {type: "text/plain"});
const url = window.URL.createObjectURL(dataBlob);
const link = document.createElement("a");
document.body.appendChild(link);
link.download = "states_data" + Date.now() + ".csv";
link.href = url;
link.click();
window.setTimeout(function() {window.URL.revokeObjectURL(url);}, 2000);
}
function enterBiomesCustomizationMode() {
if (!layerIsOn("toggleBiomes")) toggleBiomes();
customization = 6;
biomes.append("g").attr("id", "temp");
body.querySelectorAll("div > *").forEach(e => e.disabled = true);
document.querySelectorAll("#biomesBottom > button").forEach(el => el.style.display = "none");
document.querySelectorAll("#biomesBottom > div").forEach(el => el.style.display = "block");
body.querySelector("div.biomes").classList.add("selected");
tip("Click on biome to select, drag the circle to change biome", true);
viewbox.style("cursor", "crosshair").call(d3.drag()
.on("drag", dragBiomeBrush))
.on("click", selectBiomeOnMapClick)
.on("touchmove mousemove", moveBiomeBrush);
}
function selectBiomeOnLineClick() {
if (customization !== 6) return;
const selected = body.querySelector("div.selected");
if (selected) selected.classList.remove("selected");
this.classList.add("selected");
}
function selectBiomeOnMapClick() {
const point = d3.mouse(this);
const i = findCell(point[0], point[1]);
if (pack.cells.h[i] < 20) {tip("You cannot reassign water via biomes. Please edit the Heightmap to change water", false, "error"); return;}
const assigned = biomes.select("#temp").select("polygon[data-cell='"+i+"']");
const biome = assigned.size() ? +assigned.attr("data-biome") : pack.cells.biome[i];
body.querySelector("div.selected").classList.remove("selected");
body.querySelector("div[data-id='"+biome+"']").classList.add("selected");
}
function dragBiomeBrush() {
const p = d3.mouse(this);
const r = +biomesManuallyBrush.value;
moveCircle(p[0], p[1], r);
const found = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1], r)];
const selection = found.filter(isLand);
if (selection) changeBiomeForSelection(selection);
}
// change region within selection
function changeBiomeForSelection(selection) {
const temp = biomes.select("#temp");
const selected = body.querySelector("div.selected");
const biomeNew = selected.dataset.id;
const color = biomesData.color[biomeNew];
selection.forEach(function(i) {
const exists = temp.select("polygon[data-cell='"+i+"']");
const biomeOld = exists.size() ? +exists.attr("data-biome") : pack.cells.biome[i];
if (biomeNew === biomeOld) return;
// change of append new element
if (exists.size()) exists.attr("data-biome", biomeNew).attr("fill", color).attr("stroke", color);
else temp.append("polygon").attr("data-cell", i).attr("data-biome", biomeNew).attr("points", getPackPolygon(i)).attr("fill", color).attr("stroke", color);
});
}
function moveBiomeBrush() {
showMainTip();
const point = d3.mouse(this);
const radius = +biomesManuallyBrush.value;
moveCircle(point[0], point[1], radius);
}
function applyBiomesChange() {
const changed = biomes.select("#temp").selectAll("polygon");
changed.each(function() {
const i = +this.dataset.cell;
const b = +this.dataset.biome;
pack.cells.biome[i] = b;
});
if (changed.size()) {
drawBiomes();
refreshBiomesEditor();
}
exitBiomesCustomizationMode();
}
function exitBiomesCustomizationMode() {
customization = 0;
biomes.select("#temp").remove();
removeCircle();
document.querySelectorAll("#biomesBottom > button").forEach(el => el.style.display = "inline-block");
document.querySelectorAll("#biomesBottom > div").forEach(el => el.style.display = "none");
body.querySelectorAll("div > *").forEach(e => e.disabled = false);
restoreDefaultEvents();
clearMainTip();
const selected = document.querySelector("#biomesBody > div.selected");
if (selected) selected.classList.remove("selected");
}
function restoreInitialBiomes() {
biomesData = applyDefaultBiomesSystem();
defineBiomes();
drawBiomes();
recalculatePopulation();
refreshBiomesEditor();
}
function closeBiomesEditor() {
//biomes.on("mousemove", null).on("mouseleave", null);
exitBiomesCustomizationMode();
}
}

336
modules/ui/burg-editor.js Normal file
View file

@ -0,0 +1,336 @@
"use strict";
function editBurg() {
if (customization) return;
closeDialogs(".stable");
if (!layerIsOn("toggleIcons")) toggleIcons();
if (!layerIsOn("toggleLabels")) toggleLabels();
const id = +d3.event.target.dataset.id;
elSelected = burgLabels.select("[data-id='" + id + "']");
burgLabels.selectAll("text").call(d3.drag().on("start", dragBurgLabel)).classed("draggable", true);
selectBurgGroup(event.target);
document.getElementById("burgNameInput").value = elSelected.text();
const my = elSelected.attr("id") == d3.event.target.id ? "center bottom" : "center top+10";
const at = elSelected.attr("id") == d3.event.target.id ? "top" : "bottom";
$("#burgEditor").dialog({
title: "Edit Burg: " + elSelected.text(), resizable: false,
position: {my, at, of: d3.event.target, collision: "fit"},
close: closeBurgEditor
});
if (modules.editBurg) return;
modules.editBurg = true;
// add listeners
document.getElementById("burgGroupShow").addEventListener("click", showGroupSection);
document.getElementById("burgGroupHide").addEventListener("click", hideGroupSection);
document.getElementById("burgSelectGroup").addEventListener("change", changeGroup);
document.getElementById("burgInputGroup").addEventListener("change", createNewGroup);
document.getElementById("burgAddGroup").addEventListener("click", toggleNewGroupInput);
document.getElementById("burgRemoveGroup").addEventListener("click", removeBurgsGroup);
document.getElementById("burgNameShow").addEventListener("click", showNameSection);
document.getElementById("burgNameHide").addEventListener("click", hideNameSection);
document.getElementById("burgNameInput").addEventListener("input", changeName);
document.getElementById("burgNameReCulture").addEventListener("click", generateNameCulture);
document.getElementById("burgNameReRandom").addEventListener("click", generateNameRandom);
document.getElementById("burgSeeInMFCG").addEventListener("click", openInMFCG);
document.getElementById("burgRelocate").addEventListener("click", toggleRelocateBurg);
document.getElementById("burglLegend").addEventListener("click", editBurgLegend);
document.getElementById("burgRemove").addEventListener("click", removeSelectedBurg);
function dragBurgLabel() {
const tr = parseTransform(this.getAttribute("transform"));
const dx = +tr[0] - d3.event.x, dy = +tr[1] - d3.event.y;
d3.event.on("drag", function() {
const x = d3.event.x, y = d3.event.y;
this.setAttribute("transform", `translate(${(dx+x)},${(dy+y)})`);
tip('Use dragging for fine-tuning only, to actually move burg use "Relocate" button', false, "warning");
});
}
function selectBurgGroup(node) {
const group = node.parentNode.id;
const select = document.getElementById("burgSelectGroup");
select.options.length = 0; // remove all options
burgLabels.selectAll("g").each(function() {
select.options.add(new Option(this.id, this.id, false, this.id === group));
});
}
function showGroupSection() {
document.querySelectorAll("#burgEditor > button").forEach(el => el.style.display = "none");
document.getElementById("burgGroupSection").style.display = "inline-block";
}
function hideGroupSection() {
document.querySelectorAll("#burgEditor > button").forEach(el => el.style.display = "inline-block");
document.getElementById("burgGroupSection").style.display = "none";
document.getElementById("burgInputGroup").style.display = "none";
document.getElementById("burgInputGroup").value = "";
document.getElementById("burgSelectGroup").style.display = "inline-block";
}
function changeGroup() {
const id = +elSelected.attr("data-id");
moveBurgToGroup(id, this.value);
}
function toggleNewGroupInput() {
if (burgInputGroup.style.display === "none") {
burgInputGroup.style.display = "inline-block";
burgInputGroup.focus();
burgSelectGroup.style.display = "none";
} else {
burgInputGroup.style.display = "none";
burgSelectGroup.style.display = "inline-block";
}
}
function createNewGroup() {
if (!this.value) {tip("Please provide a valid group name"); return;}
let group = this.value.toLowerCase().replace(/ /g, "_").replace(/[^\w\s]/gi, "");
if (Number.isFinite(+group.charAt(0))) group = "g" + group;
if (document.getElementById(group)) {
tip("Element with this id already exists. Please provide a unique name", false, "error");
return;
}
const id = +elSelected.attr("data-id");
const oldGroup = elSelected.node().parentNode.id;
const label = document.querySelector("#burgLabels [data-id='" + id + "']");
const icon = document.querySelector("#burgIcons [data-id='" + id + "']");
const anchor = document.querySelector("#anchors [data-id='" + id + "']");
if (!label || !icon) {console.error("Cannot find label or icon elements"); return;}
const labelG = document.querySelector("#burgLabels > #"+oldGroup);
const iconG = document.querySelector("#burgIcons > #"+oldGroup);
const anchorG = document.querySelector("#anchors > #"+oldGroup);
// just rename if only 1 element left
const count = elSelected.node().parentNode.childElementCount;
if (oldGroup !== "cities" && oldGroup !== "towns" && count === 1) {
document.getElementById("burgSelectGroup").selectedOptions[0].remove();
document.getElementById("burgSelectGroup").options.add(new Option(group, group, false, true));
toggleNewGroupInput();
document.getElementById("burgInputGroup").value = "";
labelG.id = group;
iconG.id = group;
if (anchor) anchorG.id = group;
return;
}
// create new groups
document.getElementById("burgSelectGroup").options.add(new Option(group, group, false, true));
toggleNewGroupInput();
document.getElementById("burgInputGroup").value = "";
const newLabelG = document.querySelector("#burgLabels").appendChild(labelG.cloneNode(false));
newLabelG.id = group;
const newIconG = document.querySelector("#burgIcons").appendChild(iconG.cloneNode(false));
newIconG.id = group;
if (anchor) {
const newAnchorG = document.querySelector("#anchors").appendChild(anchorG.cloneNode(false));
newAnchorG.id = group;
}
moveBurgToGroup(id, group);
}
function removeBurgsGroup() {
const group = elSelected.node().parentNode;
const basic = group.id === "cities" || group.id === "towns";
const burgsInGroup = [];
for (let i=0; i < group.children.length; i++) {
burgsInGroup.push(+group.children[i].dataset.id);
}
const burgsToRemove = burgsInGroup.filter(b => !pack.burgs[b].capital);
const capital = burgsToRemove.length < burgsInGroup.length;
alertMessage.innerHTML = `Are you sure you want to remove
${basic || capital ? "all elements in the group" : "the entire burg group"}?
<br>Please note that capital burgs will not be deleted.
<br><br>Burgs to be removed: ${burgsToRemove.length}`;
$("#alert").dialog({resizable: false, title: "Remove route group",
buttons: {
Remove: function() {
$(this).dialog("close");
$("#burgEditor").dialog("close");
hideGroupSection();
burgsToRemove.forEach(b => removeBurg(b));
if (!basic && !capital) {
// entirely remove group
const labelG = document.querySelector("#burgLabels > #"+group.id);
const iconG = document.querySelector("#burgIcons > #"+group.id);
const anchorG = document.querySelector("#anchors > #"+group.id);
if (labelG) labelG.remove();
if (iconG) iconG.remove();
if (anchorG) anchorG.remove();
}
},
Cancel: function() {$(this).dialog("close");}
}
});
}
function showNameSection() {
document.querySelectorAll("#burgEditor > button").forEach(el => el.style.display = "none");
document.getElementById("burgNameSection").style.display = "inline-block";
}
function hideNameSection() {
document.querySelectorAll("#burgEditor > button").forEach(el => el.style.display = "inline-block");
document.getElementById("burgNameSection").style.display = "none";
}
function changeName() {
const id = +elSelected.attr("data-id");
pack.burgs[id].name = burgNameInput.value;
elSelected.text(burgNameInput.value);
}
function generateNameCulture() {
const id = +elSelected.attr("data-id");
const culture = pack.burgs[id].culture;
burgNameInput.value = Names.getCulture(culture);
changeName();
}
function generateNameRandom() {
const base = rand(nameBase.length-1);
burgNameInput.value = Names.getBase(base);
changeName();
}
function openInMFCG() {
const id = elSelected.attr("data-id");
const name = elSelected.text();
const cell = pack.burgs[id].cell;
const pop = rn(pack.burgs[id].population);
const size = Math.max(Math.min(pop, 65), 6);
// MFCG seed is FMG map seed + burg id padded to 4 chars with zeros
const s = seed + id.padStart(4, 0);
const hub = +pack.cells.road[cell] > 50;
const river = pack.cells.r[cell] ? 1 : 0;
const coast = +pack.burgs[id].port;
const half = rn(pop) % 2;
const most = (+id + rn(pop)) % 3 ? 1 : 0;
const walls = pop > 10 && half || pop > 20 && most || pop > 30 ? 1 : 0;;
const shanty = pop > 40 && half || pop > 60 && most || pop > 80 ? 1 : 0;
const temple = pop > 50 && half || pop > 80 && most || pop > 100 ? 1 : 0;
const url = `http://fantasycities.watabou.ru/?name=${name}&size=${size}&seed=${s}&hub=${hub}&random=0&continuous=0&river=${river}&coast=${coast}&citadel=${half}&plaza=${half}&temple=${temple}&walls=${walls}&shantytown=${shanty}`;
window.open(url, '_blank');
}
function toggleRelocateBurg() {
const toggler = document.getElementById("toggleCells");
document.getElementById("burgRelocate").classList.toggle("pressed");
if (document.getElementById("burgRelocate").classList.contains("pressed")) {
viewbox.style("cursor", "crosshair").on("click", relocateBurgOnClick);
tip("Click on map to relocate burg. Hold Shift for continuous move", true);
if (!layerIsOn("toggleCells")) {toggleCells(); toggler.dataset.forced = true;}
} else {
clearMainTip();
viewbox.on("click", clicked).style("cursor", "default");
if (layerIsOn("toggleCells") && toggler.dataset.forced) {toggleCells(); toggler.dataset.forced = false;}
}
}
function relocateBurgOnClick() {
const cells = pack.cells;
const point = d3.mouse(this);
const cell = findCell(point[0], point[1]);
const id = +elSelected.attr("data-id");
const burg = pack.burgs[id];
if (cells.h[cell] < 20) {
tip("Cannot place burg into the water! Select a land cell", false, "error");
return;
}
if (cells.burg[cell] && cells.burg[cell] !== id) {
tip("There is already a burg in this cell. Please select a free cell", false, "error");
return;
}
const newState = cells.state[cell];
const oldState = burg.state;
if (newState !== oldState && burg.capital) {
tip("Capital cannot be relocated into another state!", false, "error");
return;
}
// change UI
const x = rn(point[0], 2), y = rn(point[1], 2);
burgIcons.select("[data-id='" + id + "']").attr("transform", null).attr("cx", x).attr("cy", y);
burgLabels.select("text[data-id='" + id + "']").attr("transform", null).attr("x", x).attr("y", y);
const anchor = anchors.select("use[data-id='" + id+ "']");
if (anchor.size()) {
const size = anchor.attr("width");
const xa = rn(x - size * 0.47, 2);
const ya = rn(y - size * 0.47, 2);
anchor.attr("transform", null).attr("x", xa).attr("y", ya);
}
// change data
cells.burg[burg.cell] = 0;
cells.burg[cell] = id;
burg.cell = cell;
burg.state = newState;
burg.x = x;
burg.y = y;
if (burg.capital) pack.states[newState].center = burg.cell;
if (d3.event.shiftKey === false) toggleRelocateBurg();
}
function editBurgLegend() {
const id = elSelected.attr("data-id");
const name = elSelected.text();
editLegends("burg"+id, name);
}
function removeSelectedBurg() {
const id = +elSelected.attr("data-id");
const capital = pack.burgs[id].capital;
if (capital) {
alertMessage.innerHTML = `You cannot remove the burg as it is a capital.<br><br>
You can change the capital using the Burgs Editor`;
$("#alert").dialog({resizable: false, title: "Remove burg",
buttons: {Ok: function() {$(this).dialog("close");}}
});
} else {
alertMessage.innerHTML = "Are you sure you want to remove the burg?";
$("#alert").dialog({resizable: false, title: "Remove burg",
buttons: {
Remove: function() {
$(this).dialog("close");
removeBurg(id); // see Editors module
$("#burgEditor").dialog("close");
},
Cancel: function() {$(this).dialog("close");}
}
});
}
}
function closeBurgEditor() {
document.getElementById("burgRelocate").classList.remove("pressed");
burgLabels.selectAll("text").call(d3.drag().on("drag", null)).classed("draggable", false);
unselect();
}
}

332
modules/ui/burgs-editor.js Normal file
View file

@ -0,0 +1,332 @@
"use strict";
function editBurgs() {
if (customization) return;
closeDialogs("#burgsEditor, .stable");
if (!layerIsOn("toggleIcons")) toggleIcons();
if (!layerIsOn("toggleLabels")) toggleLabels();
const body = document.getElementById("burgsBody");
updateFilter();
burgsEditorAddLines();
if (modules.editBurgs) return;
modules.editBurgs = true;
$("#burgsEditor").dialog({title: "Burgs Editor", width: fitContent(), close: exitAddBurgMode,
position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}
});
// add listeners
document.getElementById("burgsEditorRefresh").addEventListener("click", burgsEditorAddLines);
document.getElementById("burgsFilterState").addEventListener("change", burgsEditorAddLines);
document.getElementById("burgsFilterCulture").addEventListener("change", burgsEditorAddLines);
document.getElementById("regenerateBurgNames").addEventListener("click", regenerateNames);
document.getElementById("addNewBurg").addEventListener("click", enterAddBurgMode);
document.getElementById("burgsExport").addEventListener("click", downloadBurgsData);
document.getElementById("burgNamesImport").addEventListener("click", e => burgsListToLoad.click());
document.getElementById("burgsListToLoad").addEventListener("change", importBurgNames);
document.getElementById("burgsRemoveAll").addEventListener("click", triggerAllBurgsRemove);
function updateFilter() {
const stateFilter = document.getElementById("burgsFilterState");
const selectedState = stateFilter.value || 1;
stateFilter.options.length = 0; // remove all options
stateFilter.options.add(new Option("all", -1, false, selectedState == -1));
pack.states.forEach(s => stateFilter.options.add(new Option(s.name, s.i, false, s.i == selectedState)));
const cultureFilter = document.getElementById("burgsFilterCulture");
const selectedCulture = cultureFilter.value || -1;
cultureFilter.options.length = 0; // remove all options
cultureFilter.options.add(new Option("all", -1, false, selectedCulture == -1));
pack.cultures.forEach(c => cultureFilter.options.add(new Option(c.name, c.i, false, c.i == selectedCulture)));
}
// add line for each state
function burgsEditorAddLines() {
const selectedState = +document.getElementById("burgsFilterState").value;
const selectedCulture = +document.getElementById("burgsFilterCulture").value;
let filtered = pack.burgs.filter(b => b.i && !b.removed); // all valid burgs
if (selectedState != -1) filtered = filtered.filter(b => b.state === selectedState); // filtered by state
if (selectedCulture != -1) filtered = filtered.filter(b => b.culture === selectedCulture); // filtered by culture
const showState = selectedState == -1 ? "visible" : "hidden";
document.getElementById("burgStateHeader").style.display = `${selectedState == -1 ? "inline-block" : "none"}`;
body.innerHTML = "";
let lines = "", totalPopulation = 0;
for (const b of filtered) {
const population = rn(b.population * populationRate.value * urbanization.value);
totalPopulation += population;
const type = b.capital && b.port ? "a-capital-port" : b.capital ? "c-capital" : b.port ? "p-port" : "z-burg";
const state = pack.states[b.state].name;
const culture = pack.cultures[b.culture].name;
lines += `<div class="states" data-id=${b.i} data-name=${b.name} data-state=${state} data-culture=${culture} data-population=${population} data-type=${type}>
<span data-tip="Click to zoom into view" class="icon-dot-circled pointer"></span>
<input data-tip="Burg name. Click and type to change" class="burgName" value="${b.name}" autocorrect="off" spellcheck="false">
<span data-tip="Burg state" class="burgState ${showState}">${state}</span>
<select data-tip="Dominant culture. Click to change" class="stateCulture">${getCultureOptions(b.culture)}</select>
<span data-tip="Burg population" class="icon-male"></span>
<input data-tip="Burg population. Type to change" class="burgPopulation" value=${population}>
<div class="burgType">
<span data-tip="${b.capital ? ' This burg is a state capital' : 'Click to assign a capital status'}" class="icon-star-empty${b.capital ? '' : ' inactive pointer'}"></span>
<span data-tip="Click to toggle port status" class="icon-anchor pointer${b.port ? '' : ' inactive'}" style="font-size:.9em"></span>
</div>
<span data-tip="Remove burg" class="icon-trash-empty"></span>
</div>`;
}
body.insertAdjacentHTML("beforeend", lines);
// update footer
burgsFooterBurgs.innerHTML = filtered.length;
burgsFooterPopulation.innerHTML = filtered.length ? rn(totalPopulation / filtered.length) : 0;
// add listeners
body.querySelectorAll("div.states").forEach(el => el.addEventListener("mouseenter", ev => burgHighlightOn(ev)));
body.querySelectorAll("div.states").forEach(el => el.addEventListener("mouseleave", ev => burgHighlightOff(ev)));
body.querySelectorAll("div > input.burgName").forEach(el => el.addEventListener("input", changeBurgName));
body.querySelectorAll("div > span.icon-dot-circled").forEach(el => el.addEventListener("click", zoomIntoBurg));
body.querySelectorAll("div > select.burgCulture").forEach(el => el.addEventListener("click", updateCulturesList));
body.querySelectorAll("div > input.burgPopulation").forEach(el => el.addEventListener("change", changeBurgPopulation));
body.querySelectorAll("div > span.icon-star-empty").forEach(el => el.addEventListener("click", toggleCapitalStatus));
body.querySelectorAll("div > span.icon-anchor").forEach(el => el.addEventListener("click", togglePortStatus));
body.querySelectorAll("div > span.icon-trash-empty").forEach(el => el.addEventListener("click", triggerBurgRemove));
applySorting(burgsHeader);
$("#burgsEditor").dialog();
}
function getCultureOptions(culture) {
let options = "";
pack.cultures.slice(1).forEach(c => options += `<option ${c.i === culture ? "selected" : ""} value="${c.i}">${c.name}</option>`);
return options;
}
function burgHighlightOn(event) {
if (!layerIsOn("toggleLabels")) toggleLabels();
const burg = +event.target.dataset.id;
burgLabels.select("[data-id='" + burg + "']").classed("drag", true);
}
function burgHighlightOff() {
burgLabels.selectAll("text.drag").classed("drag", false);
}
function changeBurgName() {
if (this.value == "")tip("Please provide a name", false, "error");
const burg = +this.parentNode.dataset.id;
pack.burgs[burg].name = this.value;
this.parentNode.dataset.name = this.value;
const label = document.querySelector("#burgLabels [data-id='" + burg + "']");
if (label) label.innerHTML = this.value;
}
function zoomIntoBurg() {
const burg = +this.parentNode.dataset.id;
const label = document.querySelector("#burgLabels [data-id='" + burg + "']");
const x = +label.getAttribute("x"), y = +label.getAttribute("y");
zoomTo(x, y, 8, 2000);
}
function updateCulturesList() {
const burg = +this.parentNode.dataset.id;
const v = +this.value;
pack.burgs[burg].culture = v;
this.parentNode.dataset.culture = pack.cultures[v].name;
this.options.length = 0;
pack.cultures.slice(1).forEach(c => this.options.add(new Option(c.name, c.i, false, c.i === v)));
}
function changeBurgPopulation() {
const burg = +this.parentNode.dataset.id;
if (this.value == "" || isNaN(+this.value)) {
tip("Please provide a valid number", false, "error");
this.value = pack.burgs[burg].population * populationRate.value * urbanization.value;
return;
}
pack.burgs[burg].population = this.value / populationRate.value / urbanization.value;
this.parentNode.dataset.population = this.value;
const population = [];
body.querySelectorAll(":scope > div").forEach(el => population.push(+el.dataset.population));
pack.burgsFooterPopulation.innerHTML = rn(d3.mean(population));
}
function toggleCapitalStatus() {
const burg = +this.parentNode.parentNode.dataset.id, state = pack.burgs[burg].state;
if (pack.burgs[burg].capital) {tip("To change capital please assign capital status to another burg", false, "error"); return;}
if (!state) {tip("Neutral lands cannot have a capital", false, "error"); return;}
const old = pack.states[state].capital;
// change statuses
pack.states[state].capital = burg;
pack.burgs[burg].capital = true;
pack.burgs[old].capital = false;
moveBurgToGroup(burg, "cities");
moveBurgToGroup(old, "towns");
burgsEditorAddLines();
}
function togglePortStatus() {
const burg = +this.parentNode.parentNode.dataset.id;
const anchor = document.querySelector("#anchors [data-id='" + burg + "']");
if (anchor) anchor.remove();
if (!pack.burgs[burg].port) {
const haven = pack.cells.haven[pack.burgs[burg].cell];
const port = haven ? pack.cells.f[haven] : -1;
if (!haven) tip("Port haven is not found, system won't be able to make a searoute", false, "warning");
pack.burgs[burg].port = port;
const g = pack.burgs[burg].capital ? "cities" : "towns";
const group = anchors.select("g#"+g);
const size = +group.attr("size");
group.append("use").attr("xlink:href", "#icon-anchor").attr("data-id", burg)
.attr("x", rn(pack.burgs[burg].x - size * .47, 2)).attr("y", rn(pack.burgs[burg].y - size * .47, 2))
.attr("width", size).attr("height", size);
} else {
pack.burgs[burg].port = 0;
}
burgsEditorAddLines();
}
function triggerBurgRemove() {
const burg = +this.parentNode.dataset.id;
if (pack.burgs[burg].capital) {tip("You cannot remove the capital. Please change the capital first", false, "error"); return;}
removeBurg(burg);
burgsEditorAddLines();
}
function regenerateNames() {
body.querySelectorAll(":scope > div").forEach(function(el) {
const burg = +el.dataset.id;
const culture = pack.burgs[burg].culture;
const name = Names.getCulture(culture);
el.querySelector(".burgName").value = name;
pack.burgs[burg].name = el.dataset.name = name;
burgLabels.select("[data-id='" + burg + "']").text(name);
});
}
function enterAddBurgMode() {
if (this.classList.contains("pressed")) {exitAddBurgMode(); return;};
customization = 3;
this.classList.add("pressed");
tip("Click on the map to create a new burg. Hold Shift to add multiple", true);
viewbox.style("cursor", "crosshair").on("click", addBurgOnClick);
body.querySelectorAll("div > *").forEach(e => e.disabled = true);
}
function addBurgOnClick() {
const point = d3.mouse(this);
const cell = findCell(point[0], point[1]);
if (pack.cells.h[cell] < 20) {tip("You cannot place state into the water. Please click on a land cell", false, "error"); return;}
if (pack.cells.burg[cell]) {tip("There is already a burg in this cell. Please select a free cell", false, "error"); return;}
addBurg(point); // add new burg
if (d3.event.shiftKey === false) {
exitAddBurgMode();
burgsEditorAddLines();
}
}
function exitAddBurgMode() {
customization = 0;
restoreDefaultEvents();
clearMainTip();
body.querySelectorAll("div > *").forEach(e => e.disabled = false);
if (addBurgTool.classList.contains("pressed")) addBurgTool.classList.remove("pressed");
if (addNewBurg.classList.contains("pressed")) addNewBurg.classList.remove("pressed");
}
function downloadBurgsData() {
let data = "Id,Burg,State,Culture,Population,Capital,Port\n"; // headers
const valid = pack.burgs.filter(b => b.i && !b.removed); // all valid burgs
valid.forEach(b => {
data += b.i + ",";
data += b.name + ",";
data += pack.states[b.state].name + ",";
data += pack.cultures[b.culture].name + ",";
data += rn(b.population * populationRate.value * urbanization.value) + ",";
data += b.capital ? "capital," : ",";
data += b.port ? "port\n" : "\n";
});
const dataBlob = new Blob([data], {type: "text/plain"});
const url = window.URL.createObjectURL(dataBlob);
const link = document.createElement("a");
document.body.appendChild(link);
link.download = "burgs_data" + Date.now() + ".csv";
link.href = url;
link.click();
window.setTimeout(function() {window.URL.revokeObjectURL(url);}, 2000);
}
function importBurgNames() {
const el = document.getElementById("burgsListToLoad");
const fileToLoad = el.files[0];
el.value = "";
const fileReader = new FileReader();
fileReader.onload = function(e) {
const dataLoaded = e.target.result;
const data = dataLoaded.split("\r\n");
if (!data.length) {tip("Cannot parse the list, please check the file format", false, "error"); return;}
let change = [];
let message = `Burgs will be renamed as below. Please confirm`;
message += `<div class="overflow-div"><table class="overflow-table"><tr><th>Id</th><th>Current name</th><th>New Name</th></tr>`;
for (let i=1; i < data.length && i < pack.burgs.length; i++) {
const v = data[i];
if (!v || v == pack.burgs[i].name) continue;
change.push({i, name: v});
message += `<tr><td style="width:20%">${i}</td><td style="width:40%">${pack.burgs[i].name}</td><td style="width:40%">${v}</td></tr>`;
}
message += `</tr></table></div>`;
alertMessage.innerHTML = message;
$("#alert").dialog({title: "Burgs bulk renaming", position: {my: "center", at: "center", of: "svg"},
buttons: {
Cancel: function() {$(this).dialog("close");},
Confirm: function() {
for (let i=0; i < change.length; i++) {
const id = change[i].i;
pack.burgs[id].name = change[i].name;
burgLabels.select("[data-id='" + id + "']").text(change[i].name);
}
$(this).dialog("close");
burgsEditorAddLines();
}
}
});
}
fileReader.readAsText(fileToLoad, "UTF-8");
}
function triggerAllBurgsRemove() {
alertMessage.innerHTML = `Are you sure you want to remove all burgs except of capitals?
<br>To remove a capital you have to remove its state first`;
$("#alert").dialog({resizable: false, title: "Remove all burgs",
buttons: {
Remove: function() {
$(this).dialog("close");
removeAllBurgs();
},
Cancel: function() {$(this).dialog("close");}
}
});
}
function removeAllBurgs() {
pack.burgs.filter(b => b.i && !b.capital).forEach(b => removeBurg(b.i));
burgsEditorAddLines();
}
}

View file

@ -0,0 +1,435 @@
"use strict";
function editCultures() {
if (customization) return;
closeDialogs("#culturesEditor, .stable");
if (!layerIsOn("toggleCultures")) toggleCultures();
if (layerIsOn("toggleStates")) toggleStates();
if (layerIsOn("toggleBiomes")) toggleBiomes();
const body = document.getElementById("culturesBody");
const animate = d3.transition().duration(2000).ease(d3.easeSinIn);
drawCultureCenters();
refreshCulturesEditor();
if (modules.editCultures) return;
modules.editCultures = true;
$("#culturesEditor").dialog({
title: "Cultures Editor", width: fitContent(), close: closeCulturesEditor,
position: {my: "right top", at: "right-10 top+10", of: "svg"}
});
// add listeners
document.getElementById("culturesEditorRefresh").addEventListener("click", refreshCulturesEditor);
document.getElementById("culturesPercentage").addEventListener("click", togglePercentageMode);
document.getElementById("culturesRecalculate").addEventListener("click", recalculateCultures);
document.getElementById("culturesManually").addEventListener("click", enterCultureManualAssignent);
document.getElementById("culturesManuallyApply").addEventListener("click", applyCultureManualAssignent);
document.getElementById("culturesManuallyCancel").addEventListener("click", exitCulturesManualAssignment);
document.getElementById("culturesEditNamesBase").addEventListener("click", editNamesbase);
document.getElementById("culturesAdd").addEventListener("click", addCulture);
document.getElementById("culturesExport").addEventListener("click", downloadCulturesData);
function refreshCulturesEditor() {
culturesCollectStatistics();
culturesEditorAddLines();
}
function culturesCollectStatistics() {
const cells = pack.cells, cultures = pack.cultures;
cultures.forEach(c => c.cells = c.area = c.rural = c.urban = 0);
for (const i of cells.i) {
if (cells.h[i] < 20) continue;
const c = cells.culture[i];
cultures[c].cells += 1;
cultures[c].area += cells.area[i];
cultures[c].rural += cells.pop[i];
if (cells.burg[i]) cultures[c].urban += pack.burgs[cells.burg[i]].population;
}
}
// add line for each culture
function culturesEditorAddLines() {
const unit = areaUnit.value === "square" ? " " + distanceUnit.value + "²" : " " + areaUnit.value;
let lines = "", totalArea = 0, totalPopulation = 0;
for (const c of pack.cultures) {
if (c.removed) continue;
const area = c.area * (distanceScale.value ** 2);
const rural = c.rural * populationRate.value;
const urban = c.urban * populationRate.value * urbanization.value;
const population = rural + urban;
const populationTip = `Total population: ${si(population)}; Rural population: ${si(rural)}; Urban population: ${si(urban)}`;
totalArea += area;
totalPopulation += population;
if (!c.i) {
// Uncultured (neutral) line
lines += `<div class="states" data-id=${c.i} data-name="${c.name}" data-color="" data-cells=${c.cells}
data-area=${area} data-population=${population} data-base=${c.base} data-type="" data-expansionism="">
<input class="stateColor placeholder" type="color">
<input data-tip="Culture name. Click and type to change" class="cultureName italic" value="${c.name}" autocorrect="off" spellcheck="false">
<span data-tip="Cells count" class="icon-check-empty"></span>
<div data-tip="Cells count" class="stateCells">${c.cells}</div>
<span class="icon-resize-full placeholder"></span>
<input class="statePower placeholder" type="number">
<select class="cultureType placeholder">${getTypeOptions(c.type)}</select>
<span data-tip="Culture area" style="padding-right: 4px" class="icon-map-o"></span>
<div data-tip="Culture area" class="biomeArea">${si(area) + unit}</div>
<span data-tip="${populationTip}" class="icon-male"></span>
<div data-tip="${populationTip}" class="culturePopulation">${si(population)}</div>
<span data-tip="Click to re-generate names for burgs with this culture assigned" class="icon-arrows-cw"></span>
<select data-tip="Culture namesbase. Click to change" class="cultureBase">${getBaseOptions(c.base)}</select>
</div>`;
continue;
}
lines += `<div class="states cultures" data-id=${c.i} data-name="${c.name}" data-color="${c.color}" data-cells=${c.cells}
data-area=${area} data-population=${population} data-base=${c.base} data-type=${c.type} data-expansionism=${c.expansionism}>
<input data-tip="Culture color. Click to change" class="stateColor" type="color" value="${c.color}">
<input data-tip="Culture name. Click and type to change" class="cultureName" value="${c.name}" autocorrect="off" spellcheck="false">
<span data-tip="Cells count" class="icon-check-empty"></span>
<div data-tip="Cells count" class="stateCells">${c.cells}</div>
<span data-tip="Culture expansionism (defines competitive size)" class="icon-resize-full"></span>
<input data-tip="Expansionism (defines competitive size). Change to re-calculate cultures based on new value" class="statePower" type="number" min=0 max=99 step=.1 value=${c.expansionism}>
<select data-tip="Culture type. Change to re-calculate cultures based on new value" class="cultureType">${getTypeOptions(c.type)}</select>
<span data-tip="Culture area" style="padding-right: 4px" class="icon-map-o"></span>
<div data-tip="Culture area" class="biomeArea">${si(area) + unit}</div>
<span data-tip="${populationTip}" class="icon-male"></span>
<div data-tip="${populationTip}" class="culturePopulation">${si(population)}</div>
<span data-tip="Click to re-generate names for burgs with this culture assigned" class="icon-arrows-cw"></span>
<select data-tip="Culture namesbase. Change and then click on the Re-generate button to get new names" class="cultureBase">${getBaseOptions(c.base)}</select>
<span data-tip="Remove culture" class="icon-trash-empty"></span>
</div>`;
}
body.innerHTML = lines;
// update footer
culturesFooterCultures.innerHTML = pack.cultures.filter(c => c.i && !c.removed).length;
culturesFooterCells.innerHTML = pack.cells.h.filter(h => h >= 20).length;
culturesFooterArea.innerHTML = si(totalArea) + unit;
culturesFooterPopulation.innerHTML = si(totalPopulation);
culturesFooterArea.dataset.area = totalArea;
culturesFooterPopulation.dataset.population = totalPopulation;
// add listeners
body.querySelectorAll("div.cultures").forEach(el => el.addEventListener("mouseenter", ev => cultureHighlightOn(ev)));
body.querySelectorAll("div.cultures").forEach(el => el.addEventListener("mouseleave", ev => cultureHighlightOff(ev)));
body.querySelectorAll("div.states").forEach(el => el.addEventListener("click", selectCultureOnLineClick));
body.querySelectorAll("div > input[type='color']").forEach(el => el.addEventListener("input", cultureChangeColor));
body.querySelectorAll("div > input.cultureName").forEach(el => el.addEventListener("input", cultureChangeName));
body.querySelectorAll("div > input.statePower").forEach(el => el.addEventListener("input", cultureChangeExpansionism));
body.querySelectorAll("div > select.cultureType").forEach(el => el.addEventListener("change", cultureChangeType));
body.querySelectorAll("div > select.cultureBase").forEach(el => el.addEventListener("click", updateBaseOptions));
body.querySelectorAll("div > span.icon-arrows-cw").forEach(el => el.addEventListener("click", cultureRegenerateBurgs));
body.querySelectorAll("div > span.icon-trash-empty").forEach(el => el.addEventListener("click", cultureRemove));
if (body.dataset.type === "percentage") {body.dataset.type = "absolute"; togglePercentageMode();}
applySorting(culturesHeader);
$("#culturesEditor").dialog();
}
function getTypeOptions(type) {
let options = "";
const types = ["Generic", "River", "Lake", "Naval", "Nomadic", "Hunting", "Highland"];
types.forEach(t => options += `<option ${type === t ? "selected" : ""} value="${t}">${t}</option>`);
return options;
}
function getBaseOptions(base) {
let options = "";
nameBases.forEach((n, i) => options += `<option ${base === i ? "selected" : ""} value="${i}">${n.name}</option>`);
return options;
}
function cultureHighlightOn(event) {
if (customization === 4) return;
const culture = +event.target.dataset.id;
const color = d3.interpolateLab(pack.cultures[culture].color, "#ff0000")(.8)
cults.select("#culture"+culture).raise().transition(animate).attr("stroke-width", 3).attr("stroke", color);
debug.select("#cultureCenter"+culture).raise().transition(animate).attr("r", 8);
}
function cultureHighlightOff(event) {
if (customization === 4) return;
const culture = +event.target.dataset.id;
cults.select("#culture"+culture).transition().attr("stroke-width", .7).attr("stroke", pack.cultures[culture].color);
debug.select("#cultureCenter"+culture).transition().attr("r", 6);
}
function cultureChangeColor() {
const culture = +this.parentNode.dataset.id;
pack.cultures[culture].color = this.value;
cults.select("#culture"+culture).attr("fill", this.value).attr("stroke", this.value);
debug.select("#cultureCenter"+culture).attr("fill", this.value);
}
function cultureChangeName() {
const culture = +this.parentNode.dataset.id;
this.parentNode.dataset.name = this.value;
pack.cultures[culture].name = this.value;
}
function cultureChangeExpansionism() {
const culture = +this.parentNode.dataset.id;
this.parentNode.dataset.expansionism = this.value;
pack.cultures[culture].expansionism = +this.value;
recalculateCultures();
}
function cultureChangeType() {
const culture = +this.parentNode.dataset.id;
this.parentNode.dataset.type = this.value;
pack.cultures[culture].type = this.value;
recalculateCultures();
}
function updateBaseOptions() {
const culture = +this.parentNode.dataset.id;
const v = +this.value;
this.parentNode.dataset.base = pack.cultures[culture].base = v;
this.options.length = 0;
nameBases.forEach((b, i) => this.options.add(new Option(b.name, i, false, i === v)));
}
function cultureRegenerateBurgs() {
if (customization === 4) return;
const culture = +this.parentNode.dataset.id;
const cBurgs = pack.burgs.filter(b => b.culture === culture);
cBurgs.forEach(b => {
b.name = Names.getCulture(culture);
labels.select("[data-id='" + b.i +"']").text(b.name);
});
tip(`Names for ${cBurgs.length} burgs are re-generated`);
}
function cultureRemove() {
if (customization === 4) return;
const culture = +this.parentNode.dataset.id;
cults.select("#culture"+culture).remove();
debug.select("#cultureCenter"+culture).remove();
pack.burgs.filter(b => b.culture === culture).forEach(b => b.culture = 0);
pack.cells.culture.forEach((c, i) => {if(c === culture) pack.cells.culture[i] = 0;});
pack.cultures[culture].removed = true;
refreshCulturesEditor();
}
function drawCultureCenters() {
const tooltip = 'Drag to move the culture center (ancestral home)';
debug.select("#cultureCenters").remove();
const cultureCenters = debug.append("g").attr("id", "cultureCenters");
const data = pack.cultures.filter(c => c.i && !c.removed);
cultureCenters.selectAll("circle").data(data).enter().append("circle")
.attr("id", d => "cultureCenter"+d.i).attr("data-id", d => d.i)
.attr("r", 6).attr("fill", d => d.color)
.attr("cx", d => pack.cells.p[d.center][0]).attr("cy", d => pack.cells.p[d.center][1])
.on("mouseenter", d => {tip(tooltip, true); body.querySelector(`div[data-id='${d.i}']`).classList.add("selected"); cultureHighlightOn(event);})
.on("mouseleave", d => {tip('', true); body.querySelector(`div[data-id='${d.i}']`).classList.remove("selected"); cultureHighlightOff(event);})
.call(d3.drag().on("start", cultureCenterDrag));
}
function cultureCenterDrag() {
const el = d3.select(this);
const c = +this.id.slice(13);
d3.event.on("drag", () => {
el.attr("cx", d3.event.x).attr("cy", d3.event.y);
const cell = findCell(d3.event.x, d3.event.y);
if (pack.cells.h[cell] < 20) return; // ignore dragging on water
pack.cultures[c].center = cell;
recalculateCultures();
});
}
function togglePercentageMode() {
if (body.dataset.type === "absolute") {
body.dataset.type = "percentage";
const totalCells = +culturesFooterCells.innerHTML;
const totalArea = +culturesFooterArea.dataset.area;
const totalPopulation = +culturesFooterPopulation.dataset.population;
body.querySelectorAll(":scope > div").forEach(function(el) {
el.querySelector(".stateCells").innerHTML = rn(+el.dataset.cells / totalCells * 100) + "%";
el.querySelector(".biomeArea").innerHTML = rn(+el.dataset.area / totalArea * 100) + "%";
el.querySelector(".culturePopulation").innerHTML = rn(+el.dataset.population / totalPopulation * 100) + "%";
});
} else {
body.dataset.type = "absolute";
culturesEditorAddLines();
}
}
// re-calculate cultures
function recalculateCultures() {
pack.cells.culture = new Int8Array(pack.cells.i.length);
pack.cultures.forEach(function(c) {
if (!c.i || c.removed) return;
pack.cells.culture[c.center] = c.i;
});
Cultures.expand();
drawCultures();
pack.burgs.forEach(b => b.culture = pack.cells.culture[b.cell]);
refreshCulturesEditor();
}
function enterCultureManualAssignent() {
if (!layerIsOn("toggleCultures")) toggleCultures();
customization = 4;
cults.append("g").attr("id", "temp");
document.querySelectorAll("#culturesBottom > button").forEach(el => el.style.display = "none");
document.getElementById("culturesManuallyButtons").style.display = "inline-block";
debug.select("#cultureCenters").style("display", "none");
tip("Click on culture to select, drag the circle to change culture", true);
viewbox.style("cursor", "crosshair").call(d3.drag()
.on("drag", dragCultureBrush))
.on("click", selectCultureOnMapClick)
.on("touchmove mousemove", moveCultureBrush);
body.querySelectorAll("div > *").forEach(e => e.disabled = true);
body.querySelector("div").classList.add("selected");
}
function selectCultureOnLineClick(i) {
if (customization !== 4) return;
body.querySelector("div.selected").classList.remove("selected");
this.classList.add("selected");
}
function selectCultureOnMapClick() {
const point = d3.mouse(this);
const i = findCell(point[0], point[1]);
if (pack.cells.h[i] < 20) return;
const assigned = cults.select("#temp").select("polygon[data-cell='"+i+"']");
const culture = assigned.size() ? +assigned.attr("data-culture") : pack.cells.culture[i];
body.querySelector("div.selected").classList.remove("selected");
body.querySelector("div[data-id='"+culture+"']").classList.add("selected");
}
function dragCultureBrush() {
const p = d3.mouse(this);
const r = +culturesManuallyBrush.value;
moveCircle(p[0], p[1], r);
const found = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1], r)];
const selection = found.filter(isLand);
if (selection) changeCultureForSelection(selection);
}
// change culture within selection
function changeCultureForSelection(selection) {
const temp = cults.select("#temp");
const selected = body.querySelector("div.selected");
const cultureNew = +selected.dataset.id;
const color = pack.cultures[cultureNew].color;
selection.forEach(function(i) {
const exists = temp.select("polygon[data-cell='"+i+"']");
const cultureOld = exists.size() ? +exists.attr("data-culture") : pack.cells.culture[i];
if (cultureNew === cultureOld) return;
// change of append new element
if (exists.size()) exists.attr("data-culture", cultureNew).attr("fill", color).attr("stroke", color);
else temp.append("polygon").attr("data-cell", i).attr("data-culture", cultureNew).attr("points", getPackPolygon(i)).attr("fill", color).attr("stroke", color);
});
}
function moveCultureBrush() {
showMainTip();
const point = d3.mouse(this);
const radius = +culturesManuallyBrush.value;
moveCircle(point[0], point[1], radius);
}
function applyCultureManualAssignent() {
const changed = cults.select("#temp").selectAll("polygon");
changed.each(function() {
const i = +this.dataset.cell;
const c = +this.dataset.culture;
pack.cells.culture[i] = c;
if (pack.cells.burg[i]) pack.burgs[pack.cells.burg[i]].culture = c;
});
if (changed.size()) {
drawCultures();
refreshCulturesEditor();
}
exitCulturesManualAssignment();
}
function exitCulturesManualAssignment() {
customization = 0;
cults.select("#temp").remove();
removeCircle();
document.querySelectorAll("#culturesBottom > button").forEach(el => el.style.display = "inline-block");
document.getElementById("culturesManuallyButtons").style.display = "none";
body.querySelectorAll("div > *").forEach(e => e.disabled = false);
debug.select("#cultureCenters").style("display", null);
restoreDefaultEvents();
clearMainTip();
const selected = body.querySelector("div.selected");
if (selected) selected.classList.remove("selected");
}
function addCulture() {
const defaultCultures = Cultures.getDefault();
let culture, base, name;
if (pack.cultures.length < defaultCultures.length) {
// add one of the default cultures
culture = pack.cultures.length;
base = defaultCultures[culture].base;
name = defaultCultures[culture].name;
} else {
// add random culture besed on one of the current ones
culture = rand(pack.cultures.length - 1);
name = Names.getCulture(culture, 5, 8, "");
base = pack.cultures[culture].base;
}
const i = pack.cultures.length;
const color = d3.color(d3.scaleSequential(d3.interpolateRainbow)(Math.random())).hex();
const land = pack.cells.i.filter(isLand);
const center = land[Math.floor(Math.random() * land.length - 1)];
pack.cultures.push({name, color, base, center, i, expansionism:1, type:"Generic", cells:0, area:0, rural:0, urban:0});
drawCultureCenters();
culturesEditorAddLines();
}
function downloadCulturesData() {
const unit = areaUnit.value === "square" ? distanceUnit.value + "2" : areaUnit.value;
let data = "Id,Culture,Color,Cells,Expansionism,Type,Area "+unit+",Population,Namesbase\n"; // headers
body.querySelectorAll(":scope > div").forEach(function(el) {
data += el.dataset.id + ",";
data += el.dataset.name + ",";
data += el.dataset.color + ",";
data += el.dataset.cells + ",";
data += el.dataset.expansionism + ",";
data += el.dataset.type + ",";
data += el.dataset.area + ",";
data += el.dataset.population + ",";
const base = +el.dataset.base;
data += nameBases[base].name + "\n";
});
const dataBlob = new Blob([data], {type: "text/plain"});
const url = window.URL.createObjectURL(dataBlob);
const link = document.createElement("a");
document.body.appendChild(link);
link.download = "cultures_data" + Date.now() + ".csv";
link.href = url;
link.click();
window.setTimeout(function() {window.URL.revokeObjectURL(url);}, 2000);
}
function closeCulturesEditor() {
debug.select("#cultureCenters").remove();
exitCulturesManualAssignment();
}
}

189
modules/ui/editors.js Normal file
View file

@ -0,0 +1,189 @@
// module stub to store common functions for ui editors
"use strict";
restoreDefaultEvents(); // apply default viewbox events on load
// restore default viewbox events
function restoreDefaultEvents() {
svg.call(zoom);
viewbox.style("cursor", "default")
.on(".drag", null)
.on("click", clicked)
.on("touchmove mousemove", moved);
}
// on viewbox click event - run function based on target
function clicked() {
const el = d3.event.target;
if (!el || !el.parentElement || !el.parentElement.parentElement) return;
const parent = el.parentElement, grand = parent.parentElement;
if (parent.id === "rivers") editRiver(); else
if (grand.id === "routes") editRoute(); else
if (el.tagName === "textPath" && grand.parentNode.id === "labels") editLabel(); else
if (grand.id === "burgLabels") editBurg(); else
if (grand.id === "burgIcons") editBurg(); else
if (parent.id === "terrain") editReliefIcon(); else
if (parent.id === "markers") editMarker();
}
// clear elSelected variable
function unselect() {
restoreDefaultEvents();
if (!elSelected) return;
elSelected.call(d3.drag().on("drag", null)).attr("class", null);
debug.selectAll("*").remove();
viewbox.style("cursor", "default");
elSelected = null;
}
// close all dialogs except stated
function closeDialogs(except = "#except") {
$(".dialog:visible").not(except).each(function() {
$(this).dialog("close");
});
}
// move brush radius circle
function moveCircle(x, y, r = 20) {
let circle = document.getElementById("brushCircle");
if (!circle) {
const html = `<circle id="brushCircle" cx=${x} cy=${y} r=${r}></circle>`;
document.getElementById("debug").insertAdjacentHTML("afterBegin", html);
} else {
circle.setAttribute("cx", x);
circle.setAttribute("cy", y);
circle.setAttribute("r", r);
}
}
function removeCircle() {
if (document.getElementById("brushCircle")) document.getElementById("brushCircle").remove();
}
// get browser-defined fit-content
function fitContent() {
return !window.chrome ? "-moz-max-content" : "fit-content";
}
// DOM elements sorting on header click
$(".sortable").on("click", function() {
const el = $(this);
// remove sorting for all siblings except of clicked element
el.siblings().removeClass("icon-sort-name-up icon-sort-name-down icon-sort-number-up icon-sort-number-down");
const type = el.hasClass("alphabetically") ? "name" : "number";
let state = "no";
if (el.is("[class*='down']")) state = "asc";
if (el.is("[class*='up']")) state = "desc";
const sortby = el.attr("data-sortby");
const list = el.parent().next(); // get list container element (e.g. "countriesBody")
const lines = list.children("div"); // get list elements
if (state === "no" || state === "asc") { // sort desc
el.removeClass("icon-sort-" + type + "-down");
el.addClass("icon-sort-" + type + "-up");
lines.sort(function(a, b) {
let an = a.getAttribute("data-" + sortby);
if (an === "bottom") {return 1;}
let bn = b.getAttribute("data-" + sortby);
if (bn === "bottom") {return -1;}
if (type === "number") {an = +an; bn = +bn;}
if (an > bn) {return 1;}
if (an < bn) {return -1;}
return 0;
});
}
if (state === "desc") { // sort asc
el.removeClass("icon-sort-" + type + "-up");
el.addClass("icon-sort-" + type + "-down");
lines.sort(function(a, b) {
let an = a.getAttribute("data-" + sortby);
if (an === "bottom") {return 1;}
let bn = b.getAttribute("data-" + sortby);
if (bn === "bottom") {return -1;}
if (type === "number") {an = +an; bn = +bn;}
if (an < bn) {return 1;}
if (an > bn) {return -1;}
return 0;
});
}
lines.detach().appendTo(list);
});
function applySorting(headers) {
const header = headers.querySelector("[class*='icon-sort']");
if (!header) return;
const sortby = header.dataset.sortby;
const type = header.classList.contains("alphabetically") ? "name" : "number";
const desc = headers.querySelector("[class*='-down']") ? -1 : 1;
const list = headers.nextElementSibling;
const lines = Array.from(list.children);
lines.sort(function(a, b) {
let an = a.getAttribute("data-" + sortby);
let bn = b.getAttribute("data-" + sortby);
if (type === "number") {an = +an; bn = +bn;}
return (an - bn) * desc;
}).forEach(line => list.appendChild(line));
}
// trigger trash button click on "Delete" keypress
function removeElementOnKey() {
$(".dialog:visible .icon-trash").click();
$("button:visible:contains('Remove')").click();
}
function addBurg(point) {
const cells = pack.cells;
const x = rn(point[0], 2), y = rn(point[1], 2);
const cell = findCell(x, point[1]);
const i = pack.burgs.length;
const culture = cells.culture[cell];
const name = Names.getCulture(culture);
const state = cells.state[cell];
const feature = cells.f[cell];
const population = Math.max((cells.s[cell] + cells.road[cell]) / 3 + i / 1000 + cell % 100 / 1000, .1);
pack.burgs.push({name, cell, x, y, state, i, culture, feature, capital: false, port: 0, population});
const townSize = burgIcons.select("#towns").attr("size") || 0.5;
burgIcons.select("#towns").append("circle").attr("id", "burg"+i).attr("data-id", i)
.attr("cx", x).attr("cy", y).attr("r", townSize);
burgLabels.select("#towns").append("text").attr("id", "burgLabel"+i).attr("data-id", i)
.attr("x", x).attr("y", y).attr("dy", `${townSize * -1.5}px`).text(name);
return i;
}
function moveBurgToGroup(id, g) {
const label = document.querySelector("#burgLabels [data-id='" + id + "']");
const icon = document.querySelector("#burgIcons [data-id='" + id + "']");
const anchor = document.querySelector("#anchors [data-id='" + id + "']");
if (!label || !icon) {console.error("Cannot find label or icon elements"); return;}
document.querySelector("#burgLabels > #"+g).appendChild(label);
document.querySelector("#burgIcons > #"+g).appendChild(icon);
const iconSize = icon.parentNode.getAttribute("size");
icon.setAttribute("r", iconSize);
label.setAttribute("dy", `${iconSize * -1.5}px`);
if (anchor) {
document.querySelector("#anchors > #"+g).appendChild(anchor);
const anchorSize = +anchor.parentNode.getAttribute("size");
anchor.setAttribute("width", anchorSize);
anchor.setAttribute("height", anchorSize);
anchor.setAttribute("x", rn(pack.burgs[id].x - anchorSize * 0.47, 2));
anchor.setAttribute("y", rn(pack.burgs[id].y - anchorSize * 0.47, 2));
}
}
function removeBurg(id) {
const label = document.querySelector("#burgLabels [data-id='" + id + "']");
const icon = document.querySelector("#burgIcons [data-id='" + id + "']");
const anchor = document.querySelector("#anchors [data-id='" + id + "']");
if (label) label.remove();
if (icon) icon.remove();
if (anchor) anchor.remove();
pack.burgs[id].removed = true;
const cell = pack.burgs[id].cell;
pack.cells.burg[cell] = 0;
}

267
modules/ui/general.js Normal file
View file

@ -0,0 +1,267 @@
// Module to store general UI functions
"use strict";
// ask before closing the window
window.onbeforeunload = () => "Are you sure you want to navigate away?";
// fit full-screen map if window is resized
$(window).resize(function(e) {
// trick to prevent resize on download bar opening
if (autoResize === false) return;
mapWidthInput.value = window.innerWidth;
mapHeightInput.value = window.innerHeight;
changeMapSize();
});
// Tooltips
const tooltip = document.getElementById("tooltip");
// show tip for non-svg elemets with data-tip
document.getElementById("dialogs").addEventListener("mousemove", showDataTip);
document.getElementById("optionsContainer").addEventListener("mousemove", showDataTip);
function tip(tip = "Tip is undefined", main = false, error = false) {
const reg = "linear-gradient(0.1turn, #ffffff00, #5e5c5c66, #ffffff00)";
const red = "linear-gradient(0.1turn, #ffffff00, #e3141499, #ffffff00)";
tooltip.innerHTML = tip;
tooltip.style.background = error ? red : reg;
if (main) tooltip.dataset.main = tip;
}
function showMainTip() {
tooltip.style.background = "linear-gradient(0.1turn, #aaffff00, #3a26264d, #ffffff00)";
tooltip.innerHTML = tooltip.dataset.main;
}
function clearMainTip() {
tooltip.dataset.main = "";
tooltip.innerHTML = "";
}
function showDataTip(e) {
if (!e.target) return;
if (e.target.dataset.tip) {tip(e.target.dataset.tip); return;};
if (e.target.parentNode.dataset.tip) tip(e.target.parentNode.dataset.tip);
}
function moved() {
const point = d3.mouse(this);
const i = findCell(point[0], point[1]); // pack ell id
if (i === undefined) return;
showLegend(d3.event, i);
const g = findGridCell(point[0], point[1]); // grid cell id
if (tooltip.dataset.main) showMainTip(); else showMapTooltip(d3.event, i, g);
if (toolsContent.style.display === "block" && cellInfo.style.display === "block") updateCellInfo(point, i, g);
}
// show legend on hover (if any)
function showLegend(e, i) {
let id = e.target.id || e.target.parentNode.id;
if (e.target.parentNode.parentNode.id === "burgLabels") id = "burg" + e.target.dataset.id; else
if (e.target.parentNode.parentNode.id === "burgIcons") id = "burg" + e.target.dataset.id;
const note = notes.find(note => note.id === id);
if (note !== undefined && note.legend !== "") {
document.getElementById("legend").style.display = "block";
document.getElementById("legendHeader").innerHTML = note.name;
document.getElementById("legendBody").innerHTML = note.legend;
} else {
document.getElementById("legend").style.display = "none";
document.getElementById("legendHeader").innerHTML = "";
document.getElementById("legendBody").innerHTML = "";
}
}
// show viewbox tooltip if main tooltip is blank
function showMapTooltip(e, i, g) {
tip(""); // clear tip
const tag = e.target.tagName;
const path = e.composedPath ? e.composedPath() : getComposedPath(e.target); // apply polyfill
const group = path[path.length - 7].id;
const subgroup = path[path.length - 8].id;
const land = pack.cells.h[i] >= 20;
// specific elements
if (group === "rivers") {tip("Click to edit the River"); return;}
if (group === "routes") {tip("Click to edit the Route"); return;}
if (group === "terrain") {tip("Click to edit the Relief Icon"); return;}
if (subgroup === "burgLabels" || subgroup === "burgIcons") {tip("Click to open Burg Editor"); return;}
if (group === "labels") {tip("Click to edit the Label"); return;}
if (group === "markers") {tip("Click to edit the Marker"); return;}
if (group === "ruler") {
if (tag === "rect") {tip("Drag to split the ruler into 2 parts"); return;}
if (tag === "circle") {tip("Drag to adjust the measurer"); return;}
if (tag === "path" || tag === "line") {tip("Drag to move the measurer"); return;}
if (tag === "text") {tip("Click to remove the measurer"); return;}
}
if (subgroup === "burgIcons") {tip("Click to edit the Burg"); return;}
if (subgroup === "burgLabels") {tip("Click to edit the Burg"); return;}
if (subgroup === "freshwater" && !land) {tip("Freshwater lake"); return;}
if (subgroup === "salt" && !land) {tip("Salt lake"); return;}
// covering elements
if (layerIsOn("togglePrec") && land) tip("Annual Precipitation: "+ getFriendlyPrecipitation(i)); else
if (layerIsOn("togglePopulation")) tip("Population: "+ getFriendlyPopulation(i)); else
if (layerIsOn("toggleTemp")) tip("Temperature: " + convertTemperature(grid.cells.temp[g])); else
if (layerIsOn("toggleBiomes") && pack.cells.biome[i]) tip("Biome: " + biomesData.name[pack.cells.biome[i]]); else
if (layerIsOn("toggleStates") && pack.cells.state[i]) tip("State: " + pack.states[pack.cells.state[i]].name); else
if (layerIsOn("toggleCultures") && pack.cells.culture[i]) tip("Culture: " + pack.cultures[pack.cells.culture[i]].name); else
if (layerIsOn("toggleHeight")) tip("Height: " + getFriendlyHeight(pack.cells.h[i]));
}
// get cell info on mouse move
function updateCellInfo(point, i, g) {
const cells = pack.cells;
infoX.innerHTML = rn(point[0]);
infoY.innerHTML = rn(point[1]);
infoCell.innerHTML = i;
const unit = areaUnit.value === "square" ? " " + distanceUnit.value + "²" : " " + areaUnit.value;
infoArea.innerHTML = cells.area[i] ? si(cells.area[i] * distanceScale.value ** 2) + unit : "n/a";
infoHeight.innerHTML = getFriendlyHeight(cells.h[i]) + " (" + cells.h[i] + ")";
infoTemp.innerHTML = convertTemperature(grid.cells.temp[g]);
infoPrec.innerHTML = pack.cells.h[i] >= 20 ? getFriendlyPrecipitation(i) : "n/a";
infoState.innerHTML = ifDefined(cells.state[i]) !== "no" ? pack.states[cells.state[i]].name + " (" + cells.state[i] + ")" : "n/a";
infoCulture.innerHTML = ifDefined(cells.culture[i]) !== "no" ? pack.cultures[cells.culture[i]].name + " (" + cells.culture[i] + ")" : "n/a";
infoPopulation.innerHTML = getFriendlyPopulation(i);
infoBurg.innerHTML = cells.burg[i] ? pack.burgs[cells.burg[i]].name + " (" + cells.burg[i] + ")" : "no";
const f = cells.f[i];
infoFeature.innerHTML = f ? pack.features[f].group + " (" + f + ")" : "n/a";
infoBiome.innerHTML = biomesData.name[cells.biome[i]];
}
// return value (v) if defined with number of decimals (d), else return "no" or attribute (r)
function ifDefined(v, r = "no", d) {
if (v === null || v === undefined) return r;
if (d) return v.toFixed(d);
return v;
}
// get user-friendly (real-world) height value from map data
function getFriendlyHeight(h) {
const unit = heightUnit.value;
let unitRatio = 3.281; // default calculations are in feet
if (unit === "m") unitRatio = 1; // if meter
else if (unit === "f") unitRatio = 0.5468; // if fathom
let height = -990;
if (h >= 20) height = Math.pow(h - 18, +heightExponent.value);
else if (h < 20 && h > 0) height = (h - 20) / h * 50;
return rn(height * unitRatio) + " " + unit;
}
// get user-friendly (real-world) precipitation value from map data
function getFriendlyPrecipitation(i) {
const prec = grid.cells.prec[pack.cells.g[i]];
return prec * 100 + " mm";
}
// get user-friendly (real-world) population value from map data
function getFriendlyPopulation(i) {
const rural = pack.cells.pop[i] * populationRate.value;
const urban = pack.cells.burg[i] ? pack.burgs[pack.cells.burg[i]].population * populationRate.value * urbanization.value : 0;
return si(rural+urban);
}
// assign lock behavior
document.querySelectorAll("[data-locked]").forEach(function(e) {
e.addEventListener("mouseover", function(event) {
if (this.className === "icon-lock") tip("Click to unlock the option and allow it to be randomized on new map generation");
else tip("Click to lock the option and always use the current value on new map generation");
event.stopPropagation();
});
e.addEventListener("click", function(event) {
const id = (this.id).slice(5);
if (this.className === "icon-lock") unlock(id);
else lock(id);
});
});
// lock option
function lock(id) {
const input = document.querySelector("[data-stored='"+id+"']");
if (input) localStorage.setItem(id, input.value);
const el = document.getElementById("lock_" + id);
if(!el) return;
el.dataset.locked = 1;
el.className = "icon-lock";
}
// unlock option
function unlock(id) {
localStorage.removeItem(id);
const el = document.getElementById("lock_" + id);
if(!el) return;
el.dataset.locked = 0;
el.className = "icon-lock-open";
}
// check if option is locked
function locked(id) {
const lockEl = document.getElementById("lock_" + id);
return lockEl.dataset.locked == 1;
}
// Hotkeys, see github.com/Azgaar/Fantasy-Map-Generator/wiki/Hotkeys
document.addEventListener("keydown", function(event) {
const active = document.activeElement.tagName;
if (active === "INPUT" || active === "SELECT" || active === "TEXTAREA") return; // don't trigger if user inputs a text
const key = event.keyCode, ctrl = event.ctrlKey, shift = event.shiftKey;
if (key === 118) regenerateMap(); // "F7" for new map
else if (key === 27) {closeDialogs(); hideOptions();} // Escape to close all dialogs
else if (key === 9) {toggleOptions(event); event.preventDefault();} // Tab to toggle options
else if (ctrl && key === 80) saveAsImage("png"); // Ctrl + "P" to save as PNG
else if (ctrl && key === 83) saveAsImage("svg"); // Ctrl + "S" to save as SVG
else if (ctrl && key === 77) saveMap(); // Ctrl + "M" to save MAP file
else if (ctrl && key === 76) mapToLoad.click(); // Ctrl + "L" to load MAP
else if (key === 46) removeElementOnKey(); // "Delete" to remove the selected element
else if (shift && key === 192) console.log(pack.cells); // Shift + "`" to log cells data
else if (shift && key === 66) console.table(pack.burgs); // Shift + "B" to log burgs data
else if (shift && key === 83) console.table(pack.states); // Shift + "S" to log states data
else if (shift && key === 67) console.table(pack.cultures); // Shift + "C" to log cultures data
else if (shift && key === 70) console.table(pack.features); // Shift + "F" to log features data
else if (key === 88) toggleTexture(); // "X" to toggle Texture layer
else if (key === 72) toggleHeight(); // "H" to toggle Heightmap layer
else if (key === 66) toggleBiomes(); // "B" to toggle Biomes layer
else if (key === 69) toggleCells(); // "E" to toggle Cells layer
else if (key === 71) toggleGrid(); // "G" to toggle Grid layer
else if (key === 79) toggleCoordinates(); // "O" to toggle Coordinates layer
else if (key === 87) toggleCompass(); // "W" to toggle Compass Rose layer
else if (key === 86) toggleRivers(); // "V" to toggle Rivers layer
else if (key === 82) toggleRelief(); // "R" to toggle Relief icons layer
else if (key === 67) toggleCultures(); // "C" to toggle Cultures layer
else if (key === 83) toggleStates(); // "S" to toggle States layer
else if (key === 68) toggleBorders(); // "D" to toggle Borders layer
else if (key === 85) toggleRoutes(); // "U" to toggle Routes layer
else if (key === 84) toggleTemp(); // "T" to toggle Temperature layer
else if (key === 80) togglePopulation(); // "P" to toggle Population layer
else if (key === 65) togglePrec(); // "A" to toggle Precipitation layer
else if (key === 76) toggleLabels(); // "L" to toggle Labels layer
else if (key === 73) toggleIcons(); // "I" to toggle Icons layer
else if (key === 77) toggleMarkers(); // "M" to toggle Markers layer
else if (key === 187) toggleRulers(); // Equal (=) to toggle Rulers
else if (key === 189) toggleScaleBar(); // Minus (-) to toggle Scale bar
else if (key === 37) zoom.translateBy(svg, 10, 0); // Left to scroll map left
else if (key === 39) zoom.translateBy(svg, -10, 0); // Right to scroll map right
else if (key === 38) zoom.translateBy(svg, 0, 10); // Up to scroll map up
else if (key === 40) zoom.translateBy(svg, 0, -10); // Up to scroll map up
else if (key === 107) zoom.scaleBy(svg, 1.2); // Numpad Plus to zoom map up
else if (key === 109) zoom.scaleBy(svg, 0.8); // Numpad Minus to zoom map out
else if (key === 48 || key === 96) resetZoom(1000); // 0 to reset zoom
else if (key === 49 || key === 97) zoom.scaleTo(svg, 1); // 1 to zoom to 1
else if (key === 50 || key === 98) zoom.scaleTo(svg, 2); // 2 to zoom to 2
else if (key === 51 || key === 99) zoom.scaleTo(svg, 3); // 3 to zoom to 3
else if (key === 52 || key === 100) zoom.scaleTo(svg, 4); // 4 to zoom to 4
else if (key === 53 || key === 101) zoom.scaleTo(svg, 5); // 5 to zoom to 5
else if (key === 54 || key === 102) zoom.scaleTo(svg, 6); // 6 to zoom to 6
else if (key === 55 || key === 103) zoom.scaleTo(svg, 7); // 7 to zoom to 7
else if (key === 56 || key === 104) zoom.scaleTo(svg, 8); // 8 to zoom to 8
else if (key === 57 || key === 105) zoom.scaleTo(svg, 9); // 9 to zoom to 9
else if (ctrl && key === 90) undo.click(); // Ctrl + "Z" to undo
else if (ctrl && key === 89) redo.click(); // Ctrl + "Y" to redo
});

File diff suppressed because it is too large Load diff

312
modules/ui/labels-editor.js Normal file
View file

@ -0,0 +1,312 @@
"use strict";
function editLabel() {
if (customization) return;
closeDialogs(".stable");
if (!layerIsOn("toggleLabels")) toggleLabels();
const node = d3.event.target;
elSelected = d3.select(node.parentNode).call(d3.drag().on("start", dragLabel)).classed("draggable", true);
viewbox.on("touchmove mousemove", showEditorTips);
$("#labelEditor").dialog({
title: "Edit Label: " + node.innerHTML, resizable: false,
position: {my: "center top+10", at: "bottom", of: node, collision: "fit"},
close: closeLabelEditor
});
debug.append("g").attr("id", "controlPoints").attr("transform", elSelected.attr("transform"));
drawControlPointsAndLine();
selectLabelGroup(node);
updateValues(node);
if (modules.editLabel) return;
modules.editLabel = true;
// add listeners
document.getElementById("labelGroupShow").addEventListener("click", showGroupSection);
document.getElementById("labelGroupHide").addEventListener("click", hideGroupSection);
document.getElementById("labelGroupSelect").addEventListener("click", changeGroup);
document.getElementById("labelGroupInput").addEventListener("change", createNewGroup);
document.getElementById("labelGroupNew").addEventListener("click", toggleNewGroupInput);
document.getElementById("labelGroupRemove").addEventListener("click", removeLabelsGroup);
document.getElementById("labelTextShow").addEventListener("click", showTextSection);
document.getElementById("labelTextHide").addEventListener("click", hideTextSection);
document.getElementById("labelText").addEventListener("input", changeText);
document.getElementById("labelTextRandom").addEventListener("click", generateRandomName);
document.getElementById("labelSizeShow").addEventListener("click", showSizeSection);
document.getElementById("labelSizeHide").addEventListener("click", hideSizeSection);
document.getElementById("labelStartOffset").addEventListener("input", changeStartOffset);
document.getElementById("labelRelativeSize").addEventListener("input", changeRelativeSize);
document.getElementById("labelLegend").addEventListener("click", editLabelLegend);
document.getElementById("labelRemoveSingle").addEventListener("click", removeLabel);
function showEditorTips() {
showMainTip();
if (d3.event.target.parentNode.id === elSelected.attr("id")) tip("Drag to shift the label"); else
if (d3.event.target.parentNode.id === "controlPoints") {
if (d3.event.target.tagName === "circle") tip("Drag to move, click to delete the control point");
if (d3.event.target.tagName === "path") tip("Click to add a control point");
}
}
function selectLabelGroup(node) {
const group = node.parentNode.parentNode.id;
const select = document.getElementById("labelGroupSelect");
select.options.length = 0; // remove all options
labels.selectAll(":scope > g").each(function() {
if (this.id === "burgLabels") return;
select.options.add(new Option(this.id, this.id, false, this.id === group));
});
}
function updateValues(node) {
document.getElementById("labelText").value = node.innerHTML;
document.getElementById("labelStartOffset").value = parseFloat(node.getAttribute("startOffset"));
document.getElementById("labelRelativeSize").value = parseFloat(node.getAttribute("font-size"));
}
function drawControlPointsAndLine() {
const path = document.getElementById("textPath_" + elSelected.attr("id"));
debug.select("#controlPoints").append("path").attr("d", path.getAttribute("d")).on("click", addInterimControlPoint);
const l = path.getTotalLength();
const increment = l / Math.max(Math.ceil(l / 100), 2);
for (let i=0; i <= l; i += increment) {addControlPoint(path.getPointAtLength(i));}
}
function addControlPoint(point) {
debug.select("#controlPoints").append("circle")
.attr("cx", point.x).attr("cy", point.y).attr("r", 1)
.call(d3.drag().on("drag", dragControlPoint))
.on("click", clickControlPoint);
}
function dragControlPoint() {
this.setAttribute("cx", d3.event.x);
this.setAttribute("cy", d3.event.y);
redrawLabelPath();
}
function redrawLabelPath() {
const path = document.getElementById("textPath_" + elSelected.attr("id"));
lineGen.curve(d3.curveBundle.beta(1));
const points = [];
debug.select("#controlPoints").selectAll("circle").each(function() {
points.push([this.getAttribute("cx"), this.getAttribute("cy")]);
});
const d = round(lineGen(points));
path.setAttribute("d", d);
debug.select("#controlPoints > path").attr("d", d);
}
function clickControlPoint() {
this.remove();
redrawLabelPath();
}
function addInterimControlPoint() {
const point = d3.mouse(this);
const dists = [];
debug.select("#controlPoints").selectAll("circle").each(function() {
const x = +this.getAttribute("cx");
const y = +this.getAttribute("cy");
dists.push((point[0] - x) ** 2 + (point[1] - y) ** 2);
});
let index = dists.length;
if (dists.length > 1) {
const sorted = dists.slice(0).sort((a, b) => a-b);
const closest = dists.indexOf(sorted[0]);
const next = dists.indexOf(sorted[1]);
if (closest <= next) index = closest+1; else index = next+1;
}
const before = ":nth-child(" + (index + 2) + ")";
debug.select("#controlPoints").insert("circle", before)
.attr("cx", point[0]).attr("cy", point[1]).attr("r", 1)
.call(d3.drag().on("drag", dragControlPoint))
.on("click", clickControlPoint);
redrawLabelPath();
}
function dragLabel() {
const tr = parseTransform(elSelected.attr("transform"));
const dx = +tr[0] - d3.event.x, dy = +tr[1] - d3.event.y;
d3.event.on("drag", function() {
const x = d3.event.x, y = d3.event.y;
const transform = `translate(${(dx+x)},${(dy+y)})`;
elSelected.attr("transform", transform);
debug.select("#controlPoints").attr("transform", transform);
});
}
function showGroupSection() {
document.querySelectorAll("#labelEditor > button").forEach(el => el.style.display = "none");
document.getElementById("labelGroupSection").style.display = "inline-block";
}
function hideGroupSection() {
document.querySelectorAll("#labelEditor > button").forEach(el => el.style.display = "inline-block");
document.getElementById("labelGroupSection").style.display = "none";
document.getElementById("labelGroupInput").style.display = "none";
document.getElementById("labelGroupInput").value = "";
document.getElementById("labelGroupSelect").style.display = "inline-block";
}
function changeGroup() {
document.getElementById(this.value).appendChild(elSelected.node());
}
function toggleNewGroupInput() {
if (labelGroupInput.style.display === "none") {
labelGroupInput.style.display = "inline-block";
labelGroupInput.focus();
labelGroupSelect.style.display = "none";
} else {
labelGroupInput.style.display = "none";
labelGroupSelect.style.display = "inline-block";
}
}
function createNewGroup() {
if (!this.value) {tip("Please provide a valid group name"); return;}
let group = this.value.toLowerCase().replace(/ /g, "_").replace(/[^\w\s]/gi, "");
if (Number.isFinite(+group.charAt(0))) group = "g" + group;
if (document.getElementById(group)) {
tip("Element with this id already exists. Please provide a unique name", false, "error");
return;
}
// just rename if only 1 element left
const oldGroup = elSelected.node().parentNode;
if (oldGroup !== "states" && oldGroup !== "addedLabels" && oldGroup.childElementCount === 1) {
document.getElementById("labelGroupSelect").selectedOptions[0].remove();
document.getElementById("labelGroupSelect").options.add(new Option(group, group, false, true));
oldGroup.id = group;
toggleNewGroupInput();
document.getElementById("labelGroupInput").value = "";
return;
}
const newGroup = elSelected.node().parentNode.cloneNode(false);
document.getElementById("labels").appendChild(newGroup);
newGroup.id = group;
document.getElementById("labelGroupSelect").options.add(new Option(group, group, false, true));
document.getElementById(group).appendChild(elSelected.node());
toggleNewGroupInput();
document.getElementById("labelGroupInput").value = "";
}
function removeLabelsGroup() {
const group = elSelected.node().parentNode.id;
const basic = group === "states" || group === "addedLabels";
const count = elSelected.node().parentNode.childElementCount;
alertMessage.innerHTML = `Are you sure you want to remove
${basic ? "all elements in the group" : "the entire label group"}?
<br><br>Labels to be removed: ${count}`;
$("#alert").dialog({resizable: false, title: "Remove route group",
buttons: {
Remove: function() {
$(this).dialog("close");
$("#labelEditor").dialog("close");
hideGroupSection();
labels.select("#"+group).selectAll("text").each(function() {
document.getElementById("textPath_" + this.id).remove();
this.remove();
});
if (!basic) labels.select("#"+group).remove();
},
Cancel: function() {$(this).dialog("close");}
}
});
}
function showTextSection() {
document.querySelectorAll("#labelEditor > button").forEach(el => el.style.display = "none");
document.getElementById("labelTextSection").style.display = "inline-block";
}
function hideTextSection() {
document.querySelectorAll("#labelEditor > button").forEach(el => el.style.display = "inline-block");
document.getElementById("labelTextSection").style.display = "none";
}
function changeText() {
const text = document.getElementById("labelText").value;
elSelected.select("textPath").text(text);
if (elSelected.attr("id").slice(0,10) === "stateLabel") {
const id = +elSelected.attr("id").slice(10);
pack.states[id].name = text;
}
}
function generateRandomName() {
let name = "";
if (elSelected.attr("id").slice(0,10) === "stateLabel") {
const id = +elSelected.attr("id").slice(10);
const culture = pack.states[id].culture;
name = Names.getState(Names.getCulture(culture, 4, 7, ""), culture);
} else {
const box = elSelected.node().getBBox();
const cell = findCell((box.x + box.width) / 2, (box.y + box.height) / 2);
const culture = pack.cells.culture[cell];
name = Names.getCulture(culture);
}
document.getElementById("labelText").value = name;
changeText();
}
function showSizeSection() {
document.querySelectorAll("#labelEditor > button").forEach(el => el.style.display = "none");
document.getElementById("labelSizeSection").style.display = "inline-block";
}
function hideSizeSection() {
document.querySelectorAll("#labelEditor > button").forEach(el => el.style.display = "inline-block");
document.getElementById("labelSizeSection").style.display = "none";
}
function changeStartOffset() {
elSelected.select("textPath").attr("startOffset", this.value + "%");
tip("Label offset: " + this.value + "%");
}
function changeRelativeSize() {
elSelected.select("textPath").attr("font-size", this.value + "%");
tip("Label relative size: " + this.value + "%");
}
function editLabelLegend() {
const id = elSelected.attr("id");
const name = elSelected.text();
editLegends(id, name);
}
function removeLabel() {
alertMessage.innerHTML = "Are you sure you want to remove the label?";
$("#alert").dialog({resizable: false, title: "Remove label",
buttons: {
Remove: function() {
$(this).dialog("close");
defs.select("#textPath_" + elSelected.attr("id")).remove();
elSelected.remove();
$("#labelEditor").dialog("close");
},
Cancel: function() {$(this).dialog("close");}
}
});
}
function closeLabelEditor() {
debug.select("#controlPoints").remove();
unselect();
}
}

813
modules/ui/layers.js Normal file
View file

@ -0,0 +1,813 @@
// UI module stub to control map layers
"use strict";
// on map regeneration restore layers if they was turned on
function restoreLayers() {
if (layerIsOn("toggleHeight")) drawHeightmap();
if (layerIsOn("toggleCells")) drawCells();
if (layerIsOn("toggleGrid")) drawGrid();
if (layerIsOn("toggleCoordinates")) drawCoordinates();
if (layerIsOn("toggleCompass")) compass.attr("display", "block");
if (layerIsOn("toggleTemp")) drawTemp();
if (layerIsOn("togglePrec")) drawPrec();
if (layerIsOn("togglePopulation")) drawPopulation();
if (layerIsOn("toggleBiomes")) drawBiomes();
if (layerIsOn("toggleRelief")) ReliefIcons();
if (layerIsOn("toggleStates") || layerIsOn("toggleBorders")) drawStatesWithBorders();
if (layerIsOn("toggleCultures")) drawCultures();
}
restoreLayers(); // run on-load
// toggle layers on preset change
function changePreset(preset) {
const layers = getLayers(preset); // layers to be turned on
const ignore = ["toggleTexture", "toggleScaleBar"]; // never toggle this layers
document.getElementById("mapLayers").querySelectorAll("li").forEach(function(e) {
if (ignore.includes(e.id)) return; // ignore
if (layers.includes(e.id) && !layerIsOn(e.id)) e.click(); // turn on
else if (!layers.includes(e.id) && layerIsOn(e.id)) e.click(); // turn off
});
layersPreset.value = preset;
}
// retrun list of layers to be turned on
function getLayers(preset) {
switch(preset) {
case "political": return ["toggleStates", "toggleRivers", "toggleBorders", "toggleRoutes", "toggleLabels", "toggleIcons"];
case "cultural": return ["toggleCultures", "toggleRivers", "toggleBorders", "toggleRoutes", "toggleLabels", "toggleIcons"];
case "heightmap": return ["toggleHeight", "toggleRivers"];
case "biomes": return ["toggleBiomes", "toggleRivers"];
case "landmass": return [];
}
}
function toggleHeight() {
if (!terrs.selectAll("*").size()) {
turnButtonOn("toggleHeight");
drawHeightmap();
} else {
if (customization === 1) {tip("You cannot turn off the layer when heightmap is in edit mode", false, "error"); return;}
turnButtonOff("toggleHeight");
terrs.selectAll("*").remove();
}
}
function drawHeightmap() {
console.time("drawHeightmap");
terrs.selectAll("*").remove();
const cells = pack.cells, vertices = pack.vertices, n = cells.i.length;
const used = new Uint8Array(cells.i.length);
const paths = new Array(101).fill("");
const scheme = getColorScheme();
const terracing = +styleHeightmapTerracingInput.value / 10; // add additional shifted darker layer for pseudo-3d effect
const skip = +styleHeightmapSkipOutput.value + 1;
const simplification = +styleHeightmapSimplificationInput.value;
switch (+styleHeightmapCurveInput.value) {
case 0: lineGen.curve(d3.curveBasisClosed); break;
case 1: lineGen.curve(d3.curveLinear); break;
case 2: lineGen.curve(d3.curveStep); break;
default: lineGen.curve(d3.curveBasisClosed);
}
let currentLayer = 20;
const heights = cells.i.sort((a, b) => cells.h[a] - cells.h[b]);
for (const i of heights) {
const h = cells.h[i];
if (h > currentLayer) currentLayer += skip;
if (currentLayer > 100) break; // no layers possible with height > 100
if (h < currentLayer) continue;
if (used[i]) continue; // already marked
const onborder = cells.c[i].some(n => cells.h[n] < h);
if (!onborder) continue;
const vertex = cells.v[i].find(v => vertices.c[v].some(i => cells.h[i] < h));
const chain = connectVertices(vertex, h);
if (chain.length < 3) continue;
const points = simplifyLine(chain).map(v => vertices.p[v]);
paths[h] += round(lineGen(points));
}
terrs.append("rect").attr("x", 0).attr("y", 0).attr("width", "100%").attr("height", "100%").attr("fill", scheme(.8)); // draw base layer
for (const i of d3.range(20, 101)) {
if (paths[i].length < 10) continue;
const color = getColor(i, scheme);
if (terracing) terrs.append("path").attr("d", paths[i]).attr("transform", "translate(.7,1.4)").attr("fill", d3.color(color).darker(terracing)).attr("data-height", i);
terrs.append("path").attr("d", paths[i]).attr("fill", color).attr("data-height", i);
}
// connect vertices to chain
function connectVertices(start, h) {
const chain = []; // vertices chain to form a path
for (let i=0, current = start; i === 0 || current !== start && i < 20000; i++) {
const prev = chain[chain.length - 1]; // previous vertex in chain
chain.push(current); // add current vertex to sequence
const c = vertices.c[current]; // cells adjacent to vertex
c.filter(c => cells.h[c] === h).forEach(c => used[c] = 1);
const c0 = c[0] >= n || cells.h[c[0]] < h;
const c1 = c[1] >= n || cells.h[c[1]] < h;
const c2 = c[2] >= n || cells.h[c[2]] < h;
const v = vertices.v[current]; // neighboring vertices
if (v[0] !== prev && c0 !== c1) current = v[0];
else if (v[1] !== prev && c1 !== c2) current = v[1];
else if (v[2] !== prev && c0 !== c2) current = v[2];
if (current === chain[chain.length - 1]) {console.error("Next vertex is not found"); break;}
}
return chain;
}
function simplifyLine(chain) {
if (!simplification) return chain;
const n = simplification + 1; // filter each nth element
return chain.filter((d, i) => i % n === 0);
}
console.timeEnd("drawHeightmap");
}
function getColorScheme() {
const scheme = styleHeightmapSchemeInput.value;
if (scheme === "bright") return d3.scaleSequential(d3.interpolateSpectral);
if (scheme === "light") return d3.scaleSequential(d3.interpolateRdYlGn);
if (scheme === "green") return d3.scaleSequential(d3.interpolateGreens);
if (scheme === "monochrome") return d3.scaleSequential(d3.interpolateGreys);
}
function getColor(value, scheme = getColorScheme()) {
return scheme(1 - (value < 20 ? value - 5 : value) / 100);
}
function toggleTemp() {
if (!temperature.selectAll("*").size()) {
turnButtonOn("toggleTemp");
drawTemp();
} else {
turnButtonOff("toggleTemp");
temperature.selectAll("*").remove();
}
}
function drawTemp() {
console.time("drawTemp");
temperature.selectAll("*").remove();
lineGen.curve(d3.curveBasisClosed);
const scheme = d3.scaleSequential(d3.interpolateSpectral);
const tMax = +temperatureEquatorOutput.max, tMin = +temperatureEquatorOutput.min, delta = tMax - tMin;
const cells = grid.cells, vertices = grid.vertices, n = cells.i.length;
const used = new Uint8Array(n); // to detect already passed cells
const min = d3.min(cells.temp), max = d3.max(cells.temp);
const step = Math.max(Math.round(Math.abs(min - max) / 5), 1);
const isolines = d3.range(min+step, max, step);
const chains = [], labels = []; // store label coordinates
for (const i of cells.i) {
const t = cells.temp[i];
if (used[i] || !isolines.includes(t)) continue;
const start = findStart(i, t);
if (!start) continue;
used[i] = 1;
//debug.append("circle").attr("r", 3).attr("cx", vertices.p[start][0]).attr("cy", vertices.p[start][1]).attr("fill", "red").attr("stroke", "black").attr("stroke-width", .3);
const chain = connectVertices(start, t); // vertices chain to form a path
const relaxed = chain.filter((v, i) => i%4 === 0 || vertices.c[v].some(c => c >= n));
if (relaxed.length < 6) continue;
const points = relaxed.map(v => vertices.p[v]);
chains.push([t, points]);
addLabel(points, t);
}
// min temp isoline covers all map
temperature.append("path").attr("d", `M0,0 h${svgWidth} v${svgHeight} h${-svgWidth} Z`).attr("fill", scheme(1 - (min - tMin) / delta)).attr("stroke", "none");
for (const t of isolines) {
const path = chains.filter(c => c[0] === t).map(c => round(lineGen(c[1]))).join();
if (!path) continue;
const fill = scheme(1 - (t - tMin) / delta), stroke = d3.color(fill).darker(.2);
temperature.append("path").attr("d", path).attr("fill", fill).attr("stroke", stroke);
}
const tempLabels = temperature.append("g").attr("id", "tempLabels").attr("fill-opacity", 1);
tempLabels.selectAll("text").data(labels).enter().append("text").attr("x", d => d[0]).attr("y", d => d[1]).text(d => convertTemperature(d[2]));
// find cell with temp < isotherm and find vertex to start path detection
function findStart(i, t) {
if (cells.b[i]) return cells.v[i].find(v => vertices.c[v].some(c => c >= n)); // map border cell
return cells.v[i][cells.c[i].findIndex(c => cells.temp[c] < t || !cells.temp[c])];
}
function addLabel(points, t) {
const c = svgWidth / 2; // map center x coordinate
// add label on isoline top center
const tc = points[d3.scan(points, (a, b) => (a[1] - b[1]) + (Math.abs(a[0] - c) - Math.abs(b[0] - c)) / 2)];
pushLabel(tc[0], tc[1], t);
// add label on isoline bottom center
if (points.length > 20) {
const bc = points[d3.scan(points, (a, b) => (b[1] - a[1]) + (Math.abs(a[0] - c) - Math.abs(b[0] - c)) / 2)];
const dist2 = (tc[1] - bc[1]) ** 2 + (tc[0] - bc[0]) ** 2; // square distance between this and top point
if (dist2 > 100) pushLabel(bc[0], bc[1], t);
}
}
function pushLabel(x, y, t) {
if (x < 20 || x > svgWidth - 20) return;
if (y < 20 || y > svgHeight - 20) return;
labels.push([x, y, t]);
}
// connect vertices to chain
function connectVertices(start, t) {
const chain = []; // vertices chain to form a path
for (let i=0, current = start; i === 0 || current !== start && i < 20000; i++) {
const prev = chain[chain.length - 1]; // previous vertex in chain
chain.push(current); // add current vertex to sequence
const c = vertices.c[current]; // cells adjacent to vertex
c.filter(c => cells.temp[c] === t).forEach(c => used[c] = 1);
const c0 = c[0] >= n || cells.temp[c[0]] < t;
const c1 = c[1] >= n || cells.temp[c[1]] < t;
const c2 = c[2] >= n || cells.temp[c[2]] < t;
const v = vertices.v[current]; // neighboring vertices
if (v[0] !== prev && c0 !== c1) current = v[0];
else if (v[1] !== prev && c1 !== c2) current = v[1];
else if (v[2] !== prev && c0 !== c2) current = v[2];
if (current === chain[chain.length - 1]) {console.error("Next vertex is not found"); break;}
}
chain.push(start);
return chain;
}
console.timeEnd("drawTemp");
}
function toggleBiomes() {
if (!biomes.selectAll("path").size()) {
turnButtonOn("toggleBiomes");
drawBiomes();
} else {
biomes.selectAll("path").remove();
turnButtonOff("toggleBiomes");
}
}
function drawBiomes() {
biomes.selectAll("path").remove();
const cells = pack.cells, vertices = pack.vertices, n = cells.i.length;
const used = new Uint8Array(cells.i.length);
const paths = new Array(biomesData.i.length).fill("");
for (const i of cells.i) {
if (!cells.biome[i]) continue; // no need to mark water
if (used[i]) continue; // already marked
const b = cells.biome[i];
const onborder = cells.c[i].some(n => cells.biome[n] !== b);
if (!onborder) continue;
const edgeVerticle = cells.v[i].find(v => vertices.c[v].some(i => cells.biome[i] !== b));
const chain = connectVertices(edgeVerticle, b);
if (chain.length < 3) continue;
const points = chain.map(v => vertices.p[v]);
paths[b] += "M" + points.join("L") + "Z";
}
paths.forEach(function(d, i) {
if (d.length < 10) return;
const color = biomesData.color[i];
biomes.append("path").attr("d", d).attr("fill", color).attr("stroke", color).attr("id", "biome"+i);
});
// connect vertices to chain
function connectVertices(start, b) {
const chain = []; // vertices chain to form a path
for (let i=0, current = start; i === 0 || current !== start && i < 20000; i++) {
const prev = chain[chain.length - 1]; // previous vertex in chain
chain.push(current); // add current vertex to sequence
const c = vertices.c[current]; // cells adjacent to vertex
c.filter(c => cells.biome[c] === b).forEach(c => used[c] = 1);
const c0 = c[0] >= n || cells.biome[c[0]] !== b;
const c1 = c[1] >= n || cells.biome[c[1]] !== b;
const c2 = c[2] >= n || cells.biome[c[2]] !== b;
const v = vertices.v[current]; // neighboring vertices
if (v[0] !== prev && c0 !== c1) current = v[0];
else if (v[1] !== prev && c1 !== c2) current = v[1];
else if (v[2] !== prev && c0 !== c2) current = v[2];
if (current === chain[chain.length - 1]) {console.error("Next vertex is not found"); break;}
}
return chain;
}
}
function togglePrec() {
if (!prec.selectAll("circle").size()) {
turnButtonOn("togglePrec");
drawPrec();
} else {
turnButtonOff("togglePrec");
const hide = d3.transition().duration(1000).ease(d3.easeSinIn);
prec.selectAll("text").attr("opacity", 1).transition(hide).attr("opacity", 0);
prec.selectAll("circle").transition(hide).attr("r", 0).remove();
prec.transition().delay(1000).attr("display", "none");
}
}
function drawPrec() {
prec.selectAll("circle").remove();
const cells = grid.cells, p = grid.points;
prec.attr("display", "block");
const show = d3.transition().duration(800).ease(d3.easeSinIn);
prec.selectAll("text").attr("opacity", 0).transition(show).attr("opacity", 1);
const data = cells.i.filter(i => cells.h[i] >= 20 && cells.prec[i]);
prec.selectAll("circle").data(data).enter().append("circle")
.attr("cx", d => p[d][0]).attr("cy", d => p[d][1]).attr("r", 0)
.transition(show).attr("r", d => rn(Math.max(Math.sqrt(cells.prec[d] * .5), .8),2));
}
function togglePopulation() {
if (!population.selectAll("line").size()) {
turnButtonOn("togglePopulation");
drawPopulation();
} else {
turnButtonOff("togglePopulation");
const hide = d3.transition().duration(1000).ease(d3.easeSinIn);
population.select("#rural").selectAll("line").transition(hide).attr("y2", d => d[1]).remove();
population.select("#urban").selectAll("line").transition(hide).delay(1000).attr("y2", d => d[1]).remove();
}
}
function drawPopulation() {
population.selectAll("line").remove();
const cells = pack.cells, p = cells.p, burgs = pack.burgs;
// pack.cells.pop.reduce((s=0,v) => s+v)
// pack.burgs.map(b => b.population).reduce((s=0,v) => s+v)
const show = d3.transition().duration(2000).ease(d3.easeSinIn);
const rural = Array.from(cells.i.filter(i => cells.pop[i] > 0), i => [p[i][0], p[i][1], p[i][1] - cells.pop[i] / 8]);
population.select("#rural").selectAll("line").data(rural).enter().append("line")
.attr("x1", d => d[0]).attr("y1", d => d[1])
.attr("x2", d => d[0]).attr("y2", d => d[1])
.transition(show).attr("y2", d => d[2]);
const urban = burgs.filter(b => b.i).map(b => [b.x, b.y, b.y - b.population / 8 * urbanization.value]);
population.select("#urban").selectAll("line").data(urban).enter().append("line")
.attr("x1", d => d[0]).attr("y1", d => d[1])
.attr("x2", d => d[0]).attr("y2", d => d[1])
.transition(show).delay(500).attr("y2", d => d[2]);
}
function toggleCells() {
if (!cells.selectAll("path").size()) {
turnButtonOn("toggleCells");
drawCells();
} else {
cells.selectAll("path").remove();
turnButtonOff("toggleCells");
}
}
function drawCells() {
cells.selectAll("path").remove();
const data = customization === 1 ? grid.cells.i : pack.cells.i;
const polygon = customization === 1 ? getGridPolygon : getPackPolygon;
let path = "";
data.forEach(i => path += "M" + polygon(i));
cells.append("path").attr("d", path);
}
function toggleCultures() {
if (!cults.selectAll("path").size()) {
turnButtonOn("toggleCultures");
drawCultures();
} else {
cults.selectAll("path").remove();
turnButtonOff("toggleCultures");
}
}
function drawCultures() {
console.time("drawCultures");
cults.selectAll("path").remove();
const cells = pack.cells, vertices = pack.vertices, cultures = pack.cultures, n = cells.i.length;
const used = new Uint8Array(cells.i.length);
const paths = new Array(cultures.length).fill("");
for (const i of cells.i) {
if (!cells.culture[i]) continue;
if (used[i]) continue;
used[i] = 1;
const c = cells.culture[i];
const onborder = cells.c[i].some(n => cells.culture[n] !== c);
if (!onborder) continue;
const vertex = cells.v[i].find(v => vertices.c[v].some(i => cells.culture[i] !== c));
const chain = connectVertices(vertex, c);
if (chain.length < 3) continue;
const points = chain.map(v => vertices.p[v]);
paths[c] += "M" + points.join("L") + "Z";
}
const data = paths.map((p, i) => [p, i, cultures[i].color]).filter(d => d[0].length > 10);
cults.selectAll("path").data(data).enter().append("path").attr("d", d => d[0]).attr("fill", d => d[2]).attr("id", d => "culture"+d[1]);
// connect vertices to chain
function connectVertices(start, t) {
const chain = []; // vertices chain to form a path
for (let i=0, current = start; i === 0 || current !== start && i < 20000; i++) {
const prev = chain[chain.length - 1]; // previous vertex in chain
chain.push(current); // add current vertex to sequence
const c = vertices.c[current]; // cells adjacent to vertex
c.filter(c => cells.culture[c] === t).forEach(c => used[c] = 1);
const c0 = c[0] >= n || cells.culture[c[0]] !== t;
const c1 = c[1] >= n || cells.culture[c[1]] !== t;
const c2 = c[2] >= n || cells.culture[c[2]] !== t;
const v = vertices.v[current]; // neighboring vertices
if (v[0] !== prev && c0 !== c1) current = v[0];
else if (v[1] !== prev && c1 !== c2) current = v[1];
else if (v[2] !== prev && c0 !== c2) current = v[2];
if (current === chain[chain.length - 1]) {console.error("Next vertex is not found"); break;}
}
return chain;
}
console.timeEnd("drawCultures");
}
function toggleStates() {
if (!layerIsOn("toggleStates")) {
turnButtonOn("toggleStates");
regions.attr("display", null);
drawStatesWithBorders();
} else {
regions.attr("display", "none").selectAll("path").remove();
turnButtonOff("toggleStates");
}
}
function drawStatesWithBorders() {
console.time("drawStatesWithBorders");
regions.selectAll("path").remove();
borders.selectAll("path").remove();
const cells = pack.cells, vertices = pack.vertices, states = pack.states, n = cells.i.length;
const used = new Uint8Array(cells.i.length);
const body = new Array(states.length).fill(""); // store path around each state
const gap = new Array(states.length).fill(""); // store path along water for each state to fill the gaps
const border = new Array(states.length).fill(""); // store path along land for all states to render borders
for (const i of cells.i) {
if (!cells.state[i] || used[i]) continue;
used[i] = 1;
const s = cells.state[i];
const onborder = cells.c[i].some(n => cells.state[n] !== s);
if (!onborder) continue;
const borderWith = cells.c[i].map(c => cells.state[c]).find(n => n !== s);
const vertex = cells.v[i].find(v => vertices.c[v].some(i => cells.state[i] === borderWith));
const chain = connectVertices(vertex, s, borderWith);
if (chain.length < 3) continue;
body[s] += "M" + chain.map(v => vertices.p[v[0]]).join("L");
gap[s] += "M" + vertices.p[chain[0][0]] + chain.reduce((r,v,i,d) => !i ? r : !v[2] ? r + "L" + vertices.p[v[0]] : d[i+1] && !d[i+1][2] ? r + "M" + vertices.p[v[0]] : r, "");
border[s] += "M" + vertices.p[chain[0][0]] + chain.reduce((r,v,i,d) => !i ? r : v[2] && s > v[1] ? r + "L" + vertices.p[v[0]] : d[i+1] && d[i+1][2] && s > d[i+1][1] ? r + "M" + vertices.p[v[0]] : r, "");
// debug.append("circle").attr("r", 2).attr("cx", cells.p[i][0]).attr("cy", cells.p[i][1]).attr("fill", "blue");
// const p = chain.map(v => vertices.p[v[0]])
// debug.selectAll(".circle").data(p).enter().append("circle").attr("cx", d => d[0]).attr("cy", d => d[1]).attr("r", 1).attr("fill", "red");
// const poly = polylabel([p], 1.0); // pole of inaccessibility
// debug.append("circle").attr("r", 2).attr("cx", poly[0]).attr("cy", poly[1]).attr("fill", "green");
}
const bodyData = body.map((p, i) => [p.length > 10 ? p : null, i, states[i].color]).filter(d => d[0]);
statesBody.selectAll("path").data(bodyData).enter().append("path").attr("d", d => d[0]).attr("fill", d => d[2]).attr("stroke", "none").attr("id", d => "state"+d[1]);
const gapData = gap.map((p, i) => [p.length > 10 ? p : null, i, states[i].color]).filter(d => d[0]);
statesBody.selectAll(".path").data(gapData).enter().append("path").attr("d", d => d[0]).attr("fill", "none").attr("stroke", d => d[2]).attr("id", d => "state-gap"+d[1]);
defs.select("#statePaths").selectAll("clipPath").remove();
defs.select("#statePaths").selectAll("clipPath").data(bodyData).enter().append("clipPath").attr("id", d => "state-clip"+d[1]).append("use").attr("href", d => "#state"+d[1]);
statesHalo.selectAll(".path").data(bodyData).enter().append("path").attr("d", d => d[0]).attr("stroke", d => d3.color(d[2]).darker().hex()).attr("id", d => "state-border"+d[1]).attr("clip-path", d => "url(#state-clip"+d[1]+")");
const borderData = border.map((p, i) => [p.length > 10 ? p : null, i]).filter(d => d[0]);
borders.selectAll("path").data(borderData).enter().append("path").attr("d", d => d[0]).attr("id", d => "border"+d[1]);
// connect vertices to chain
function connectVertices(start, t, state) {
const chain = []; // vertices chain to form a path
let land = vertices.c[start].some(c => cells.h[c] >= 20 && cells.state[c] !== t);
function check(i) {state = cells.state[i]; land = cells.h[i] >= 20;}
for (let i=0, current = start; i === 0 || current !== start && i < 20000; i++) {
const prev = chain[chain.length - 1] ? chain[chain.length - 1][0] : -1; // previous vertex in chain
chain.push([current, state, land]); // add current vertex to sequence
const c = vertices.c[current]; // cells adjacent to vertex
c.filter(c => cells.state[c] === t).forEach(c => used[c] = 1);
const c0 = c[0] >= n || cells.state[c[0]] !== t;
const c1 = c[1] >= n || cells.state[c[1]] !== t;
const c2 = c[2] >= n || cells.state[c[2]] !== t;
const v = vertices.v[current]; // neighboring vertices
if (v[0] !== prev && c0 !== c1) {current = v[0]; check(c0 ? c[0] : c[1]);} else
if (v[1] !== prev && c1 !== c2) {current = v[1]; check(c1 ? c[1] : c[2]);} else
if (v[2] !== prev && c0 !== c2) {current = v[2]; check(c2 ? c[2] : c[0]);}
if (current === chain[chain.length - 1][0]) {console.error("Next vertex is not found"); break;}
}
chain.push([start, state, land]); // add starting vertex to sequence to close the path
return chain;
}
console.timeEnd("drawStatesWithBorders");
}
function toggleBorders() {
if (!layerIsOn("toggleBorders")) {
turnButtonOn("toggleBorders");
$('#borders').fadeIn();
} else {
turnButtonOff("toggleBorders");
$('#borders').fadeOut();
}
}
function toggleGrid() {
if (!gridOverlay.selectAll("*").size()) {
turnButtonOn("toggleGrid");
drawGrid();
calculateFriendlyGridSize();
} else {
turnButtonOff("toggleGrid");
gridOverlay.selectAll("*").remove();
}
}
function drawGrid() {
console.time("drawGrid");
gridOverlay.selectAll("*").remove();
const type = styleGridType.value;
const size = +styleGridSize.value;
if (type === "pointyHex" || type === "flatHex") {
const points = getHexGridPoints(size, type);
const hex = "m" + getHex(size, type).slice(0, 4).join("l");
const d = points.map(p => "M" + p + hex).join("");
gridOverlay.append("path").attr("d", d);
} else if (type === "square") {
const pathX = d3.range(size, svgWidth, size).map(x => "M" + rn(x, 2) + ",0v" + svgHeight);
const pathY = d3.range(size, svgHeight, size).map(y => "M0," + rn(y, 2) + "h" + svgWidth);
gridOverlay.append("path").attr("d", pathX + pathY);
}
// calculate hexes centers
function getHexGridPoints(size, type) {
const points = [];
const rt3 = Math.sqrt(3);
const off = type === "pointyHex" ? rn(rt3 * size / 2, 2) : rn(size * 3 / 2, 2);
const ySpace = type === "pointyHex" ? rn(size * 3 / 2, 2) : rn(rt3 * size / 2, 2);
const xSpace = type === "pointyHex" ? rn(rt3 * size, 2) : rn(size * 3, 2);
for (let y = 0, l = 0; y < graphHeight+ySpace; y += ySpace, l++) {
for (let x = l % 2 ? 0 : off; x < graphWidth+xSpace; x += xSpace) {points.push([rn(x, 2), rn(y, 2)]);}
}
return points;
}
// calculate hex points
function getHex(radius, type) {
let x0 = 0, y0 = 0;
const s = type === "pointyHex" ? 0 : Math.PI / -6;
const thirdPi = Math.PI / 3;
let angles = [s, s + thirdPi, s + 2 * thirdPi, s + 3 * thirdPi, s + 4 * thirdPi, s + 5 * thirdPi];
return angles.map(function(a) {
const x1 = Math.sin(a) * radius;
const y1 = -Math.cos(a) * radius;
const dx = rn(x1 - x0, 2);
const dy = rn(y1 - y0, 2);
x0 = x1, y0 = y1;
return [rn(dx, 2), rn(dy, 2)];
});
}
console.timeEnd("drawGrid");
}
function toggleCoordinates() {
if (!coordinates.selectAll("*").size()) {
turnButtonOn("toggleCoordinates");
drawCoordinates();
} else {
turnButtonOff("toggleCoordinates");
coordinates.selectAll("*").remove();
}
}
function drawCoordinates() {
if (!layerIsOn("toggleCoordinates")) return;
coordinates.selectAll("*").remove(); // remove every time
const eqY = +document.getElementById("equatorOutput").value;
const eqD = +document.getElementById("equidistanceOutput").value;
const merX = svgWidth / 2; // x of zero meridian
const steps = [.5, 1, 2, 5, 10, 15, 30]; // possible steps
const goal = merX / eqD / scale ** 0.4 * 12;
const step = steps.reduce((p, c) => Math.abs(c - goal) < Math.abs(p - goal) ? c : p);
const p = getViewPoint(2 + scale, 2 + scale); // on border point on viexBox
const desired = +coordinates.attr("data-size")
const size = Math.max(desired + 1 - scale, 2);
coordinates.attr("font-size", size);
// map coordinates extent
const extent = getViewBoxExtent();
const latS = mapCoordinates.latS + (1 - extent[1][1] / svgHeight) * mapCoordinates.latT;
const latN = mapCoordinates.latN - (extent[0][1] / svgHeight) * mapCoordinates.latT;
const lonW = mapCoordinates.lonW + (extent[0][0] / svgWidth) * mapCoordinates.lonT;
const lonE = mapCoordinates.lonE - (1 - extent[1][0] / svgWidth) * mapCoordinates.lonT;
const grid = coordinates.append("g").attr("id", "coordinateGrid");
const lalitude = coordinates.append("g").attr("id", "lalitude");
const longitude = coordinates.append("g").attr("id", "longitude");
// rander lalitude lines
d3.range(nextStep(latS), nextStep(latN)+0.01, step).forEach(function(l) {
const c = eqY - l / 90 * eqD;
const lat = l < 0 ? Math.abs(l) + "°S" : l + "°N";
grid.append("line").attr("x1", 0).attr("x2", svgWidth).attr("y1", c).attr("y2", c).attr("l", l);
const nearBorder = c - size <= extent[0][1] || c + size / 2 >= extent[1][1];
if (nearBorder || !Number.isInteger(l)) return;
lalitude.append("text").attr("x", p.x).attr("y", c).text(lat);
});
// rander longitude lines
d3.range(nextStep(lonW), nextStep(lonE)+0.01, step).forEach(function(l) {
const c = merX + l / 90 * eqD;
const lon = l < 0 ? Math.abs(l) + "°W" : l + "°E";
grid.append("line").attr("x1", c).attr("x2", c).attr("y1", 0).attr("y2", svgHeight).attr("l", l);
const nearBorder = c - size * 1.5 <= extent[0][0] || c + size >= extent[1][0];
if (nearBorder || !Number.isInteger(l)) return;
longitude.append("text").attr("x", c).attr("y", p.y).text(lon);
});
function nextStep(v) {return (v / step | 0) * step;}
}
// conver svg point into viewBox point
function getViewPoint(x, y) {
const view = document.getElementById('viewbox');
const svg = document.getElementById('map');
const pt = svg.createSVGPoint();
pt.x = x, pt.y = y;
return pt.matrixTransform(view.getScreenCTM().inverse());
}
function toggleCompass() {
if (!layerIsOn("toggleCompass")) {
turnButtonOn("toggleCompass");
$('#compass').fadeIn();
if (!compass.selectAll("*").size()) {
const tr = `translate(80 80) scale(.25)`;
d3.select("#rose").attr("transform", tr);
compass.append("use").attr("xlink:href","#rose");
}
} else {
$('#compass').fadeOut();
turnButtonOff("toggleCompass");
}
}
function toggleRelief() {
if (!layerIsOn("toggleRelief")) {
turnButtonOn("toggleRelief");
if (!terrain.selectAll("*").size()) ReliefIcons();
$('#terrain').fadeIn();
} else {
$('#terrain').fadeOut();
turnButtonOff("toggleRelief");
}
}
function toggleTexture() {
if (!layerIsOn("toggleTexture")) {
turnButtonOn("toggleTexture");
// append default texture image selected by default. Don't append on load to not harm performance
if (!texture.selectAll("*").size()) {
const link = getAbsolutePath(styleTextureInput.value);
texture.append("image").attr("x", 0).attr("y", 0).attr("width", "100%").attr("height", "100%")
.attr('xlink:href', link).attr('preserveAspectRatio', "xMidYMid slice");
}
$('#texture').fadeIn();
zoom.scaleBy(svg, 1.00001); // enforce browser re-draw
} else {
$('#texture').fadeOut();
turnButtonOff("toggleTexture");
}
}
function toggleRivers() {
if (!layerIsOn("toggleRivers")) {
turnButtonOn("toggleRivers");
$('#rivers').fadeIn();
} else {
$('#rivers').fadeOut();
turnButtonOff("toggleRivers");
}
}
function toggleRoutes() {
if (!layerIsOn("toggleRoutes")) {
turnButtonOn("toggleRoutes");
$('#routes').fadeIn();
} else {
$('#routes').fadeOut();
turnButtonOff("toggleRoutes");
}
}
function toggleMarkers() {
if (!layerIsOn("toggleMarkers")) {
turnButtonOn("toggleMarkers");
$('#markers').fadeIn();
} else {
$('#markers').fadeOut();
turnButtonOff("toggleMarkers");
}
}
function toggleLabels() {
if (!layerIsOn("toggleLabels")) {
turnButtonOn("toggleLabels");
$('#labels').fadeIn();
} else {
turnButtonOff("toggleLabels");
$('#labels').fadeOut();
}
}
function toggleIcons() {
if (!layerIsOn("toggleIcons")) {
turnButtonOn("toggleIcons");
$('#icons').fadeIn();
} else {
turnButtonOff("toggleIcons");
$('#icons').fadeOut();
}
}
function toggleRulers() {
if (!layerIsOn("toggleRulers")) {
turnButtonOn("toggleRulers");
$('#ruler').fadeIn();
} else {
$('#ruler').fadeOut();
turnButtonOff("toggleRulers");
}
}
function toggleScaleBar() {
if (!layerIsOn("toggleScaleBar")) {
turnButtonOn("toggleScaleBar");
$('#scaleBar').fadeIn();
} else {
$('#scaleBar').fadeOut();
turnButtonOff("toggleScaleBar");
}
}
function layerIsOn(el) {
const buttonoff = document.getElementById(el).classList.contains("buttonoff");
return !buttonoff;
}
function turnButtonOff(el) {
document.getElementById(el).classList.add("buttonoff");
layersPreset.value = "custom";
}
function turnButtonOn(el) {
document.getElementById(el).classList.remove("buttonoff");
layersPreset.value = "custom";
}
// move layers on mapLayers dragging (jquery sortable)
$("#mapLayers").sortable({items: "li:not(.solid)", cancel: ".solid", update: moveLayer});
function moveLayer(event, ui) {
const el = getLayer(ui.item.attr("id"));
if (el) {
const prev = getLayer(ui.item.prev().attr("id"));
const next = getLayer(ui.item.next().attr("id"));
if (prev) el.insertAfter(prev); else if (next) el.insertBefore(next);
}
}
// define connection between option layer buttons and actual svg groups to move the element
function getLayer(id) {
if (id === "toggleHeight") return $("#terrs");
if (id === "toggleBiomes") return $("#biomes");
if (id === "toggleCells") return $("#cells");
if (id === "toggleGrid") return $("#gridOverlay");
if (id === "toggleCoordinates") return $("#coordinates");
if (id === "toggleCompass") return $("#compass");
if (id === "toggleRivers") return $("#rivers");
if (id === "toggleRelief") return $("#terrain");
if (id === "toggleCultures") return $("#cults");
if (id === "toggleStates") return $("#regions");
if (id === "toggleBorders") return $("#borders");
if (id === "toggleRoutes") return $("#routes");
if (id === "toggleTemp") return $("#temperature");
if (id === "togglePrec") return $("#prec");
if (id === "togglePopulation") return $("#population");
if (id === "toggleTexture") return $("#texture");
if (id === "toggleLabels") return $("#labels");
if (id === "toggleIcons") return $("#icons");
if (id === "toggleMarkers") return $("#markers");
if (id === "toggleRulers") return $("#ruler");
}

View file

@ -0,0 +1,147 @@
"use strict";
function editLegends(id, name) {
// update list of objects
const select = document.getElementById("legendSelect");
for (let i = select.options.length; i < notes.length; i++) {
select.options.add(new Option(notes[i].id, notes[i].id));
}
// select an object
if (id) {
let note = notes.find(note => note.id === id);
if (note === undefined) {
if (!name) name = id;
note = {id, name, legend: ""};
notes.push(note);
select.options.add(new Option(id, id));
}
select.value = id;
legendName.value = note.name;
legendText.value = note.legend;
}
// open a dialog
$("#legendEditor").dialog({
title: "Legends Editor", minWidth: Math.min(svgWidth, 400),
position: {my: "center", at: "center", of: "svg"}
});
if (modules.editLegends) return;
modules.editLegends = true;
// add listeners
document.getElementById("legendSelect").addEventListener("change", changeObject);
document.getElementById("legendName").addEventListener("input", changeName);
document.getElementById("legendText").addEventListener("input", changeText);
document.getElementById("legendFocus").addEventListener("click", validateHighlightElement);
document.getElementById("legendDownload").addEventListener("click", downloadLegends);
document.getElementById("legendUpload").addEventListener("click", () => legendsToLoad.click());
document.getElementById("legendsToLoad").addEventListener("change", uploadLegends);
document.getElementById("legendRemove").addEventListener("click", triggerLegendRemove);
function changeObject() {
const note = notes.find(note => note.id === this.value);
legendName.value = note.name;
legendText.value = note.legend;
}
function changeName() {
const id = document.getElementById("legendSelect").value;
const note = notes.find(note => note.id === id);
note.name = this.value;
}
function changeText() {
const id = document.getElementById("legendSelect").value;
const note = notes.find(note => note.id === id);
note.legend = this.value;
}
function validateHighlightElement() {
const select = document.getElementById("legendSelect");
const element = document.getElementById(select.value);
// if element is not found
if (element === null) {
alertMessage.innerHTML = "Related element is not found. Would you like to remove the note (legend item)?";
$("#alert").dialog({resizable: false, title: "Element not found",
buttons: {
Remove: function() {$(this).dialog("close"); removeLegend();},
Keep: function() {$(this).dialog("close");}
}
});
return;
}
highlightElement(element); // if element is found
}
function highlightElement(element) {
if (debug.select(".highlighted").size()) return; // allow only 1 highlight element simultaniosly
const box = element.getBBox();
const transform = element.getAttribute("transform") || null;
const t = d3.transition().duration(1000).ease(d3.easeBounceOut);
const r = d3.transition().duration(500).ease(d3.easeLinear);
const highlight = debug.append("rect").attr("x", box.x).attr("y", box.y)
.attr("width", box.width).attr("height", box.height).attr("transform", transform);
highlight.classed("highlighted", 1)
.transition(t).style("outline-offset", "0px")
.transition(r).style("outline-color", "transparent").remove();
const tr = parseTransform(transform);
let x = box.x + box.width / 2;
if (tr[0]) x += tr[0];
let y = box.y + box.height / 2;
if (tr[1]) y += tr[1];
if (scale >= 2) zoomTo(x, y, scale, 1600);
}
function downloadLegends() {
const legendString = JSON.stringify(notes);
const dataBlob = new Blob([legendString],{type:"text/plain"});
const url = window.URL.createObjectURL(dataBlob);
const link = document.createElement("a");
link.download = "legends" + Date.now() + ".txt";
link.href = url;
link.click();
}
function uploadLegends() {
const fileToLoad = this.files[0];
this.value = "";
const fileReader = new FileReader();
fileReader.onload = function(fileLoadedEvent) {
const dataLoaded = fileLoadedEvent.target.result;
if (dataLoaded) {
notes = JSON.parse(dataLoaded);
document.getElementById("legendSelect").options.length = 0;
editLegends(notes[0].id, notes[0].name);
} else {
tip("Cannot load a file. Please check the data format", false, "error")
}
}
fileReader.readAsText(fileToLoad, "UTF-8");
}
function triggerLegendRemove() {
alertMessage.innerHTML = "Are you sure you want to remove the selected legend?";
$("#alert").dialog({resizable: false, title: "Remove legend element",
buttons: {
Remove: function() {$(this).dialog("close"); removeLegend();},
Keep: function() {$(this).dialog("close");}
}
});
}
function removeLegend() {
const select = document.getElementById("legendSelect");
const index = notes.findIndex(n => n.id === select.value);
notes.splice(index, 1);
select.options.length = 0;
if (!notes.length) {$("#legendEditor").dialog("close"); return;}
editLegends(notes[0].id, notes[0].name);
}
}

View file

@ -0,0 +1,474 @@
"use strict";
function editMarker() {
if (customization) return;
closeDialogs("#markerEditor, .stable");
$("#markerEditor").dialog();
elSelected = d3.select(d3.event.target).call(d3.drag().on("start", dragMarker)).classed("draggable", true);
updateInputs();
if (modules.editMarker) return;
modules.editMarker = true;
$("#markerEditor").dialog({
title: "Edit Marker", resizable: false,
position: {my: "center top+30", at: "bottom", of: d3.event, collision: "fit"},
close: closeMarkerEditor
});
// add listeners
document.getElementById("markerGroup").addEventListener("click", toggleGroupSection);
document.getElementById("markerAddGroup").addEventListener("click", toggleGroupInput);
document.getElementById("markerSelectGroup").addEventListener("change", changeGroup);
document.getElementById("markerInputGroup").addEventListener("change", createGroup);
document.getElementById("markerRemoveGroup").addEventListener("click", removeGroup);
document.getElementById("markerIcon").addEventListener("click", toggleIconSection);
document.getElementById("markerIconSize").addEventListener("input", changeIconSize);
document.getElementById("markerIconShiftX").addEventListener("input", changeIconShiftX);
document.getElementById("markerIconShiftY").addEventListener("input", changeIconShiftY);
document.getElementById("markerIconCustom").addEventListener("input", applyCustomUnicodeIcon);
document.getElementById("markerStyle").addEventListener("click", toggleStyleSection);
document.getElementById("markerSize").addEventListener("input", changeMarkerSize);
document.getElementById("markerBaseStroke").addEventListener("input", changePinStroke);
document.getElementById("markerBaseFill").addEventListener("input", changePinFill);
document.getElementById("markerIconStrokeWidth").addEventListener("input", changeIconStrokeWidth);
document.getElementById("markerIconStroke").addEventListener("input", changeIconStroke);
document.getElementById("markerIconFill").addEventListener("input", changeIconFill);
document.getElementById("markerToggleBubble").addEventListener("click", togglePinVisibility);
document.getElementById("markerLegendButton").addEventListener("click", editMarkerLegend);
document.getElementById("markerAdd").addEventListener("click", toggleAddMarker);
document.getElementById("markerRemove").addEventListener("click", removeMarker);
updateGroupOptions();
function dragMarker() {
const tr = parseTransform(this.getAttribute("transform"));
const x = +tr[0] - d3.event.x, y = +tr[1] - d3.event.y;
d3.event.on("drag", function() {
const transform = `translate(${(x + d3.event.x)},${(y + d3.event.y)})`;
this.setAttribute("transform", transform);
});
}
function updateInputs() {
const id = elSelected.attr("data-id");
const symbol = d3.select("#defs-markers").select(id);
const icon = symbol.select("text");
markerSelectGroup.value = id.slice(1);
markerIconSize.value = parseFloat(icon.attr("font-size"));
markerIconShiftX.value = parseFloat(icon.attr("x"));
markerIconShiftY.value = parseFloat(icon.attr("y"));
markerSize.value = elSelected.attr("data-size");
markerBaseStroke.value = symbol.select("path").attr("fill");
markerBaseFill.value = symbol.select("circle").attr("fill");
markerIconStrokeWidth.value = icon.attr("stroke-width");
markerIconStroke.value = icon.attr("stroke");
markerIconFill.value = icon.attr("fill");
markerToggleBubble.className = symbol.select("circle").attr("opacity") === "0" ? "icon-info" : "icon-info-circled";
const table = document.getElementById("markerIconTable");
let selected = table.getElementsByClassName("selected");
if (selected.length) selected[0].removeAttribute("class");
selected = document.querySelectorAll("#markerIcon" + icon.text().codePointAt());
if (selected.length) selected[0].className = "selected";
markerIconCustom.value = selected.length ? "" : icon.text();
}
function toggleGroupSection() {
if (markerGroupSection.style.display === "inline-block") {
markerEditor.querySelectorAll("button:not(#markerGroup)").forEach(b => b.style.display = "inline-block");
markerGroupSection.style.display = "none";
} else {
markerEditor.querySelectorAll("button:not(#markerGroup)").forEach(b => b.style.display = "none");
markerGroupSection.style.display = "inline-block";
}
}
function updateGroupOptions() {
markerSelectGroup.innerHTML = "";
d3.select("#defs-markers").selectAll("symbol").each(function() {
markerSelectGroup.options.add(new Option(this.id, this.id));
});
markerSelectGroup.value = elSelected.attr("data-id").slice(1);
}
function toggleGroupInput() {
if (markerInputGroup.style.display === "inline-block") {
markerSelectGroup.style.display = "inline-block";
markerInputGroup.style.display = "none";
} else {
markerSelectGroup.style.display = "none";
markerInputGroup.style.display = "inline-block";
markerInputGroup.focus();
}
}
function changeGroup() {
elSelected.attr("xlink:href", "#"+this.value);
elSelected.attr("data-id", "#"+this.value);
}
function createGroup() {
let newGroup = this.value.toLowerCase().replace(/ /g, "_").replace(/[^\w\s]/gi, "");
if (Number.isFinite(+newGroup.charAt(0))) newGroup = "m" + newGroup;
if (document.getElementById(newGroup)) {
tip("Element with this id already exists. Please provide a unique name", false, "error");
return;
}
markerInputGroup.value = "";
// clone old group assigning new id
const id = elSelected.attr("data-id");
const clone = d3.select("#defs-markers").select(id).node().cloneNode(true);
clone.id = newGroup;
document.getElementById("defs-markers").insertBefore(clone, null);
elSelected.attr("xlink:href", "#"+newGroup).attr("data-id", "#"+newGroup);
// select new group
markerSelectGroup.options.add(new Option(newGroup, newGroup, false, true));
toggleGroupInput();
}
function removeGroup() {
const id = elSelected.attr("data-id");
const used = document.querySelectorAll("use[data-id='"+id+"']");
const count = used.length === 1 ? "1 element" : used.length + " elements";
alertMessage.innerHTML = "Are you sure you want to remove the marker (" + count + ")?";
$("#alert").dialog({resizable: false, title: "Remove marker",
buttons: {
Remove: function() {
$(this).dialog("close");
if (id !== "#marker0") d3.select("#defs-markers").select(id).remove();
used.forEach(e => e.remove());
updateGroupOptions();
updateGroupOptions();
$("#markerEditor").dialog("close");
},
Cancel: function() {$(this).dialog("close");}
}
});
}
function toggleIconSection() {
if (markerIconSection.style.display === "inline-block") {
markerEditor.querySelectorAll("button:not(#markerIcon)").forEach(b => b.style.display = "inline-block");
markerIconSection.style.display = "none";
} else {
markerEditor.querySelectorAll("button:not(#markerIcon)").forEach(b => b.style.display = "none");
markerIconSection.style.display = "inline-block";
if (!markerIconTable.innerHTML) drawIconsList();
}
}
function drawIconsList() {
let icons = [
// emoticons in FF:
["2693", "⚓", "Anchor"],
["26EA", "⛪", "Church"],
["1F3EF", "🏯", "Japanese Castle"],
["1F3F0", "🏰", "Castle"],
["1F5FC", "🗼", "Tower"],
["1F3E0", "🏠", "House"],
["1F3AA", "🎪", "Tent"],
["1F3E8", "🏨", "Hotel"],
["1F4B0", "💰", "Money bag"],
["1F4A8", "💨", "Dashing away"],
["1F334", "🌴", "Palm"],
["1F335", "🌵", "Cactus"],
["1F33E", "🌾", "Sheaf"],
["1F5FB", "🗻", "Mountain"],
["1F30B", "🌋", "Volcano"],
["1F40E", "🐎", "Horse"],
["1F434", "🐴", "Horse Face"],
["1F42E", "🐮", "Cow"],
["1F43A", "🐺", "Wolf Face"],
["1F435", "🐵", "Monkey face"],
["1F437", "🐷", "Pig face"],
["1F414", "🐔", "Chiken"],
["1F411", "🐑", "Eve"],
["1F42B", "🐫", "Camel"],
["1F418", "🐘", "Elephant"],
["1F422", "🐢", "Turtle"],
["1F40C", "🐌", "Snail"],
["1F40D", "🐍", "Snake"],
["1F433", "🐳", "Whale"],
["1F42C", "🐬", "Dolphin"],
["1F420", "🐟", "Fish"],
["1F432", "🐲", "Dragon Head"],
["1F479", "👹", "Ogre"],
["1F47B", "👻", "Ghost"],
["1F47E", "👾", "Alien"],
["1F480", "💀", "Skull"],
["1F374", "🍴", "Fork and knife"],
["1F372", "🍲", "Food"],
["1F35E", "🍞", "Bread"],
["1F357", "🍗", "Poultry leg"],
["1F347", "🍇", "Grapes"],
["1F34F", "🍏", "Apple"],
["1F352", "🍒", "Cherries"],
["1F36F", "🍯", "Honey pot"],
["1F37A", "🍺", "Beer"],
["1F377", "🍷", "Wine glass"],
["1F3BB", "🎻", "Violin"],
["1F3B8", "🎸", "Guitar"],
["26A1", "⚡", "Electricity"],
["1F320", "🌠", "Shooting star"],
["1F319", "🌙", "Crescent moon"],
["1F525", "🔥", "Fire"],
["1F4A7", "💧", "Droplet"],
["1F30A", "🌊", "Wave"],
["231B", "⌛", "Hourglass"],
["1F3C6", "🏆", "Goblet"],
["26F2", "⛲", "Fountain"],
["26F5", "⛵", "Sailboat"],
["26FA", "⛺", "Tend"],
["1F489", "💉", "Syringe"],
["1F4D6", "📚", "Books"],
["1F3AF", "🎯", "Archery"],
["1F52E", "🔮", "Magic ball"],
["1F3AD", "🎭", "Performing arts"],
["1F3A8", "🎨", "Artist palette"],
["1F457", "👗", "Dress"],
["1F451", "👑", "Crown"],
["1F48D", "💍", "Ring"],
["1F48E", "💎", "Gem"],
["1F514", "🔔", "Bell"],
["1F3B2", "🎲", "Die"],
// black and white icons in FF:
["26A0", "⚠", "Alert"],
["2317", "⌗", "Hash"],
["2318", "⌘", "POI"],
["2307", "⌇", "Wavy"],
["21E6", "⇦", "Left arrow"],
["21E7", "⇧", "Top arrow"],
["21E8", "⇨", "Right arrow"],
["21E9", "⇩", "Left arrow"],
["21F6", "⇶", "Three arrows"],
["2699", "⚙", "Gear"],
["269B", "⚛", "Atom"],
["0024", "$", "Dollar"],
["2680", "⚀", "Die1"],
["2681", "⚁", "Die2"],
["2682", "⚂", "Die3"],
["2683", "⚃", "Die4"],
["2684", "⚄", "Die5"],
["2685", "⚅", "Die6"],
["26B4", "⚴", "Pallas"],
["26B5", "⚵", "Juno"],
["26B6", "⚶", "Vesta"],
["26B7", "⚷", "Chiron"],
["26B8", "⚸", "Lilith"],
["263F", "☿", "Mercury"],
["2640", "♀", "Venus"],
["2641", "♁", "Earth"],
["2642", "♂", "Mars"],
["2643", "♃", "Jupiter"],
["2644", "♄", "Saturn"],
["2645", "♅", "Uranus"],
["2646", "♆", "Neptune"],
["2647", "♇", "Pluto"],
["26B3", "⚳", "Ceres"],
["2654", "♔", "Chess king"],
["2655", "♕", "Chess queen"],
["2656", "♖", "Chess rook"],
["2657", "♗", "Chess bishop"],
["2658", "♘", "Chess knight"],
["2659", "♙", "Chess pawn"],
["2660", "♠", "Spade"],
["2663", "♣", "Club"],
["2665", "♥", "Heart"],
["2666", "♦", "Diamond"],
["2698", "⚘", "Flower"],
["2625", "☥", "Ankh"],
["2626", "☦", "Orthodox"],
["2627", "☧", "Chi Rho"],
["2628", "☨", "Lorraine"],
["2629", "☩", "Jerusalem"],
["2670", "♰", "Syriac cross"],
["2020", "†", "Dagger"],
["262A", "☪", "Muslim"],
["262D", "☭", "Soviet"],
["262E", "☮", "Peace"],
["262F", "☯", "Yin yang"],
["26A4", "⚤", "Heterosexuality"],
["26A2", "⚢", "Female homosexuality"],
["26A3", "⚣", "Male homosexuality"],
["26A5", "⚥", "Male and female"],
["26AD", "⚭", "Rings"],
["2690", "⚐", "White flag"],
["2691", "⚑", "Black flag"],
["263C", "☼", "Sun"],
["263E", "☾", "Moon"],
["2668", "♨", "Hot springs"],
["2600", "☀", "Black sun"],
["2601", "☁", "Cloud"],
["2602", "☂", "Umbrella"],
["2603", "☃", "Snowman"],
["2604", "☄", "Comet"],
["2605", "★", "Black star"],
["2606", "☆", "White star"],
["269D", "⚝", "Outlined star"],
["2618", "☘", "Shamrock"],
["21AF", "↯", "Lightning"],
["269C", "⚜", "FleurDeLis"],
["2622", "☢", "Radiation"],
["2623", "☣", "Biohazard"],
["2620", "☠", "Skull"],
["2638", "☸", "Dharma"],
["2624", "☤", "Caduceus"],
["2695", "⚕", "Aeculapius staff"],
["269A", "⚚", "Hermes staff"],
["2697", "⚗", "Alembic"],
["266B", "♫", "Music"],
["2702", "✂", "Scissors"],
["2696", "⚖", "Scales"],
["2692", "⚒", "Hammer and pick"],
["2694", "⚔", "Swords"]
];
const table = document.getElementById("markerIconTable");
table.addEventListener("click", selectIcon, false);
table.addEventListener("mouseover", hoverIcon, false);
let row = "";
for (let i=0; i < icons.length; i++) {
if (i%16 === 0) row = table.insertRow(0);
const cell = row.insertCell(0);
const icon = String.fromCodePoint(parseInt(icons[i][0], 16));
cell.innerHTML = icon;
cell.id = "markerIcon" + icon.codePointAt();
cell.dataset.desc = icons[i][2];
}
}
function selectIcon(e) {
if (e.target !== e.currentTarget) {
const table = document.getElementById("markerIconTable");
const selected = table.getElementsByClassName("selected");
if (selected.length) selected[0].removeAttribute("class");
e.target.className = "selected";
const id = elSelected.attr("data-id");
const icon = e.target.innerHTML;
d3.select("#defs-markers").select(id).select("text").text(icon);
}
e.stopPropagation();
}
function hoverIcon(e) {
if (e.target !== e.currentTarget) tip(e.target.innerHTML + " " + e.target.dataset.desc);
e.stopPropagation();
}
function changeIconSize() {
const id = elSelected.attr("data-id");
d3.select("#defs-markers").select(id).select("text").attr("font-size", this.value + "px");
}
function changeIconShiftX() {
const id = elSelected.attr("data-id");
d3.select("#defs-markers").select(id).select("text").attr("x", this.value + "%");
}
function changeIconShiftY() {
const id = elSelected.attr("data-id");
d3.select("#defs-markers").select(id).select("text").attr("y", this.value + "%");
}
function applyCustomUnicodeIcon() {
if (!this.value) return;
const id = elSelected.attr("data-id");
d3.select("#defs-markers").select(id).select("text").text(this.value);
}
function toggleStyleSection() {
if (markerStyleSection.style.display === "inline-block") {
markerEditor.querySelectorAll("button:not(#markerStyle)").forEach(b => b.style.display = "inline-block");
markerStyleSection.style.display = "none";
} else {
markerEditor.querySelectorAll("button:not(#markerStyle)").forEach(b => b.style.display = "none");
markerStyleSection.style.display = "inline-block";
}
}
function changeMarkerSize() {
const id = elSelected.attr("data-id");
document.querySelectorAll("use[data-id='"+id+"']").forEach(e => e.dataset.size = markerSize.value);
invokeActiveZooming();
}
function changePinStroke() {
const id = elSelected.attr("data-id");
d3.select(id).select("path").attr("fill", this.value);
d3.select(id).select("circle").attr("stroke", this.value);
}
function changePinFill() {
const id = elSelected.attr("data-id");
d3.select(id).select("circle").attr("fill", this.value);
}
function changeIconStrokeWidth() {
const id = elSelected.attr("data-id");
d3.select("#defs-markers").select(id).select("text").attr("stroke-width", this.value);
}
function changeIconStroke() {
const id = elSelected.attr("data-id");
d3.select("#defs-markers").select(id).select("text").attr("stroke", this.value);
}
function changeIconFill() {
const id = elSelected.attr("data-id");
d3.select("#defs-markers").select(id).select("text").attr("fill", this.value);
}
function togglePinVisibility() {
const id = elSelected.attr("data-id");
let show = 1;
if (this.className === "icon-info-circled") {this.className = "icon-info"; show = 0; }
else this.className = "icon-info-circled";
d3.select(id).select("circle").attr("opacity", show);
d3.select(id).select("path").attr("opacity", show);
}
function editMarkerLegend() {
const id = elSelected.attr("id");
editLegends(id, id);
}
function toggleAddMarker() {
document.getElementById("addMarker").click();
}
function removeMarker() {
alertMessage.innerHTML = "Are you sure you want to remove the marker?";
$("#alert").dialog({resizable: false, title: "Remove marker",
buttons: {
Remove: function() {
$(this).dialog("close");
elSelected.remove();
$("#markerEditor").dialog("close");
},
Cancel: function() {$(this).dialog("close");}
}
});
}
function closeMarkerEditor() {
unselect();
if (addMarker.classList.contains("pressed")) addMarker.classList.remove("pressed");
if (markerAdd.classList.contains("pressed")) markerAdd.classList.remove("pressed");
restoreDefaultEvents();
clearMainTip();
}
}

277
modules/ui/measurers.js Normal file
View file

@ -0,0 +1,277 @@
// UI measurers: rulers (linear, curve, area) and Scale Bar
"use strict";
// Linear measurer (one is added by default)
function addRuler(x1, y1, x2, y2) {
const cx = rn((x1 + x2) / 2, 2), cy = rn((y1 + y2) / 2, 2);
const size = rn(1 / scale ** .3 * 2, 1);
const dash = rn(30 / distanceScale.value, 2);
// body
const rulerNew = ruler.append("g").call(d3.drag().on("start", dragRuler));
rulerNew.append("line").attr("x1", x1).attr("y1", y1).attr("x2", x2).attr("y2", y2).attr("class", "white").attr("stroke-width", size);
rulerNew.append("line").attr("x1", x1).attr("y1", y1).attr("x2", x2).attr("y2", y2).attr("class", "gray").attr("stroke-width", size).attr("stroke-dasharray", dash);
rulerNew.append("circle").attr("r", 2 * size).attr("stroke-width", .5 * size).attr("cx", x1).attr("cy", y1).attr("data-edge", "left").call(d3.drag().on("drag", dragRulerEdge));
rulerNew.append("circle").attr("r", 2 * size).attr("stroke-width", .5 * size).attr("cx", x2).attr("cy", y2).attr("data-edge", "right").call(d3.drag().on("drag", dragRulerEdge));
// label and center
const angle = Math.atan2(y2 - y1, x2 - x1) * 180 / Math.PI;
const rotate = `rotate(${angle} ${cx} ${cy})`;
const dist = rn(Math.hypot(x1 - x2, y1 - y2));
const label = rn(dist * distanceScale.value) + " " + distanceUnit.value;
rulerNew.append("rect").attr("x", cx - size * 1.5).attr("y", cy - size * 1.5).attr("width", size * 3).attr("height", size * 3).attr("transform", rotate).attr("stroke-width", .5 * size).call(d3.drag().on("start", rulerCenterDrag));
rulerNew.append("text").attr("x", cx).attr("y", cy).attr("dx", ".3em").attr("dy", "-.3em").attr("transform", rotate).attr("font-size", 10 * size).text(label).on("click", removeParent);
}
function dragRuler() {
const tr = parseTransform(this.getAttribute("transform"));
const x = +tr[0] - d3.event.x, y = +tr[1] - d3.event.y;
d3.event.on("drag", function() {
const transform = `translate(${(x + d3.event.x)},${(y + d3.event.y)})`;
this.setAttribute("transform", transform);
});
}
function dragRulerEdge() {
const ruler = d3.select(this.parentNode);
const x = d3.event.x, y = d3.event.y;
d3.select(this).attr("cx", x).attr("cy", y);
const line = ruler.selectAll("line");
const left = this.dataset.edge === "left";
const x0 = left ? +line.attr("x2") : +line.attr("x1");
const y0 = left ? +line.attr("y2") : +line.attr("y1");
if (left) line.attr("x1", x).attr("y1", y); else line.attr("x2", x).attr("y2", y);
const cx = rn((x + x0) / 2, 2), cy = rn((y + y0) / 2, 2);
const dist = Math.hypot(x0 - x, y0 - y);
const label = rn(dist * distanceScale.value) + " " + distanceUnit.value;
const atan = x0 > x ? Math.atan2(y0 - y, x0 - x) : Math.atan2(y - y0, x - x0);
const angle = rn(atan * 180 / Math.PI, 3);
const rotate = `rotate(${angle} ${cx} ${cy})`;
const size = rn(1 / scale ** .3 * 2, 1);
ruler.select("rect").attr("x", cx - size * 1.5).attr("y", cy - size * 1.5).attr("transform", rotate);
ruler.select("text").attr("x", cx).attr("y", cy).attr("transform", rotate).text(label);
}
function rulerCenterDrag() {
let xc1, yc1, xc2, yc2, r1, r2;
const rulerOld = d3.select(this.parentNode); // current ruler
let x = d3.event.x, y = d3.event.y; // current coords
const line = rulerOld.selectAll("line"); // current lines
const x1 = +line.attr("x1"), y1 = +line.attr("y1"), x2 = +line.attr("x2"), y2 = +line.attr("y2"); // initial line edge points
const size = rn(1 / scale ** .3 * 2, 1);
const dash = +rulerOld.select(".gray").attr("stroke-dasharray");
const rulerNew = ruler.insert("g", ":first-child");
rulerNew.attr("transform", rulerOld.attr("transform")).call(d3.drag().on("start", dragRuler));
rulerNew.append("line").attr("class", "white").attr("stroke-width", size);
rulerNew.append("line").attr("class", "gray").attr("stroke-dasharray", dash).attr("stroke-width", size);
rulerNew.append("text").attr("dx", ".3em").attr("dy", "-.3em").on("click", removeParent).attr("font-size", 10 * size).attr("stroke-width", size);
d3.event.on("drag", function() {
x = d3.event.x, y = d3.event.y;
// change first part
let dist = rn(Math.hypot(x1 - x, y1 - y));
let label = rn(dist * distanceScale.value) + " " + distanceUnit.value;
let atan = x1 > x ? Math.atan2(y1 - y, x1 - x) : Math.atan2(y - y1, x - x1);
xc1 = rn((x + x1) / 2, 2), yc1 = rn((y + y1) / 2, 2);
r1 = `rotate(${rn(atan * 180 / Math.PI, 3)} ${xc1} ${yc1})`;
line.attr("x1", x1).attr("y1", y1).attr("x2", x).attr("y2", y);
rulerOld.select("rect").attr("x", x - size * 1.5).attr("y", y - size * 1.5).attr("transform", null);
rulerOld.select("text").attr("x", xc1).attr("y", yc1).attr("transform", r1).text(label);
// change second (new) part
dist = rn(Math.hypot(x2 - x, y2 - y));
label = rn(dist * distanceScale.value) + " " + distanceUnit.value;
atan = x2 > x ? Math.atan2(y2 - y, x2 - x) : Math.atan2(y - y2, x - x2);
xc2 = rn((x + x2) / 2, 2), yc2 = rn((y + y2) / 2, 2);
r2 = `rotate(${rn(atan * 180 / Math.PI, 3)} ${xc2} ${yc2})`;
rulerNew.selectAll("line").attr("x1", x).attr("y1", y).attr("x2", x2).attr("y2", y2);
rulerNew.select("text").attr("x", xc2).attr("y", yc2).attr("transform", r2).text(label);
});
d3.event.on("end", function() {
// contols for 1st part
rulerOld.select("circle[data-edge='left']").attr("cx", x1).attr("cy", y1);
rulerOld.select("circle[data-edge='right']").attr("cx", x).attr("cy", y);
rulerOld.select("rect").attr("x", xc1 - size * 1.5).attr("y", yc1 - size * 1.5).attr("transform", r1);
// contols for 2nd part
rulerNew.append("circle").attr("cx", x).attr("cy", y).attr("r", 2 * size).attr("stroke-width", 0.5 * size).attr("data-edge", "left").call(d3.drag().on("drag", dragRulerEdge));
rulerNew.append("circle").attr("cx", x2).attr("cy", y2).attr("r", 2 * size).attr("stroke-width", 0.5 * size).attr("data-edge", "right").call(d3.drag().on("drag", dragRulerEdge));
rulerNew.append("rect").attr("x", xc2 - size * 1.5).attr("y", yc2 - size * 1.5).attr("width", size * 3).attr("height", size * 3).attr("transform", r2).attr("stroke-width", .5 * size).call(d3.drag().on("start", rulerCenterDrag));
});
}
function drawOpisometer() {
lineGen.curve(d3.curveBasis);
const size = rn(1 / scale ** .3 * 2, 1);
const dash = rn(30 / distanceScale.value, 2);
const p0 = d3.mouse(this);
const points = [[p0[0], p0[1]]];
let length = 0;
const rulerNew = ruler.append("g").call(d3.drag().on("start", dragRuler));
const curve = rulerNew.append("path").attr("class", "white").attr("stroke-width", size);
const curveGray = rulerNew.append("path").attr("class", "gray").attr("stroke-width", size).attr("stroke-dasharray", dash);
const text = rulerNew.append("text").attr("dy", "-.3em").attr("font-size", 10 * size).on("click", removeParent);
const start = rulerNew.append("circle").attr("r", 2 * size).attr("stroke-width", .5 * size).attr("data-edge", "start").call(d3.drag().on("start", dragOpisometerEnd));
const end = rulerNew.append("circle").attr("r", 2 * size).attr("stroke-width", .5 * size).attr("data-edge", "end").call(d3.drag().on("start", dragOpisometerEnd));
d3.event.on("drag", function() {
const p = d3.mouse(this);
const diff = Math.hypot(last(points)[0] - p[0], last(points)[1] - p[1]);
if (diff > 3) points.push([p[0], p[1]]); else return;
const path = round(lineGen(points));
curve.attr("d", path);
curveGray.attr("d", path);
length = curve.node().getTotalLength();
const label = rn(length * distanceScale.value) + " " + distanceUnit.value;
text.attr("x", p[0]).attr("y", p[1]).text(label);
});
d3.event.on("end", function() {
restoreDefaultEvents();
clearMainTip();
addOpisometer.classList.remove("pressed");
const c = curve.node().getPointAtLength(length / 2);
const p = curve.node().getPointAtLength(length / 2 - 1);
const atan = p.x > c.x ? Math.atan2(p.y - c.y, p.x - c.x) : Math.atan2(c.y - p.y, c.x - p.x);
const angle = rn(atan * 180 / Math.PI, 3);
const rotate = `rotate(${angle} ${c.x} ${c.y})`;
rulerNew.attr("data-points", JSON.stringify(points));
text.attr("x", c.x).attr("y", c.y).attr("transform", rotate);
start.attr("cx", points[0][0]).attr("cy", points[0][1]);
end.attr("cx", last(points)[0]).attr("cy", last(points)[1]);
});
}
function dragOpisometerEnd() {
const ruler = d3.select(this.parentNode);
const curve = ruler.select(".white");
const curveGray = ruler.select(".gray");
const text = ruler.select("text");
const points = JSON.parse(ruler.attr("data-points"));
const x0 = +this.getAttribute("cx"), y0 = +this.getAttribute("cy");
if (x0 === points[0][0] && y0 === points[0][1]) points.reverse();
lineGen.curve(d3.curveBasis);
let length = 0;
d3.event.on("drag", function() {
const p = d3.mouse(this);
d3.select(this).attr("cx", p[0]).attr("cy", p[1]);
const diff = Math.hypot(last(points)[0] - p[0], last(points)[1] - p[1]);
if (diff > 3) points.push([p[0], p[1]]); else return;
const path = round(lineGen(points));
curve.attr("d", path);
curveGray.attr("d", path);
length = curve.node().getTotalLength();
const label = rn(length * distanceScale.value) + " " + distanceUnit.value;
text.text(label);
});
d3.event.on("end", function() {
const c = curve.node().getPointAtLength(length / 2);
const p = curve.node().getPointAtLength(length / 2 - 1);
const atan = p.x > c.x ? Math.atan2(p.y - c.y, p.x - c.x) : Math.atan2(c.y - p.y, c.x - p.x);
const angle = rn(atan * 180 / Math.PI, 3);
const rotate = `rotate(${angle} ${c.x} ${c.y})`;
ruler.attr("data-points", JSON.stringify(points));
text.attr("x", c.x).attr("y", c.y).attr("transform", rotate);
});
}
function drawPlanimeter() {
lineGen.curve(d3.curveBasisClosed);
const size = rn(1 / scale ** .3 * 2, 1);
const p0 = d3.mouse(this);
const points = [[p0[0], p0[1]]];
const rulerNew = ruler.append("g").call(d3.drag().on("start", dragRuler));
const curve = rulerNew.append("path").attr("class", "planimeter").attr("stroke-width", size);
const text = rulerNew.append("text").attr("font-size", 10 * size).on("click", removeParent);
d3.event.on("drag", function() {
const p = d3.mouse(this);
const diff = Math.hypot(last(points)[0] - p[0], last(points)[1] - p[1]);
if (diff > 5) points.push([p[0], p[1]]); else return;
curve.attr("d", round(lineGen(points)));
});
d3.event.on("end", function() {
restoreDefaultEvents();
clearMainTip();
addPlanimeter.classList.remove("pressed");
const polygonArea = rn(Math.abs(d3.polygonArea(points)));
const unit = areaUnit.value === "square" ? " " + distanceUnit.value + "²" : " " + areaUnit.value;
const area = si(polygonArea * distanceScale.value ** 2) + " " + unit;
const c = polylabel([points], 1.0); // pole of inaccessibility
text.attr("x", c[0]).attr("y", c[1]).text(area);
});
}
// draw default scale bar
function drawScaleBar() {
if (scaleBar.style("display") === "none") return; // no need to re-draw hidden element
scaleBar.selectAll("*").remove(); // fully redraw every time
const dScale = distanceScale.value;
const unit = distanceUnit.value;
// calculate size
const init = 100; // actual length in pixels if scale, dScale and size = 1;
const size = +barSize.value;
let val = init * size * dScale / scale; // bar length in distance unit
if (val > 900) val = rn(val, -3); // round to 1000
else if (val > 90) val = rn(val, -2); // round to 100
else if (val > 9) val = rn(val, -1); // round to 10
else val = rn(val) // round to 1
const l = val * scale / dScale; // actual length in pixels on this scale
scaleBar.append("line").attr("x1", 0.5).attr("y1", 0).attr("x2", l+size-0.5).attr("y2", 0).attr("stroke-width", size).attr("stroke", "white");
scaleBar.append("line").attr("x1", 0).attr("y1", size).attr("x2", l+size).attr("y2", size).attr("stroke-width", size).attr("stroke", "#3d3d3d");
const dash = size + " " + rn(l / 5 - size, 2);
scaleBar.append("line").attr("x1", 0).attr("y1", 0).attr("x2", l+size).attr("y2", 0)
.attr("stroke-width", rn(size * 3, 2)).attr("stroke-dasharray", dash).attr("stroke", "#3d3d3d");
const fontSize = rn(5 * size, 1);
scaleBar.selectAll("text").data(d3.range(0,6)).enter().append("text")
.attr("x", d => rn(d * l/5, 2)).attr("y", 0).attr("dy", "-.5em")
.attr("font-size", fontSize).text(d => rn(d * l/5 * dScale / scale) + (d<5 ? "" : " " + unit));
if (barLabel.value !== "") {
scaleBar.append("text").attr("x", (l+1) / 2).attr("y", 2 * size)
.attr("dominant-baseline", "text-before-edge")
.attr("font-size", fontSize).text(barLabel.value);
}
const bbox = scaleBar.node().getBBox();
// append backbround rectangle
scaleBar.insert("rect", ":first-child").attr("x", -10).attr("y", -20).attr("width", bbox.width + 10).attr("height", bbox.height + 15)
.attr("stroke-width", size).attr("stroke", "none").attr("filter", "url(#blur5)")
.attr("fill", barBackColor.value).attr("opacity", +barBackOpacity.value);
fitScaleBar();
}
// fit ScaleBar to map size
function fitScaleBar() {
if (!scaleBar.select("rect").size()) return;
const px = isNaN(+barPosX.value) ? 100 : barPosX.value / 100;
const py = isNaN(+barPosY.value) ? 100 : barPosY.value / 100;
const bbox = scaleBar.select("rect").node().getBBox();
const x = rn(svgWidth * px - bbox.width + 10), y = rn(svgHeight * py - bbox.height + 20);
scaleBar.attr("transform", `translate(${x},${y})`);
}

View file

@ -0,0 +1,177 @@
"use strict";
function editNamesbase() {
if (customization) return;
closeDialogs("#namesbaseEditor, .stable");
$("#namesbaseEditor").dialog();
if (modules.editNamesbase) return;
modules.editNamesbase = true;
// add listeners
document.getElementById("namesbaseSelect").addEventListener("change", updateInputs);
document.getElementById("namesbaseTextarea").addEventListener("change", updateNamesData);
document.getElementById("namesbaseUpdateExamples").addEventListener("click", updateExamples);
document.getElementById("namesbaseExamples").addEventListener("click", updateExamples);
document.getElementById("namesbaseName").addEventListener("input", updateBaseName);
document.getElementById("namesbaseMin").addEventListener("input", updateBaseMin);
document.getElementById("namesbaseMax").addEventListener("input", updateBaseMax);
document.getElementById("namesbaseDouble").addEventListener("input", updateBaseDublication);
document.getElementById("namesbaseMulti").addEventListener("input", updateBaseMiltiwordRate);
document.getElementById("namesbaseAdd").addEventListener("click", namesbaseAdd);
document.getElementById("namesbaseDefault").addEventListener("click", namesbaseRestoreDefault);
document.getElementById("namesbaseDownload").addEventListener("click", namesbaseDownload);
document.getElementById("namesbaseUpload").addEventListener("click", e => namesbaseToLoad.click());
document.getElementById("namesbaseToLoad").addEventListener("change", namesbaseUpload);
createBasesList();
updateInputs();
$("#namesbaseEditor").dialog({
title: "Namesbase Editor", width: 468,
position: {my: "center", at: "center", of: "svg"}
});
function createBasesList() {
const select = document.getElementById("namesbaseSelect");
select.innerHTML = "";
nameBases.forEach(function(b, i) {
const option = new Option(b.name, i);
select.options.add(option);
});
}
function updateInputs() {
const base = +document.getElementById("namesbaseSelect").value;
if (!nameBases[base]) {tip(`Namesbase ${base} is not defined`, false, "error"); return;}
document.getElementById("namesbaseTextarea").value = nameBase[base].join(", ");
document.getElementById("namesbaseName").value = nameBases[base].name;
document.getElementById("namesbaseMin").value = nameBases[base].min;
document.getElementById("namesbaseMax").value = nameBases[base].max;
document.getElementById("namesbaseDouble").value = nameBases[base].d;
document.getElementById("namesbaseMulti").value = nameBases[base].m;
updateExamples();
}
function updateExamples() {
const base = +document.getElementById("namesbaseSelect").value;
let examples = "";
for (let i=0; i < 10; i++) {
const example = Names.getBase(base);
if (example === undefined) {
examples = "Cannot generate examples. Please verify the data";
break;
}
if (i) examples += ", ";
examples += example;
}
document.getElementById("namesbaseExamples").innerHTML = examples;
}
function updateNamesData() {
const base = +document.getElementById("namesbaseSelect").value;
const data = document.getElementById("namesbaseTextarea").value.replace(/ /g, "").split(",");
if (data.length < 3) {
tip("The names data provided is not correct", false, "error");
document.getElementById("namesbaseTextarea").value = nameBase[base].join(", ");
return;
}
nameBase[base] = data;
Names.updateChain(base);
}
function updateBaseName() {
const base = +document.getElementById("namesbaseSelect").value;
const select = document.getElementById("namesbaseSelect");
select.options[namesbaseSelect.selectedIndex].innerHTML = this.value;
nameBases[base].name = this.value;
}
function updateBaseMin() {
const base = +document.getElementById("namesbaseSelect").value;
if (+this.value > nameBases[base].max) {tip("Minimal length cannot be greater than maximal", false, "error"); return;}
nameBases[base].min = +this.value;
}
function updateBaseMax() {
const base = +document.getElementById("namesbaseSelect").value;
if (+this.value < nameBases[base].min) {tip("Maximal length should be greater than minimal", false, "error"); return;}
nameBases[base].max = +this.value;
}
function updateBaseDublication() {
const base = +document.getElementById("namesbaseSelect").value;
nameBases[base].d = this.value;
}
function updateBaseMiltiwordRate() {
if (isNaN(+this.value) || +this.value < 0 || +this.value > 1) {tip("Please provide a number within [0-1] range", false, "error"); return;}
const base = +document.getElementById("namesbaseSelect").value;
nameBases[base].m = +this.value;
}
function namesbaseAdd() {
const base = nameBases.length;
nameBases.push({name: "Base" + base, min: 5, max: 12, d: "", m: 0});
nameBase[base] = ["This", "is", "an", "example", "data", "Please", "replace", "with", "an", "actual", "names", "data", "with", "at", "least", "100", "names"];
document.getElementById("namesbaseSelect").add(new Option("Base" + base, base));
document.getElementById("namesbaseSelect").value = base;
document.getElementById("namesbaseTextarea").value = nameBase[base].join(", ");
document.getElementById("namesbaseName").value = "Base" + base;
document.getElementById("namesbaseMin").value = 5;
document.getElementById("namesbaseMax").value = 12;
document.getElementById("namesbaseDouble").value = "";
document.getElementById("namesbaseMulti").value = 0;
document.getElementById("namesbaseExamples").innerHTML = "Please provide names data";
}
function namesbaseRestoreDefault() {
alertMessage.innerHTML = `Are you sure you want to restore default namesbase?`;
$("#alert").dialog({resizable: false, title: "Restore default data",
buttons: {
Restore: function() {
$(this).dialog("close");
applyDefaultNamesData();
createBasesList();
updateInputs();
Names.updateChains();
},
Cancel: function() {$(this).dialog("close");}
}
});
}
function namesbaseDownload() {
const data = nameBases.map((b,i) => `${b.name}|${b.min}|${b.max}|${b.d}|${b.m}|${nameBase[i]}`);
const dataBlob = new Blob([data.join("\r\n")], {type:"text/plain"});
const url = window.URL.createObjectURL(dataBlob);
const link = document.createElement("a");
link.download = "namesbase" + Date.now() + ".txt";
link.href = url;
link.click();
}
function namesbaseUpload() {
const fileToLoad = this.files[0];
this.value = "";
const fileReader = new FileReader();
fileReader.onload = function(fileLoadedEvent) {
const dataLoaded = fileLoadedEvent.target.result;
const data = dataLoaded.split("\r\n");
if (!data || !data[0]) {tip("Cannot load a namesbase. Please check the data format", false, "error"); return;}
nameBases = [], nameBase = [];
data.forEach(d => {
const e = d.split("|");
nameBases.push({name:e[0], min:e[1], max:e[2], d:e[3], m:d[4]});
nameBase.push(e[5].split(","));
});
createBasesList();
updateInputs();
Names.updateChains();
};
fileReader.readAsText(fileToLoad, "UTF-8");
}
}

954
modules/ui/options.js Normal file
View file

@ -0,0 +1,954 @@
// UI module to control the options (style, preferences)
"use strict";
$("#optionsContainer").draggable({handle: ".drag-trigger", snap: "svg", snapMode: "both"});
$("#mapLayers").disableSelection();
// show control elements and remove loading screen on map load
d3.select("#loading").transition().duration(5000).style("opacity", 0).remove();
d3.select("#initial").transition().duration(5000).attr("opacity", 0).remove();
d3.select("#optionsContainer").transition().duration(5000).style("opacity", 1);
d3.select("#tooltip").transition().duration(5000).style("opacity", 1);
// remove glow if tip is aknowledged
if (localStorage.getItem("disable_click_arrow_tooltip")) {
clearMainTip();
optionsTrigger.classList.remove("glow");
}
// Show options pane on trigger click
function showOptions(event) {
if (!localStorage.getItem("disable_click_arrow_tooltip")) {
clearMainTip();
localStorage.setItem("disable_click_arrow_tooltip", true);
optionsTrigger.classList.remove("glow");
}
regenerate.style.display = "none";
options.style.display = "block";
optionsTrigger.style.display = "none";
if (event) event.stopPropagation();
}
// Hide options pane on trigger click
function hideOptions(event) {
options.style.display = "none";
optionsTrigger.style.display = "block";
if (event) event.stopPropagation();
}
// To toggle options on hotkey press
function toggleOptions(event) {
if (options.style.display === "none") showOptions(event);
else hideOptions(event);
}
// Toggle "New Map!" pane on hover
optionsTrigger.addEventListener("mouseenter", function() {
if (optionsTrigger.classList.contains("glow")) return;
if (options.style.display === "none") regenerate.style.display = "block";
});
collapsible.addEventListener("mouseleave", function() {
regenerate.style.display = "none";
});
// Activate options tab on click
options.querySelector("div.tab").addEventListener("click", function(event) {
if (event.target.tagName !== "BUTTON") return;
const id = event.target.id;
const active = options.querySelector(".tab > button.active");
if (active && id === active.id) return; // already active tab is clicked
if (active) active.classList.remove("active");
document.getElementById(id).classList.add("active");
options.querySelectorAll(".tabcontent").forEach(e => e.style.display = "none");
if (id === "styleTab") styleContent.style.display = "block"; else
if (id === "optionsTab") optionsContent.style.display = "block"; else
if (id === "toolsTab" && !customization) toolsContent.style.display = "block"; else
if (id === "toolsTab" && customization) customizationMenu.style.display = "block"; else
if (id === "aboutTab") aboutContent.style.display = "block";
});
options.querySelectorAll("i.collapsible").forEach(el => el.addEventListener("click", collapse));
function collapse(e) {
const trigger = e.target;
const section = trigger.parentElement.nextElementSibling;
if (section.style.display === "none") {
section.style.display = "block";
trigger.classList.replace("icon-down-open", "icon-up-open");
} else {
section.style.display = "none";
trigger.classList.replace("icon-up-open", "icon-down-open");
}
}
// Toggle style sections on element select
styleElementSelect.addEventListener("change", selectStyleElement);
function selectStyleElement() {
const sel = styleElementSelect.value;
let el = viewbox.select("#"+sel);
styleElements.querySelectorAll("tbody").forEach(e => e.style.display = "none"); // hide all sections
const off = el.style("display") === "none" || !el.selectAll("*").size(); // check if layer is off
if (off) {
styleIsOff.style.display = "block";
setTimeout(() => styleIsOff.style.display = "none", 1500);
}
// active group element
const group = styleGroupSelect.value;
if (sel == "ocean") el = oceanLayers.select("rect");
else if (sel == "routes" || sel == "labels" || sel == "lakes" || sel == "anchors" || sel == "burgIcons") {
el = d3.select("#"+sel).select("g#"+group).size()
? d3.select("#"+sel).select("g#"+group)
: d3.select("#"+sel).select("g");
}
if (sel !== "landmass") {
// opacity
styleOpacity.style.display = "block";
styleOpacityInput.value = styleOpacityOutput.value = el.attr("opacity") || 1;
// filter
styleFilter.style.display = "block";
if (sel == "ocean") el = oceanLayers;
styleFilterInput.value = el.attr("filter") || "";
}
// fill
if (sel === "rivers" || sel === "lakes" || sel === "landmass" || sel === "prec") {
styleFill.style.display = "block";
styleFillInput.value = styleFillOutput.value = el.attr("fill");
}
// stroke color and width
if (sel === "routes" || sel === "lakes" || sel === "borders" || sel === "cults" || sel === "cells" || sel === "gridOverlay" || sel === "coastline" || sel === "prec" || sel === "icons" || sel === "coordinates") {
styleStroke.style.display = "block";
styleStrokeInput.value = styleStrokeOutput.value = el.attr("stroke");
styleStrokeWidth.style.display = "block";
styleStrokeWidthInput.value = styleStrokeWidthOutput.value = el.attr("stroke-width") || "";
}
// stroke dash
if (sel === "routes" || sel === "borders" || sel === "gridOverlay" || sel === "temperature" || sel === "population" || sel === "coordinates") {
styleStrokeDash.style.display = "block";
styleStrokeDasharrayInput.value = el.attr("stroke-dasharray") || "";
styleStrokeLinecapInput.value = el.attr("stroke-linecap") || "inherit";
}
// clipping
if (sel === "cells" || sel === "gridOverlay" || sel === "coordinates" || sel === "compass" || sel === "terrain" || sel === "temperature" || sel === "routes" || sel === "texture" || sel === "biomes") {
styleClipping.style.display = "block";
styleClippingInput.value = el.attr("mask") || "";
}
// shift (translate)
if (sel === "gridOverlay") {
styleShift.style.display = "block";
const tr = parseTransform(el.attr("transform"));
styleShiftX.value = tr[0];
styleShiftY.value = tr[1];
}
if (sel === "compass") {
styleCompass.style.display = "block";
const tr = parseTransform(d3.select("#rose").attr("transform"));
styleCompassShiftX.value = tr[0];
styleCompassShiftY.value = tr[1];
styleCompassSizeInput.value = styleCompassSizeOutput.value = tr[2];
}
// show specific sections
if (sel === "terrs") styleHeightmap.style.display = "block";
if (sel === "gridOverlay") styleGrid.style.display = "block";
if (sel === "terrain") styleRelief.style.display = "block";
if (sel === "texture") styleTexture.style.display = "block";
if (sel === "routes" || sel === "labels" || sel == "anchors" || sel == "burgIcons" || sel === "lakes") {styleGroup.style.display = "block";}
if (sel === "population") {
stylePopulation.style.display = "block";
stylePopulationRuralStrokeInput.value = stylePopulationRuralStrokeOutput.value = population.select("#rural").attr("stroke");
stylePopulationUrbanStrokeInput.value = stylePopulationUrbanStrokeOutput.value = population.select("#urban").attr("stroke");
styleStrokeWidth.style.display = "block";
styleStrokeWidthInput.value = styleStrokeWidthOutput.value = el.attr("stroke-width") || "";
}
if (sel === "labels") {
styleFill.style.display = "block";
styleStroke.style.display = "block";
styleStrokeWidth.style.display = "block";
loadDefaultFonts();
styleFont.style.display = "block";
styleSize.style.display = "block";
styleVisibility.style.display = "block";
styleFillInput.value = styleFillOutput.value = el.attr("fill") || "#3e3e4b";
styleStrokeInput.value = styleStrokeOutput.value = el.attr("stroke") || "#3a3a3a";
styleStrokeWidthInput.value = styleStrokeWidthOutput.value = el.attr("stroke-width") || 0;
styleSelectFont.value = fonts.indexOf(el.attr("data-font"));
styleInputFont.style.display = "none";
styleInputFont.value = "";
styleFontSize.value = el.attr("data-size");
}
if (sel == "burgIcons") {
styleFill.style.display = "block";
styleStroke.style.display = "block";
styleStrokeWidth.style.display = "block";
styleStrokeDash.style.display = "block";
styleRadius.style.display = "block";
styleFillInput.value = styleFillOutput.value = el.attr("fill") || "#ffffff";
styleStrokeInput.value = styleStrokeOutput.value = el.attr("stroke") || "#3e3e4b";
styleStrokeWidthInput.value = styleStrokeWidthOutput.value = el.attr("stroke-width") || .24;
styleStrokeDasharrayInput.value = el.attr("stroke-dasharray") || "";
styleStrokeLinecapInput.value = el.attr("stroke-linecap") || "inherit";
styleRadiusInput.value = el.attr("size") || 1;
}
if (sel == "anchors") {
styleFill.style.display = "block";
styleStroke.style.display = "block";
styleStrokeWidth.style.display = "block";
styleIconSize.style.display = "block";
styleFillInput.value = styleFillOutput.value = el.attr("fill") || "#ffffff";
styleStrokeInput.value = styleStrokeOutput.value = el.attr("stroke") || "#3e3e4b";
styleStrokeWidthInput.value = styleStrokeWidthOutput.value = el.attr("stroke-width") || .24;
styleIconSizeInput.value = el.attr("size") || 2;
}
if (sel === "ocean") {
styleOcean.style.display = "block";
styleOceanBack.value = styleOceanBackOutput.value = svg.attr("background-color");
styleOceanFore.value = styleOceanForeOutput.value = oceanLayers.select("rect").attr("fill");
}
if (sel === "coastline") {
styleCoastline.style.display = "block";
if (styleCoastlineAuto.checked) styleFilter.style.display = "none";
}
if (sel === "temperature") {
styleStrokeWidth.style.display = "block";
styleTemperature.style.display = "block";
styleStrokeWidthInput.value = styleStrokeWidthOutput.value = el.attr("stroke-width") || "";
styleTemperatureFillOpacityInput.value = styleTemperatureFillOpacityOutput.value = el.attr("fill-opacity") || .1;
styleTemperatureFillInput.value = styleTemperatureFillOutput.value = el.attr("fill") || "#000";
styleTemperatureFontSizeInput.value = styleTemperatureFontSizeOutput.value = el.attr("font-size") || "8px";;
}
if (sel === "coordinates") {
styleSize.style.display = "block";
styleFontSize.value = el.attr("data-size");
}
// update group options
styleGroupSelect.options.length = 0; // remove all options
if (sel === "routes" || sel === "labels" || sel === "lakes" || sel === "anchors" || sel === "burgIcons") {
document.getElementById(sel).querySelectorAll("g").forEach(el => {
if (el.id === "burgLabels") return;
const count = el.childElementCount;
styleGroupSelect.options.add(new Option(`${el.id} (${count})`, el.id, false, false));
});
styleGroupSelect.value = el.attr("id");
} else {
styleGroupSelect.options.add(new Option(sel, sel, false, true));
}
}
// Handle style inputs change
styleGroupSelect.addEventListener("change", selectStyleElement);
function getEl() {
const el = styleElementSelect.value, g = styleGroupSelect.value;
if (g === el) return svg.select("#"+el); else return svg.select("#"+el).select("#"+g);
}
styleFillInput.addEventListener("input", function() {
styleFillOutput.value = this.value;
getEl().attr('fill', this.value);
});
styleStrokeInput.addEventListener("input", function() {
styleStrokeOutput.value = this.value;
getEl().attr('stroke', this.value);
});
styleStrokeWidthInput.addEventListener("input", function() {
styleStrokeWidthOutput.value = this.value;
getEl().attr('stroke-width', +this.value);
});
styleStrokeDasharrayInput.addEventListener("input", function() {
getEl().attr('stroke-dasharray', this.value);
});
styleStrokeLinecapInput.addEventListener("change", function() {
getEl().attr('stroke-linecap', this.value);
});
styleOpacityInput.addEventListener("input", function() {
styleOpacityOutput.value = this.value;
getEl().attr('opacity', this.value);
});
styleFilterInput.addEventListener("change", function() {
if (styleGroupSelect.value === "ocean") {oceanLayers.attr('filter', this.value); return;}
getEl().attr('filter', this.value);
});
styleTextureInput.addEventListener("change", function() {
texture.select("image").attr("xlink:href", getAbsolutePath(this.value));
});
styleTextureShiftX.addEventListener("input", function() {
texture.select("image").attr("x", this.value).attr("width", svgWidth - this.valueAsNumber);
});
styleTextureShiftY.addEventListener("input", function() {
texture.select("image").attr("y", this.value).attr("height", svgHeight - this.valueAsNumber);
});
styleClippingInput.addEventListener("change", function() {
getEl().attr('mask', this.value);
});
styleGridType.addEventListener("change", function() {
if (layerIsOn("toggleGrid")) drawGrid();
});
styleGridSize.addEventListener("input", function() {
if (layerIsOn("toggleGrid")) drawGrid();
styleGridSizeOutput.value = this.value;
calculateFriendlyGridSize();
});
function calculateFriendlyGridSize() {
const size = styleGridSize.value * Math.cos(30 * Math.PI / 180) * 2;;
const friendly = "(" + rn(size * distanceScale.value) + " " + distanceUnit.value + ")";
styleGridSizeFriendly.value = friendly;
}
styleShiftX.addEventListener("input", shiftElement);
styleShiftY.addEventListener("input", shiftElement);
function shiftElement() {
const x = styleShiftX.value || 0;
const y = styleShiftY.value || 0;
getEl().attr("transform", `translate(${x},${y})`);
}
styleOceanBack.addEventListener("input", function() {
svg.style("background-color", this.value);
styleOceanBackOutput.value = this.value;
});
styleOceanFore.addEventListener("input", function() {
oceanLayers.select("rect").attr("fill", this.value);
styleOceanForeOutput.value = this.value;
});
styleOceanPattern.addEventListener("change", function() {
svg.select("pattern#oceanic rect").attr("filter", this.value);
});
outlineLayersInput.addEventListener("change", function() {
oceanLayers.selectAll("path").remove();
OceanLayers();
});
styleReliefSizeInput.addEventListener("input", function() {
styleReliefSizeOutput.value = this.value;
const size = +this.value;
terrain.selectAll("use").each(function(d) {
const newSize = this.getAttribute("data-size") * size;
const shift = (newSize - +this.getAttribute("width")) / 2;
this.setAttribute("width", newSize);
this.setAttribute("height", newSize);
const x = +this.getAttribute("x");
const y = +this.getAttribute("y");
this.setAttribute("x", x - shift);
this.setAttribute("y", y - shift);
});
});
styleReliefDensityInput.addEventListener("input", function() {
styleReliefDensityOutput.value = rn(this.value * 100) + "%";
ReliefIcons();
});
styleTemperatureFillOpacityInput.addEventListener("input", function() {
temperature.attr("fill-opacity", this.value);
styleTemperatureFillOpacityOutput.value = this.value;
});
styleTemperatureFontSizeInput.addEventListener("input", function() {
temperature.attr("font-size", this.value + "px");
styleTemperatureFontSizeOutput.value = this.value + "px";
});
styleTemperatureFillInput.addEventListener("input", function() {
temperature.attr("fill", this.value);
styleTemperatureFillOutput.value = this.value;
});
stylePopulationRuralStrokeInput.addEventListener("input", function() {
population.select("#rural").attr("stroke", this.value);
stylePopulationRuralStrokeOutput.value = this.value;
});
stylePopulationUrbanStrokeInput.addEventListener("input", function() {
population.select("#urban").attr("stroke", this.value);
stylePopulationUrbanStrokeOutput.value = this.value;
});
styleCompassSizeInput.addEventListener("input", function() {
styleCompassSizeOutput.value = this.value;
shiftCompass();
});
styleCompassShiftX.addEventListener("input", shiftCompass);
styleCompassShiftY.addEventListener("input", shiftCompass);
function shiftCompass() {
const tr = `translate(${styleCompassShiftX.value} ${styleCompassShiftY.value}) scale(${styleCompassSizeInput.value})`;
d3.select("#rose").attr("transform", tr);
}
styleSelectFont.addEventListener("change", changeFont);
function changeFont() {
const value = styleSelectFont.value;
const font = fonts[value].split(':')[0].replace(/\+/g, " ");
getEl().attr("font-family", font).attr("data-font", fonts[value]);
}
styleFontAdd.addEventListener("click", function() {
if (styleInputFont.style.display === "none") {
styleInputFont.style.display = "inline-block";
styleInputFont.focus();
styleSelectFont.style.display = "none";
} else {
styleInputFont.style.display = "none";
styleSelectFont.style.display = "inline-block";
}
});
styleInputFont.addEventListener("change", function() {
if (!this.value) {tip("Please provide a valid Google font name or link to a @font-face declaration"); return;}
fetchFonts(this.value).then(fetched => {
if (!fetched) return;
styleFontAdd.click();
styleInputFont.value = "";
if (fetched !== 1) return;
styleSelectFont.value = fonts.length-1;
changeFont(); // auto-change font if 1 font is fetched
});
});
styleFontSize.addEventListener("change", function() {
changeFontSize(+this.value);
});
styleFontPlus.addEventListener("click", function() {
const size = Math.max(rn(getEl().attr("data-size") * 1.1, 2), 1);
changeFontSize(size);
});
styleFontMinus.addEventListener("click", function() {
const size = Math.max(rn(getEl().attr("data-size") * .9, 2), 1);
changeFontSize(size);
});
function changeFontSize(size) {
getEl().attr("data-size", size).attr("font-size", rn((size + (size / scale)) / 2, 2));
styleFontSize.value = size;
}
styleRadiusInput.addEventListener("change", function() {
changeRadius(+this.value);
});
styleRadiusPlus.addEventListener("click", function() {
const size = Math.max(rn(getEl().attr("size") * 1.1, 2), .2);
changeRadius(size);
});
styleRadiusMinus.addEventListener("click", function() {
const size = Math.max(rn(getEl().attr("size") * .9, 2), .2);
changeRadius(size);
});
function changeRadius(size) {
getEl().attr("size", size)
getEl().selectAll("circle").each(function() {this.setAttribute("r", size)});
styleRadiusInput.value = size;
const group = getEl().attr("id");
burgLabels.select("g#"+group).selectAll("text").each(function() {this.setAttribute("dy", `${size * -1.5}px`)});
changeIconSize(size * 2, group); // change also anchor icons
}
styleIconSizeInput.addEventListener("change", function() {
changeIconSize(+this.value);
});
styleIconSizePlus.addEventListener("click", function() {
const size = Math.max(rn(getEl().attr("size") * 1.1, 2), .2);
changeIconSize(size);
});
styleIconSizeMinus.addEventListener("click", function() {
const size = Math.max(rn(getEl().attr("size") * .9, 2), .2);
changeIconSize(size);
});
function changeIconSize(size, group) {
const el = group ? anchors.select("#"+group) : getEl();
const oldSize = +el.attr("size");
const shift = (size - oldSize) / 2;
el.attr("size", size);
el.selectAll("use").each(function() {
const x = +this.getAttribute("x");
const y = +this.getAttribute("y");
this.setAttribute("x", x - shift);
this.setAttribute("y", y - shift);
this.setAttribute("width", size);
this.setAttribute("height", size);
});;
styleIconSizeInput.value = size;
}
// request to restore default style on button click
function askToRestoreDefaultStyle() {
alertMessage.innerHTML = "Are you sure you want to restore default style for all elements?";
$("#alert").dialog({resizable: false, title: "Restore default style",
buttons: {
Restore: function() {
applyDefaultStyle();
selectStyleElement();
$(this).dialog("close");
},
Cancel: function() {$(this).dialog("close");}
}
});
}
// request a URL to image to be used as a texture
function textureProvideURL() {
alertMessage.innerHTML = `Provide an image URL to be used as a texture:
<input id="textureURL" type="url" style="width: 254px" placeholder="http://www.example.com/image.jpg" oninput="fetchTextureURL(this.value)">
<div style="border: 1px solid darkgrey; height: 144px; margin-top: 2px"><canvas id="preview" width="256px" height="144px"></canvas></div>`;
$("#alert").dialog({resizable: false, title: "Load custom texture", width: 280,
buttons: {
Apply: function() {
const name = textureURL.value.split("/").pop();
if (!name || name === "") {tip("Please provide a valid URL", false, "error"); return;}
const opt = document.createElement("option");
opt.value = textureURL.value;
opt.text = name.slice(0, 20);
styleTextureInput.add(opt);
styleTextureInput.value = textureURL.value;
texture.select("image").attr('xlink:href', textureURL.value);
zoom.scaleBy(svg, 1.00001); // enforce browser re-draw
$(this).dialog("close");
},
Cancel: function() {$(this).dialog("close");}
}
});
}
function fetchTextureURL(url) {
console.log("Provided URL is", url);
const img = new Image();
img.onload = function () {
const canvas = document.getElementById("preview");
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
};
img.src = url;
}
// Style map filters handler
mapFilters.addEventListener("click", applyMapFilter);
function applyMapFilter() {
if (event.target.tagName !== "BUTTON") return;
const button = event.target;
svg.attr("filter", null);
if (button.classList.contains("pressed")) {button.classList.remove("pressed"); return;}
mapFilters.querySelectorAll(".pressed").forEach(button => button.classList.remove("pressed"));
button.classList.add("pressed");
svg.attr("filter", "url(#filter-" + button.id + ")");
}
// Option listeners
const optionsContent = document.getElementById("optionsContent");
optionsContent.addEventListener("input", function(event) {
const id = event.target.id, value = event.target.value;
if (id === "mapWidthInput" || id === "mapHeightInput") mapSizeInputChange();
else if (id === "densityInput" || id === "densityOutput") changeCellsDensity(value);
else if (id === "culturesInput") culturesOutput.value = value;
else if (id === "culturesOutput") culturesInput.value = value;
else if (id === "regionsInput" || id === "regionsOutput") changeStatesNumber(value);
else if (id === "powerInput") powerOutput.value = value;
else if (id === "powerOutput") powerInput.value = value;
else if (id === "neutralInput") neutralOutput.value = value;
else if (id === "neutralOutput") neutralInput.value = value;
else if (id === "manorsInput") manorsOutput.value = value;
else if (id === "manorsOutput") manorsInput.value = value;
else if (id === "uiSizeInput" || id === "uiSizeOutput") changeUIsize(value);
else if (id === "tooltipSizeInput" || id === "tooltipSizeOutput") changeTooltipSize(value);
else if (id === "transparencyInput") changeDialogsTransparency(value);
else if (id === "pngResolutionInput") pngResolutionOutput.value = value;
else if (id === "pngResolutionOutput") pngResolutionInput.value = value;
});
optionsContent.addEventListener("change", function(event) {
if (event.target.dataset.stored) lock(event.target.dataset.stored);
const id = event.target.id, value = event.target.value;
if (id === "zoomExtentMin" || id === "zoomExtentMax") changeZoomExtent(value);
});
optionsContent.addEventListener("click", function(event) {
const id = event.target.id;
if (id === "toggleFullscreen") toggleFullscreen();
else if (id === "optionsSeedGenerate") generateMapWithSeed();
else if (id === "optionsMapHistory") showSeedHistoryDialog();
else if (id === "zoomExtentDefault") restoreDefaultZoomExtent();
});
function mapSizeInputChange() {
changeMapSize();
autoResize = false;
localStorage.setItem("mapWidth", mapWidthInput.value);
localStorage.setItem("mapHeight", mapHeightInput.value);
}
// change svg size on manual size change or window resize, do not change graph size
function changeMapSize() {
svgWidth = +mapWidthInput.value;
svgHeight = +mapHeightInput.value;
svg.attr("width", svgWidth).attr("height", svgHeight);
const width = Math.max(svgWidth, graphWidth);
const height = Math.max(svgHeight, graphHeight);
zoom.translateExtent([[0, 0], [width, height]]);
fitScaleBar();
}
// just apply map size that was already set, apply graph size!
function applyMapSize() {
svgWidth = graphWidth = +mapWidthInput.value;
svgHeight = graphHeight = +mapHeightInput.value;
svg.attr("width", svgWidth).attr("height", svgHeight);
zoom.translateExtent([[0, 0],[graphWidth, graphHeight]]).scaleExtent([1, 20]).scaleTo(svg, 1);
viewbox.attr("transform", null);
}
function toggleFullscreen() {
if (mapWidthInput.value != window.innerWidth || mapHeightInput.value != window.innerHeight) {
mapWidthInput.value = window.innerWidth;
mapHeightInput.value = window.innerHeight;
localStorage.removeItem("mapHeight");
localStorage.removeItem("mapWidth");
} else {
mapWidthInput.value = graphWidth;
mapHeightInput.value = graphHeight;
}
changeMapSize();
}
function generateMapWithSeed() {
if (optionsSeed.value == seed) {
tip("The current map already has this seed", false, "error");
return;
}
regeneratePrompt();
}
function showSeedHistoryDialog() {
const alert = mapHistory.map(function(h, i) {
const created = new Date(h.created).toLocaleTimeString();
const button = `<i data-tip"Click to generate a map with this seed" onclick="restoreSeed(${i})" class="icon-history optionsSeedRestore"></i>`;
return `<div>${i+1}. Seed: ${h.seed} ${button}. Size: ${h.width}x${h.height}. Template: ${h.template}. Created: ${created}</div>`;
}).join("");
alertMessage.innerHTML = alert;
$("#alert").dialog({
resizable: false, title: "Seed history",
width: fitContent(), position: {my: "center", at: "center", of: "svg"}
});
}
// generate map with historycal seed
function restoreSeed(id) {
if (mapHistory[id].seed == seed) {
tip("The current map is already generated with this seed", null, "error");
return;
}
optionsSeed.value = mapHistory[id].seed;
mapWidthInput.value = mapHistory[id].width;
mapHeightInput.value = mapHistory[id].height;
templateInput.value = mapHistory[id].template;
if (locked("template")) unlock("template");
regeneratePrompt();
}
function restoreDefaultZoomExtent() {
zoomExtentMin.value = 1;
zoomExtentMax.value = 20;
zoom.scaleExtent([1, 20]).scaleTo(svg, 1);
}
function changeCellsDensity(value) {
densityInput.value = densityOutput.value = value;
if (value == 3) densityOutput.style.color = "red";
else if (value == 2) densityOutput.style.color = "yellow";
else if (value == 1) densityOutput.style.color = "green";
}
function changeStatesNumber(value) {
regionsInput.value = regionsOutput.value = value;
burgLabels.select("#capitals").attr("data-size", Math.max(rn(6 - value / 20), 3));
labels.select("#countries").attr("data-size", Math.max(rn(18 - value / 6), 4));
}
function changeUIsize(value) {
uiSizeInput.value = uiSizeOutput.value = value;
document.getElementsByTagName("body")[0].style.fontSize = value * 11 + "px";
document.getElementById("options").style.width = (value - 1) * 300 / 2 + 300 + "px";
}
function changeTooltipSize(value) {
tooltipSizeInput.value = tooltipSizeOutput.value = value;
tooltip.style.fontSize = `calc(${value}px + 0.5vw)`;
}
// change transparency for modal windows
function changeDialogsTransparency(value) {
transparencyInput.value = transparencyOutput.value = value;
const alpha = (100 - +value) / 100;
const optionsColor = "rgba(164, 139, 149, " + alpha + ")";
const dialogsColor = "rgba(255, 255, 255, " + alpha + ")";
const optionButtonsColor = "rgba(145, 110, 127, " + Math.min(alpha + .3, 1) + ")";
const optionLiColor = "rgba(153, 123, 137, " + Math.min(alpha + .3, 1) + ")";
document.getElementById("options").style.backgroundColor = optionsColor;
document.getElementById("dialogs").style.backgroundColor = dialogsColor;
document.querySelectorAll(".tabcontent button").forEach(el => el.style.backgroundColor = optionButtonsColor);
document.querySelectorAll(".tabcontent li").forEach(el => el.style.backgroundColor = optionLiColor);
document.querySelectorAll("button.options").forEach(el => el.style.backgroundColor = optionLiColor);
}
function changeZoomExtent(value) {
zoom.scaleExtent([+zoomExtentMin.value, +zoomExtentMax.value]);
zoom.scaleTo(svg, +value);
}
// control sroted options
function applyStoredOptions() {
for(let i=0; i < localStorage.length; i++){
const stored = localStorage.key(i), value = localStorage.getItem(stored);
const input = document.getElementById(stored+"Input");
const output = document.getElementById(stored+"Output");
if (input) input.value = value;
if (output) output.value = value;
lock(stored);
}
if (!localStorage.getItem("mapWidth") || !localStorage.getItem("mapHeight")) {
mapWidthInput.value = window.innerWidth;
mapHeightInput.value = window.innerHeight;
}
if (localStorage.getItem("winds")) winds = localStorage.getItem("winds").split(",").map(w => +w);
changeDialogsTransparency(localStorage.getItem("transparency") || 30);
if (localStorage.getItem("uiSize")) changeUIsize(localStorage.getItem("uiSize"));
if (localStorage.getItem("tooltipSize")) changeTooltipSize(localStorage.getItem("tooltipSize"));
if (localStorage.getItem("regions")) changeStatesNumber(localStorage.getItem("regions"));
if (localStorage.getItem("equator")) {
const eqY = +equatorInput.value;
equidistanceOutput.min = equidistanceInput.min = Math.max(+mapHeightInput.value - eqY, eqY);
equidistanceOutput.max = equidistanceInput.max = equidistanceOutput.min * 10;
}
}
// randomize options if randomization is allowed in option
function randomizeOptions() {
Math.seedrandom(seed); // reset seed to initial one
if (!locked("regions")) regionsInput.value = regionsOutput.value = rand(12, 17);
if (!locked("manors")) manorsInput.value = manorsOutput.value = rn(0.5 + Math.random(), 1);
if (!locked("power")) powerInput.value = powerOutput.value = rand(0, 4);
if (!locked("neutral")) neutralInput.value = neutralOutput.value = rn(0.8 + Math.random(), 1);
if (!locked("cultures")) culturesInput.value = culturesOutput.value = rand(10, 15);
if (!locked("prec")) precInput.value = precOutput.value = gauss(100, 40, 0, 500);
const tMax = +temperatureEquatorOutput.max, tMin = +temperatureEquatorOutput.min; // temperature extremes
if (!locked("temperatureEquator")) temperatureEquatorOutput.value = temperatureEquatorInput.value = rand(tMax-6, tMax);
if (!locked("temperaturePole")) temperaturePoleOutput.value = temperaturePoleInput.value = rand(tMin, tMin+10);
if (!locked("equator") && !locked("equidistance")) randomizeWorldSize();
}
// define world size
function randomizeWorldSize() {
const eq = document.getElementById("equatorInput");
const eqDI = document.getElementById("equidistanceInput");
const eqDO = document.getElementById("equidistanceOutput");
const eqY = equatorOutput.value = eq.value = rand(+eq.min, +eq.max); // equator Y
eqDO.min = eqDI.min = Math.max(graphHeight - eqY, eqY);
eqDO.max = eqDI.max = eqDO.min * 10;
eqDO.value = eqDI.value = rand(rn(eqDO.min * 1.2), rn(eqDO.min * 4)); // distance from equator to poles
}
// remove all saved data from LocalStorage and reload the page
function restoreDefaultOptions() {
localStorage.clear();
location.reload();
}
// FONTS
// fetch default fonts if not done before
function loadDefaultFonts() {
if (!$('link[href="fonts.css"]').length) {
$("head").append('<link rel="stylesheet" type="text/css" href="fonts.css">');
const fontsToAdd = ["Amatic+SC:700", "IM+Fell+English", "Great+Vibes", "MedievalSharp", "Metamorphous",
"Nova+Script", "Uncial+Antiqua", "Underdog", "Caesar+Dressing", "Bitter", "Yellowtail", "Montez",
"Shadows+Into+Light", "Fredericka+the+Great", "Orbitron", "Dancing+Script:700",
"Architects+Daughter", "Kaushan+Script", "Gloria+Hallelujah", "Satisfy", "Comfortaa:700", "Cinzel"];
fontsToAdd.forEach(function(f) {if (fonts.indexOf(f) === -1) fonts.push(f);});
updateFontOptions();
}
}
function fetchFonts(url) {
return new Promise((resolve, reject) => {
if (url === "") {
tip("Use a direct link to any @font-face declaration or just font name to fetch from Google Fonts");
return;
}
if (url.indexOf("http") === -1) {
url = url.replace(url.charAt(0), url.charAt(0).toUpperCase()).split(" ").join("+");
url = "https://fonts.googleapis.com/css?family=" + url;
}
const fetched = addFonts(url).then(fetched => {
if (fetched === undefined) {
tip("Cannot fetch font for this value!", false, "error");
return;
}
if (fetched === 0) {
tip("Already in the fonts list!", false, "error");
return;
}
updateFontOptions();
if (fetched === 1) {
tip("Font " + fonts[fonts.length - 1] + " is fetched");
} else if (fetched > 1) {
tip(fetched + " fonts are added to the list");
}
resolve(fetched);
});
})
}
function addFonts(url) {
$("head").append('<link rel="stylesheet" type="text/css" href="' + url + '">');
return fetch(url)
.then(resp => resp.text())
.then(text => {
let s = document.createElement('style');
s.innerHTML = text;
document.head.appendChild(s);
let styleSheet = Array.prototype.filter.call(
document.styleSheets,
sS => sS.ownerNode === s)[0];
let FontRule = rule => {
let family = rule.style.getPropertyValue('font-family');
let font = family.replace(/['"]+/g, '').replace(/ /g, "+");
let weight = rule.style.getPropertyValue('font-weight');
if (weight !== "400") font += ":" + weight;
if (fonts.indexOf(font) == -1) {
fonts.push(font);
fetched++
}
};
let fetched = 0;
for (let r of styleSheet.cssRules) {FontRule(r);}
document.head.removeChild(s);
return fetched;
})
.catch(function() {});
}
// Update font list for Label and Burg Editors
function updateFontOptions() {
styleSelectFont.innerHTML = "";
for (let i=0; i < fonts.length; i++) {
const opt = document.createElement('option');
opt.value = i;
const font = fonts[i].split(':')[0].replace(/\+/g, " ");
opt.style.fontFamily = opt.innerHTML = font;
styleSelectFont.add(opt);
}
}
// Sticked menu Options listeners
document.getElementById("sticked").addEventListener("click", function(event) {
const id = event.target.id;
if (id === "newMapButton") regeneratePrompt();
else if (id === "saveButton") toggleSavePane();
else if (id === "loadMap") mapToLoad.click();
else if (id === "zoomReset") resetZoom(1000);
else if (id === "saveMap") saveMap();
else if (id === "saveSVG") saveAsImage("svg");
else if (id === "savePNG") saveAsImage("png");
if (id === "saveMap" || id === "saveSVG" || id === "savePNG") toggleSavePane();
});
function regeneratePrompt() {
if (customization) {tip("Please exit the customization mode first", false, "warning"); return;}
const workingTime = (Date.now() - last(mapHistory).created) / 60000; // minutes
if (workingTime < 15) {regenerateMap(); return;}
alertMessage.innerHTML = `Are you sure you want to generate a new map?<br>
All unsaved changes made to the current map will be lost`;
$("#alert").dialog({resizable: false, title: "Generate new map",
buttons: {
Cancel: function() {$(this).dialog("close");},
Generate: regenerateMap
}
});
}
function toggleSavePane() {
if (saveDropdown.style.display === "block") {saveDropdown.style.display = "none"; return;}
saveDropdown.style.display = "block";
// ask users to allow popups
if (!localStorage.getItem("dns_allow_popup_message")) {
alertMessage.innerHTML = `Generator uses pop-up window to download files.
<br>Please ensure your browser does not block popups.
<br>Please check browser settings and turn off adBlocker if it is enabled`;
$("#alert").dialog({title: "File saver", resizable: false, position: {my: "center", at: "center", of: "svg"},
buttons: {
OK: function() {
localStorage.setItem("dns_allow_popup_message", true);
$(this).dialog("close");
}
}
});
}
}
// load map
document.getElementById("mapToLoad").addEventListener("change", function() {
closeDialogs();
const fileToLoad = this.files[0];
this.value = "";
uploadFile(fileToLoad);
});

247
modules/ui/relief-editor.js Normal file
View file

@ -0,0 +1,247 @@
"use strict";
function editReliefIcon() {
if (customization) return;
closeDialogs(".stable");
if (!layerIsOn("toggleRelief")) toggleRelief();
terrain.selectAll("use").call(d3.drag().on("drag", dragReliefIcon)).classed("draggable", true);
elSelected = d3.select(d3.event.target);
restoreEditMode();
updateReliefIconSelected();
updateReliefSizeInput();
$("#reliefEditor").dialog({
title: "Edit Relief Icons", resizable: false,
position: {my: "center top+40", at: "top", of: d3.event, collision: "fit"},
close: closeReliefEditor
});
if (modules.editReliefIcon) return;
modules.editReliefIcon = true;
// add listeners
document.getElementById("reliefIndividual").addEventListener("click", enterIndividualMode);
document.getElementById("reliefBulkAdd").addEventListener("click", enterBulkAddMode);
document.getElementById("reliefBulkRemove").addEventListener("click", enterBulkRemoveMode);
document.getElementById("reliefSize").addEventListener("input", changeIconSize);
document.getElementById("reliefSizeNumber").addEventListener("input", changeIconSize);
reliefIconsDiv.querySelectorAll("button").forEach(el => el.addEventListener("click", changeIcon));
document.getElementById("reliefCopy").addEventListener("click", copyIcon);
document.getElementById("reliefMoveFront").addEventListener("click", () => elSelected.raise());
document.getElementById("reliefMoveBack").addEventListener("click", () => elSelected.lower());
document.getElementById("reliefRemove").addEventListener("click", removeIcon);
function dragReliefIcon() {
const dx = +this.getAttribute("x") - d3.event.x;
const dy = +this.getAttribute("y") - d3.event.y;
d3.event.on("drag", function() {
const x = d3.event.x, y = d3.event.y;
this.setAttribute("x", dx+x);
this.setAttribute("y", dy+y);
});
}
function restoreEditMode() {
if (!reliefTools.querySelector("button.pressed")) enterIndividualMode(); else
if (reliefBulkAdd.classList.contains("pressed")) enterBulkAddMode(); else
if (reliefBulkRemove.classList.contains("pressed")) enterBulkRemoveMode();
}
function updateReliefIconSelected() {
const type = elSelected.attr("data-type");
reliefIconsDiv.querySelectorAll("button.pressed").forEach(b => b.classList.remove("pressed"));
reliefIconsDiv.querySelector("button[data-type='"+type+"']").classList.add("pressed");
}
function updateReliefSizeInput() {
const size = +elSelected.attr("data-size");
reliefSize.value = reliefSizeNumber.value = rn(size);
}
function enterIndividualMode() {
reliefTools.querySelectorAll("button.pressed").forEach(b => b.classList.remove("pressed"));
reliefIndividual.classList.add("pressed");
reliefSizeDiv.style.display = "block";
reliefRadiusDiv.style.display = "none";
reliefSpacingDiv.style.display = "none";
reliefIconsSeletionAny.style.display = "none";
updateReliefSizeInput();
restoreDefaultEvents();
clearMainTip();
}
function enterBulkAddMode() {
reliefTools.querySelectorAll("button.pressed").forEach(b => b.classList.remove("pressed"));
reliefBulkAdd.classList.add("pressed");
reliefSizeDiv.style.display = "block";
reliefRadiusDiv.style.display = "block";
reliefSpacingDiv.style.display = "block";
reliefIconsSeletionAny.style.display = "none";
const pressedType = reliefIconsDiv.querySelector("button.pressed");
if (pressedType.id === "reliefIconsSeletionAny") { // in "any" is pressed, select first type
reliefIconsSeletionAny.classList.remove("pressed");
reliefIconsDiv.querySelector("button").classList.add("pressed");
}
viewbox.style("cursor", "crosshair").call(d3.drag().on("start", dragToAdd)).on("touchmove mousemove", moveBrush);
tip("Drag to place relief icons within radius", true);
}
function moveBrush() {
showMainTip();
const point = d3.mouse(this);
const radius = +reliefRadius.value;
moveCircle(point[0], point[1], radius);
}
function dragToAdd() {
const pressed = reliefIconsDiv.querySelector("button.pressed");
if (!pressed) {tip("Please select an icon", false, error); return;}
const type = pressed.dataset.type;
const r = +reliefRadius.value;
const spacing = +reliefSpacing.value;
const size = +reliefSize.value;
// build a quadtree
const tree = d3.quadtree();
const positions = [];
terrain.selectAll("use").each(function() {
const x = +this.getAttribute("x") + this.getAttribute("width") / 2;
const y = +this.getAttribute("y") + this.getAttribute("height") / 2;
tree.add([x, y, x]);
const box = this.getBBox();
positions.push(box.y + box.height);
});
d3.event.on("drag", function() {
const p = d3.mouse(this);
moveCircle(p[0], p[1], r);
d3.range(Math.ceil(r/10)).forEach(function() {
const a = Math.PI * 2 * Math.random();
const rad = r * Math.random();
const cx = p[0] + rad * Math.cos(a);
const cy = p[1] + rad * Math.sin(a);
if (tree.find(cx, cy, spacing)) return; // too close to existing icon
if (pack.cells.h[findCell(cx, cy)] < 20) return; // on water cell
const h = rn(size / 2 * (Math.random() * .4 + .8), 2);
const x = rn(cx-h, 2);
const y = rn(cy-h, 2);
const z = y + h * 2;
let nth = 1;
while (positions[nth] && z > positions[nth]) {nth++;}
tree.add([cx, cy]);
positions.push(z);
terrain.insert("use", ":nth-child("+nth+")").attr("xlink:href", type).attr("data-type", type)
.attr("x", x).attr("y", y).attr("data-size", h*2).attr("width", h*2).attr("height", h*2);
});
});
}
function enterBulkRemoveMode() {
reliefTools.querySelectorAll("button.pressed").forEach(b => b.classList.remove("pressed"));
reliefBulkRemove.classList.add("pressed");
reliefSizeDiv.style.display = "none";
reliefRadiusDiv.style.display = "block";
reliefSpacingDiv.style.display = "none";
reliefIconsSeletionAny.style.display = "inline-block";
viewbox.style("cursor", "crosshair").call(d3.drag().on("start", dragToRemove)).on("touchmove mousemove", moveBrush);;
tip("Drag to remove relief icons in radius", true);
}
function dragToRemove() {
const pressed = reliefIconsDiv.querySelector("button.pressed");
if (!pressed) {tip("Please select an icon", false, error); return;}
const r = +reliefRadius.value;
const type = pressed.dataset.type;
const icons = type ? terrain.selectAll("use[data-type='"+type+"']") : terrain.selectAll("use");
const tree = d3.quadtree();
icons.each(function() {
const x = +this.getAttribute("x") + this.getAttribute("width") / 2;
const y = +this.getAttribute("y") + this.getAttribute("height") / 2;
tree.add([x, y, this]);
});
d3.event.on("drag", function() {
const p = d3.mouse(this);
moveCircle(p[0], p[1], r);
tree.findAll(p[0], p[1], r).forEach(f => f[2].remove());
});
}
function changeIconSize() {
const size = +reliefSize.value;
reliefSize.value = reliefSizeNumber.value = size;
if (!reliefIndividual.classList.contains("pressed")) return;
const shift = (size - +elSelected.attr("width")) / 2;
elSelected.attr("width", size).attr("height", size).attr("data-size", size);
const x = +elSelected.attr("x"), y = +elSelected.attr("y");
elSelected.attr("x", x-shift).attr("y", y-shift);
}
function changeIcon() {
if (this.classList.contains("pressed")) return;
reliefIconsDiv.querySelectorAll("button.pressed").forEach(b => b.classList.remove("pressed"))
this.classList.add("pressed");
if (reliefIndividual.classList.contains("pressed")) {
const type = this.dataset.type;
elSelected.attr("xlink:href", type).attr("data-type", type);
}
}
function copyIcon() {
const parent = elSelected.node().parentNode;
const copy = elSelected.node().cloneNode(true);
let x = +elSelected.attr("x") - 3, y = +elSelected.attr("y") - 3;
while (parent.querySelector("[x='"+x+"']","[x='"+y+"']")) {
x -= 3; y -= 3;
}
copy.setAttribute("x", x);
copy.setAttribute("y", y);
parent.insertBefore(copy, null);
}
function removeIcon() {
alertMessage.innerHTML = `Are you sure you want to remove the icon?`;
$("#alert").dialog({resizable: false, title: "Remove relief icon",
buttons: {
Remove: function() {
$(this).dialog("close");
elSelected.remove();
$("#reliefEditor").dialog("close");
},
Cancel: function() {$(this).dialog("close");}
}
});
}
function closeReliefEditor() {
terrain.selectAll("use").call(d3.drag().on("drag", null)).classed("draggable", false);
removeCircle();
unselect();
clearMainTip();
}
}

278
modules/ui/rivers-editor.js Normal file
View file

@ -0,0 +1,278 @@
"use strict";
function editRiver() {
if (customization) return;
if (elSelected && d3.event.target.id === elSelected.attr("id")) return;
closeDialogs(".stable");
if (!layerIsOn("toggleRivers")) toggleRivers();
const node = d3.event.target;
elSelected = d3.select(node).on("click", addInterimControlPoint)
.call(d3.drag().on("start", dragRiver)).classed("draggable", true);
viewbox.on("touchmove mousemove", showEditorTips);
debug.append("g").attr("id", "controlPoints").attr("transform", elSelected.attr("transform"));
drawControlPoints(node);
updateValues(node);
$("#riverEditor").dialog({
title: "Edit River", resizable: false,
position: {my: "center top+20", at: "top", of: d3.event, collision: "fit"},
close: closeRiverEditor
});
if (modules.editRiver) return;
modules.editRiver = true;
// add listeners
document.getElementById("riverWidthShow").addEventListener("click", showRiverWidth);
document.getElementById("riverWidthHide").addEventListener("click", hideRiverWidth);
document.getElementById("riverWidthInput").addEventListener("input", changeWidth);
document.getElementById("riverIncrement").addEventListener("input", changeIncrement);
document.getElementById("riverResizeShow").addEventListener("click", showRiverSize);
document.getElementById("riverResizeHide").addEventListener("click", hideRiverSize);
document.getElementById("riverAngle").addEventListener("input", changeAngle);
document.getElementById("riverScale").addEventListener("input", changeScale);
document.getElementById("riverReset").addEventListener("click", resetTransformation);
document.getElementById("riverCopy").addEventListener("click", copyRiver);
document.getElementById("riverNew").addEventListener("click", toggleRiverCreationMode);
document.getElementById("riverLegend").addEventListener("click", editRiverLegend);
document.getElementById("riverRemove").addEventListener("click", removeRiver);
function showEditorTips() {
showMainTip();
if (d3.event.target.parentNode.id === elSelected.attr("id")) tip("Drag to move, click to add a control point"); else
if (d3.event.target.parentNode.id === "controlPoints") tip("Drag to move, click to delete the control point");
}
function updateValues(node) {
const tr = parseTransform(node.getAttribute("transform"));
document.getElementById("riverAngle").value = tr[2];
document.getElementById("riverAngleValue").innerHTML = Math.abs(+tr[2]) + "&#xb0;";
document.getElementById("riverScale").value = tr[5];
document.getElementById("riverWidthInput").value = node.dataset.width;
document.getElementById("riverIncrement").value = node.dataset.increment;
}
function dragRiver() {
const x = d3.event.x, y = d3.event.y;
const tr = parseTransform(elSelected.attr("transform"));
d3.event.on("drag", function() {
let xc = d3.event.x, yc = d3.event.y;
let transform = `translate(${(+tr[0]+xc-x)},${(+tr[1]+yc-y)}) rotate(${tr[2]} ${tr[3]} ${tr[4]}) scale(${tr[5]})`;
elSelected.attr("transform", transform);
debug.select("#controlPoints").attr("transform", transform);
});
}
function drawControlPoints(node) {
const l = node.getTotalLength() / 2;
const segments = Math.ceil(l / 5);
const increment = rn(l / segments * 1e5);
for (let i=increment*segments, c=i; i >= 0; i -= increment, c += increment) {
const p1 = node.getPointAtLength(i / 1e5);
const p2 = node.getPointAtLength(c / 1e5);
addControlPoint([(p1.x + p2.x) / 2, (p1.y + p2.y) / 2]);
}
updateRiverLength(l);
}
function addControlPoint(point) {
debug.select("#controlPoints").append("circle")
.attr("cx", point[0]).attr("cy", point[1]).attr("r", .5)
.call(d3.drag().on("drag", dragControlPoint))
.on("click", clickControlPoint);
}
function dragControlPoint() {
this.setAttribute("cx", d3.event.x);
this.setAttribute("cy", d3.event.y);
redrawRiver();
}
function redrawRiver() {
const points = [];
debug.select("#controlPoints").selectAll("circle").each(function() {
points.push([+this.getAttribute("cx"), +this.getAttribute("cy")]);
});
if (points.length === 1) return;
if (points.length === 2) {elSelected.attr("d", `M${points[0][0]},${points[0][1]} L${points[1][0]},${points[1][1]}`); return;}
const d = Rivers.getPath(points, +riverWidthInput.value, +riverIncrement.value);
elSelected.attr("d", d);
updateRiverLength();
}
function updateRiverLength(l = elSelected.node().getTotalLength() / 2) {
const tr = parseTransform(elSelected.attr("transform"));
riverLength.innerHTML = rn(l * tr[5] * distanceScale.value) + " " + distanceUnit.value;
}
function clickControlPoint() {
this.remove();
redrawRiver();
}
function addInterimControlPoint() {
const point = d3.mouse(this);
const dists = [];
debug.select("#controlPoints").selectAll("circle").each(function() {
const x = +this.getAttribute("cx");
const y = +this.getAttribute("cy");
dists.push((point[0] - x) ** 2 + (point[1] - y) ** 2);
});
let index = dists.length;
if (dists.length > 1) {
const sorted = dists.slice(0).sort((a, b) => a-b);
const closest = dists.indexOf(sorted[0]);
const next = dists.indexOf(sorted[1]);
if (closest <= next) index = closest+1; else index = next+1;
}
const before = ":nth-child(" + (index + 1) + ")";
debug.select("#controlPoints").insert("circle", before)
.attr("cx", point[0]).attr("cy", point[1]).attr("r", .5)
.call(d3.drag().on("drag", dragControlPoint))
.on("click", clickControlPoint);
redrawRiver();
}
function showRiverWidth() {
document.querySelectorAll("#riverEditor > button").forEach(el => el.style.display = "none");
document.getElementById("riverWidthSection").style.display = "inline-block";
}
function hideRiverWidth() {
document.querySelectorAll("#riverEditor > button").forEach(el => el.style.display = "inline-block");
document.getElementById("riverWidthSection").style.display = "none";
}
function changeWidth() {
elSelected.attr("data-width", this.value);
redrawRiver();
}
function changeIncrement() {
elSelected.attr("data-increment", this.value);
redrawRiver();
}
function showRiverSize() {
document.querySelectorAll("#riverEditor > button").forEach(el => el.style.display = "none");
document.getElementById("riverResizeSection").style.display = "inline-block";
}
function hideRiverSize() {
document.querySelectorAll("#riverEditor > button").forEach(el => el.style.display = "inline-block");
document.getElementById("riverResizeSection").style.display = "none";
}
function changeAngle() {
const tr = parseTransform(elSelected.attr("transform"));
riverAngleValue.innerHTML = Math.abs(+this.value) + "&#xb0;";
const c = elSelected.node().getBBox();
const angle = +this.value, scale = +tr[5];
const transform = `translate(${tr[0]},${tr[1]}) rotate(${angle} ${(c.x+c.width/2)*scale} ${(c.y+c.height/2)*scale}) scale(${scale})`;
elSelected.attr("transform", transform);
debug.select("#controlPoints").attr("transform", transform);
}
function changeScale() {
const tr = parseTransform(elSelected.attr("transform"));
const scaleOld = +tr[5],scale = +this.value;
const c = elSelected.node().getBBox();
const cx = c.x + c.width / 2, cy = c.y + c.height / 2;
const trX = +tr[0] + cx * (scaleOld - scale);
const trY = +tr[1] + cy * (scaleOld - scale);
const scX = +tr[3] * scale / scaleOld;
const scY = +tr[4] * scale / scaleOld;
const transform = `translate(${trX},${trY}) rotate(${tr[2]} ${scX} ${scY}) scale(${scale})`;
elSelected.attr("transform", transform);
debug.select("#controlPoints").attr("transform", transform);
updateRiverLength();
}
function resetTransformation() {
elSelected.attr("transform", null);
debug.select("#controlPoints").attr("transform", null);
riverAngle.value = 0;
riverAngleValue.innerHTML = "0&#xb0;";
riverScale.value = 1;
updateRiverLength();
}
function copyRiver() {
const tr = parseTransform(elSelected.attr("transform"));
const d = elSelected.attr("d");
let x = 2, y = 2;
let transform = `translate(${tr[0]-x},${tr[1]-y}) rotate(${tr[2]} ${tr[3]} ${tr[4]}) scale(${tr[5]})`;
while (rivers.selectAll("[transform='" + transform + "'][d='" + d + "']").size() > 0) {
x += 2; y += 2;
transform = `translate(${tr[0]-x},${tr[1]-y}) rotate(${tr[2]} ${tr[3]} ${tr[4]}) scale(${tr[5]})`;
}
rivers.append("path").attr("d", d).attr("transform", transform).attr("id", getNextId("river"))
.attr("data-width", elSelected.attr("data-width")).attr("data-increment", elSelected.attr("data-increment"));
}
function toggleRiverCreationMode() {
document.getElementById("riverNew").classList.toggle("pressed");
if (document.getElementById("riverNew").classList.contains("pressed")) {
tip("Click on map to add control points", true);
viewbox.on("click", addPointOnClick).style("cursor", "crosshair");
elSelected.on("click", null);
} else {
clearMainTip();
viewbox.on("click", clicked).style("cursor", "default");
elSelected.on("click", addInterimControlPoint).attr("data-new", null)
.call(d3.drag().on("start", dragRiver)).classed("draggable", true);
}
}
function addPointOnClick() {
if (!elSelected.attr("data-new")) {
debug.select("#controlPoints").selectAll("circle").remove();
const id = getNextId("river");
elSelected = d3.select(elSelected.node().parentNode).append("path").attr("id", id)
.attr("data-new", 1).attr("data-width", 2).attr("data-increment", 1);
}
// add control point
const point = d3.mouse(this);
addControlPoint([point[0], point[1]]);
redrawRiver();
}
function editRiverLegend() {
const id = elSelected.attr("id");
editLegends(id, id);
}
function removeRiver() {
alertMessage.innerHTML = "Are you sure you want to remove the river?";
$("#alert").dialog({resizable: false, title: "Remove river",
buttons: {
Remove: function() {
$(this).dialog("close");
elSelected.remove();
$("#riverEditor").dialog("close");
},
Cancel: function() {$(this).dialog("close");}
}
});
}
function closeRiverEditor() {
elSelected.attr("data-new", null).on("click", null);
clearMainTip();
riverNew.classList.remove("pressed");
debug.select("#controlPoints").remove();
unselect();
}
}

284
modules/ui/routes-editor.js Normal file
View file

@ -0,0 +1,284 @@
"use strict";
function editRoute(onClick) {
if (customization) return;
if (!onClick && elSelected && d3.event.target.id === elSelected.attr("id")) return;
closeDialogs(".stable");
if (!layerIsOn("toggleRoutes")) toggleRoutes();
$("#routeEditor").dialog({
title: "Edit Route", resizable: false,
position: {my: "center top+20", at: "top", of: d3.event, collision: "fit"},
close: closeRoutesEditor
});
debug.append("g").attr("id", "controlPoints");
const node = onClick ? elSelected.node() : d3.event.target;
elSelected = d3.select(node).on("click", addInterimControlPoint);
drawControlPoints(node);
selectRouteGroup(node);
viewbox.on("touchmove mousemove", showEditorTips);
if (onClick) toggleRouteCreationMode();
if (modules.editRoute) return;
modules.editRoute = true;
// add listeners
document.getElementById("routeGroupsShow").addEventListener("click", showGroupSection);
document.getElementById("routeGroup").addEventListener("change", changeRouteGroup);
document.getElementById("routeGroupAdd").addEventListener("click", toggleNewGroupInput);
document.getElementById("routeGroupName").addEventListener("change", createNewGroup);
document.getElementById("routeGroupRemove").addEventListener("click", removeRouteGroup);
document.getElementById("routeGroupsHide").addEventListener("click", hideGroupSection);
document.getElementById("routeSplit").addEventListener("click", toggleRouteSplitMode);
document.getElementById("routeLegend").addEventListener("click", editRouteLegend);
document.getElementById("routeNew").addEventListener("click", toggleRouteCreationMode);
document.getElementById("routeRemove").addEventListener("click", removeRoute);
function showEditorTips() {
showMainTip();
if (routeNew.classList.contains("pressed")) return;
if (d3.event.target.id === elSelected.attr("id")) tip("Click to add a control point"); else
if (d3.event.target.parentNode.id === "controlPoints") tip("Drag to move, click to delete the control point");
}
function drawControlPoints(node) {
const l = node.getTotalLength();
const increment = l / Math.ceil(l / 5);
for (let i=0; i <= l; i += increment) {addControlPoint(node.getPointAtLength(i));}
routeLength.innerHTML = rn(l * distanceScale.value) + " " + distanceUnit.value;
}
function addControlPoint(point) {
debug.select("#controlPoints").append("circle")
.attr("cx", point.x).attr("cy", point.y).attr("r", .5)
.call(d3.drag().on("drag", dragControlPoint))
.on("click", clickControlPoint);
}
function addInterimControlPoint() {
const point = d3.mouse(this);
const dists = [];
debug.select("#controlPoints").selectAll("circle").each(function() {
const x = +this.getAttribute("cx");
const y = +this.getAttribute("cy");
dists.push((point[0] - x) ** 2 + (point[1] - y) ** 2);
});
let index = dists.length;
if (dists.length > 1) {
const sorted = dists.slice(0).sort((a, b) => a-b);
const closest = dists.indexOf(sorted[0]);
const next = dists.indexOf(sorted[1]);
if (closest <= next) index = closest+1; else index = next+1;
}
const before = ":nth-child(" + (index + 1) + ")";
debug.select("#controlPoints").insert("circle", before)
.attr("cx", point[0]).attr("cy", point[1]).attr("r", .5)
.call(d3.drag().on("drag", dragControlPoint))
.on("click", clickControlPoint);
redrawRoute();
}
function dragControlPoint() {
this.setAttribute("cx", d3.event.x);
this.setAttribute("cy", d3.event.y);
redrawRoute();
}
function redrawRoute() {
lineGen.curve(d3.curveCatmullRom.alpha(.1));
const points = [];
debug.select("#controlPoints").selectAll("circle").each(function() {
points.push([this.getAttribute("cx"), this.getAttribute("cy")]);
});
elSelected.attr("d", round(lineGen(points)));
const l = elSelected.node().getTotalLength();
routeLength.innerHTML = rn(l * distanceScale.value) + " " + distanceUnit.value;
}
function showGroupSection() {
document.querySelectorAll("#routeEditor > button").forEach(el => el.style.display = "none");
document.getElementById("routeGroupsSelection").style.display = "inline-block";
}
function hideGroupSection() {
document.querySelectorAll("#routeEditor > button").forEach(el => el.style.display = "inline-block");
document.getElementById("routeGroupsSelection").style.display = "none";
document.getElementById("routeGroupName").style.display = "none";
document.getElementById("routeGroupName").value = "";
document.getElementById("routeGroup").style.display = "inline-block";
}
function selectRouteGroup(node) {
const group = node.parentNode.id;
const select = document.getElementById("routeGroup");
select.options.length = 0; // remove all options
routes.selectAll("g").each(function() {
select.options.add(new Option(this.id, this.id, false, this.id === group));
});
}
function changeRouteGroup() {
document.getElementById(this.value).appendChild(elSelected.node());
}
function toggleNewGroupInput() {
if (routeGroupName.style.display === "none") {
routeGroupName.style.display = "inline-block";
routeGroupName.focus();
routeGroup.style.display = "none";
} else {
routeGroupName.style.display = "none";
routeGroup.style.display = "inline-block";
}
}
function createNewGroup() {
if (!this.value) {tip("Please provide a valid group name"); return;}
let group = this.value.toLowerCase().replace(/ /g, "_").replace(/[^\w\s]/gi, "");
if (Number.isFinite(+group.charAt(0))) group = "g" + group;
if (document.getElementById(group)) {
tip("Element with this id already exists. Please provide a unique name", false, "error");
return;
}
// just rename if only 1 element left
const oldGroup = elSelected.node().parentNode;
const basic = ["roads", "trails", "searoutes"].includes(oldGroup.id);
if (!basic && oldGroup.childElementCount === 1) {
document.getElementById("routeGroup").selectedOptions[0].remove();
document.getElementById("routeGroup").options.add(new Option(group, group, false, true));
oldGroup.id = group;
toggleNewGroupInput();
document.getElementById("routeGroupName").value = "";
return;
}
const newGroup = elSelected.node().parentNode.cloneNode(false);
document.getElementById("routes").appendChild(newGroup);
newGroup.id = group;
document.getElementById("routeGroup").options.add(new Option(group, group, false, true));
document.getElementById(group).appendChild(elSelected.node());
toggleNewGroupInput();
document.getElementById("routeGroupName").value = "";
}
function removeRouteGroup() {
const group = elSelected.node().parentNode.id;
const basic = ["roads", "trails", "searoutes"].includes(group);
const count = elSelected.node().parentNode.childElementCount;
alertMessage.innerHTML = `Are you sure you want to remove
${basic ? "all elements in the group" : "the entire route group"}?
<br><br>Routes to be removed: ${count}`;
$("#alert").dialog({resizable: false, title: "Remove route group",
buttons: {
Remove: function() {
$(this).dialog("close");
$("#routeEditor").dialog("close");
hideGroupSection();
if (basic) routes.select("#"+group).selectAll("path").remove();
else routes.select("#"+group).remove();
},
Cancel: function() {$(this).dialog("close");}
}
});
}
function toggleRouteSplitMode() {
document.getElementById("routeNew").classList.remove("pressed");
this.classList.toggle("pressed");
}
function clickControlPoint() {
if (routeSplit.classList.contains("pressed")) splitRoute(this);
else {this.remove(); redrawRoute();}
}
function splitRoute(clicked) {
lineGen.curve(d3.curveCatmullRom.alpha(.1));
const group = d3.select(elSelected.node().parentNode);
routeSplit.classList.remove("pressed");
const points1 = [], points2 = [];
let points = points1;
debug.select("#controlPoints").selectAll("circle").each(function() {
points.push([this.getAttribute("cx"), this.getAttribute("cy")]);
if (this === clicked) {
points = points2;
points.push([this.getAttribute("cx"), this.getAttribute("cy")]);
}
this.remove();
});
elSelected.attr("d", round(lineGen(points1)));
const id = getNextId("route");
group.append("path").attr("id", id).attr("d", lineGen(points2));
debug.select("#controlPoints").selectAll("circle").remove();
drawControlPoints(elSelected.node());
}
function toggleRouteCreationMode() {
document.getElementById("routeSplit").classList.remove("pressed");
document.getElementById("routeNew").classList.toggle("pressed");
if (document.getElementById("routeNew").classList.contains("pressed")) {
tip("Click on map to add control points", true);
viewbox.on("click", addPointOnClick).style("cursor", "crosshair");
elSelected.on("click", null);
} else {
clearMainTip();
viewbox.on("click", clicked).style("cursor", "default");
elSelected.on("click", addInterimControlPoint).attr("data-new", null);
}
}
function addPointOnClick() {
// create new route
if (!elSelected.attr("data-new")) {
debug.select("#controlPoints").selectAll("circle").remove();
const parent = elSelected.node().parentNode;
const id = getNextId("route");
elSelected = d3.select(parent).append("path").attr("id", id).attr("data-new", 1);
}
// add control point
const point = d3.mouse(this);
addControlPoint({x: point[0], y: point[1]});
redrawRoute();
}
function editRouteLegend() {
const id = elSelected.attr("id");
editLegends(id, id);
}
function removeRoute() {
alertMessage.innerHTML = "Are you sure you want to remove the route?";
$("#alert").dialog({resizable: false, title: "Remove route",
buttons: {
Remove: function() {
$(this).dialog("close");
elSelected.remove();
$("#routeEditor").dialog("close");
},
Cancel: function() {$(this).dialog("close");}
}
});
}
function closeRoutesEditor() {
elSelected.attr("data-new", null).on("click", null);
clearMainTip();
routeSplit.classList.remove("pressed");
routeNew.classList.remove("pressed");
debug.select("#controlPoints").remove();
unselect();
}
}

514
modules/ui/states-editor.js Normal file
View file

@ -0,0 +1,514 @@
"use strict";
function editStates() {
if (customization) return;
closeDialogs("#statesEditor, .stable");
if (!layerIsOn("toggleStates")) toggleStates();
if (!layerIsOn("toggleBorders")) toggleBorders();
if (layerIsOn("toggleCultures")) toggleCultures();
if (layerIsOn("toggleBiomes")) toggleBiomes();
const body = document.getElementById("statesBodySection");
refreshStatesEditor();
if (modules.editStates) return;
modules.editStates = true;
$("#statesEditor").dialog({
title: "States Editor", width: fitContent(), close: closeStatesEditor,
position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}
});
// add listeners
document.getElementById("statesEditorRefresh").addEventListener("click", refreshStatesEditor);
document.getElementById("statesPercentage").addEventListener("click", togglePercentageMode);
document.getElementById("regenerateStateNames").addEventListener("click", regenerateNames);
document.getElementById("statesRegenerate").addEventListener("click", openRegenerationMenu);
document.getElementById("statesRegenerateBack").addEventListener("click", exitRegenerationMenu);
document.getElementById("statesRecalculate").addEventListener("click", recalculateStates);
document.getElementById("statesJustify").addEventListener("click", justifyStates);
document.getElementById("statesRandomize").addEventListener("click", randomizeStatesExpansion);
document.getElementById("statesNeutral").addEventListener("input", recalculateStates);
document.getElementById("statesNeutralNumber").addEventListener("click", recalculateStates);
document.getElementById("statesManually").addEventListener("click", enterStatesManualAssignent);
document.getElementById("statesManuallyApply").addEventListener("click", applyStatesManualAssignent);
document.getElementById("statesManuallyCancel").addEventListener("click", exitStatesManualAssignment);
document.getElementById("statesAdd").addEventListener("click", enterAddStateMode);
document.getElementById("statesExport").addEventListener("click", downloadStatesData);
function refreshStatesEditor() {
statesCollectStatistics();
statesEditorAddLines();
}
function statesCollectStatistics() {
const cells = pack.cells, states = pack.states;
states.forEach(s => s.cells = s.area = s.burgs = s.rural = s.urban = 0);
for (const i of cells.i) {
if (cells.h[i] < 20) continue;
const s = cells.state[i];
states[s].cells += 1;
states[s].area += cells.area[i];
states[s].rural += cells.pop[i];
if (cells.burg[i]) {
states[s].urban += pack.burgs[cells.burg[i]].population;
states[s].burgs++;
}
}
}
// add line for each state
function statesEditorAddLines() {
const unit = areaUnit.value === "square" ? " " + distanceUnit.value + "²" : " " + areaUnit.value;
const hidden = statesRegenerateButtons.style.display === "block" ? "visible" : "hidden"; // show/hide regenerate columns
let lines = "", totalArea = 0, totalPopulation = 0, totalBurgs = 0;
for (const s of pack.states) {
if (s.removed) continue;
const area = s.area * (distanceScale.value ** 2);
const rural = s.rural * populationRate.value;
const urban = s.urban * populationRate.value * urbanization.value;
const population = rural + urban;
const populationTip = `Total population: ${si(population)}; Rural population: ${si(rural)}; Urban population: ${si(urban)}`;
totalArea += area;
totalPopulation += population;
totalBurgs += s.burgs;
if (!s.i) {
// Neutral line
lines += `<div class="states" data-id=${s.i} data-name="${s.name}" data-cells=${s.cells} data-area=${area}
data-population=${population} data-burgs=${s.burgs} data-color="" data-capital="" data-culture="" data-type="" data-expansionism="">
<input class="stateColor placeholder" type="color">
<input data-tip="State name. Click and type to change" class="stateName italic" value="${s.name}" autocorrect="off" spellcheck="false">
<span class="icon-star-empty placeholder"></span>
<input class="stateCapital placeholder">
<select class="stateCulture placeholder">${getCultureOptions(0)}</select>
<select class="cultureType ${hidden} placeholder">${getTypeOptions(0)}</select>
<span class="icon-resize-full ${hidden} placeholder"></span>
<input class="statePower ${hidden} placeholder" type="number" value=0>
<span data-tip="Cells count" class="icon-check-empty"></span>
<div data-tip="Cells count" class="stateCells">${s.cells}</div>
<span data-tip="Burgs count" style="padding-right: 1px" class="icon-dot-circled"></span>
<div data-tip="Burgs count" class="stateBurgs">${s.burgs}</div>
<span data-tip="State area" style="padding-right: 4px" class="icon-map-o"></span>
<div data-tip="State area" class="biomeArea">${si(area) + unit}</div>
<span data-tip="${populationTip}" class="icon-male"></span>
<div data-tip="${populationTip}" class="culturePopulation">${si(population)}</div>
</div>`;
continue;
}
const capital = pack.burgs[s.capital].name;
lines += `<div class="states" data-id=${s.i} data-name="${s.name}" data-capital="${capital}" data-color="${s.color}" data-cells=${s.cells}
data-area=${area} data-population=${population} data-burgs=${s.burgs} data-culture=${pack.cultures[s.culture].name} data-type=${s.type} data-expansionism=${s.expansionism}>
<input data-tip="State color. Click to change" class="stateColor" type="color" value="${s.color}">
<input data-tip="State name. Click and type to change" class="stateName" value="${s.name}" autocorrect="off" spellcheck="false">
<span data-tip="State capital. Click to zoom into view" class="icon-star-empty pointer"></span>
<input data-tip="Capital name. Click and type to rename" class="stateCapital" value="${capital}" autocorrect="off" spellcheck="false"/>
<select data-tip="Dominant culture. Click to change" class="stateCulture">${getCultureOptions(s.culture)}</select>
<select data-tip="State type. Click to change" class="cultureType ${hidden}">${getTypeOptions(s.type)}</select>
<span data-tip="State expansionism" class="icon-resize-full ${hidden}"></span>
<input data-tip="Expansionism (defines competitive size). Change to re-calculate states based on new value" class="statePower ${hidden}" type="number" min=0 max=99 step=.1 value=${s.expansionism}>
<span data-tip="Cells count" class="icon-check-empty"></span>
<div data-tip="Cells count" class="stateCells">${s.cells}</div>
<span data-tip="Burgs count" style="padding-right: 1px" class="icon-dot-circled"></span>
<div data-tip="Burgs count" class="stateBurgs">${s.burgs}</div>
<span data-tip="State area" style="padding-right: 4px" class="icon-map-o"></span>
<div data-tip="State area" class="biomeArea">${si(area) + unit}</div>
<span data-tip="${populationTip}" class="icon-male"></span>
<div data-tip="${populationTip}" class="culturePopulation">${si(population)}</div>
<span data-tip="Remove state" class="icon-trash-empty"></span>
</div>`;
}
body.innerHTML = lines;
// update footer
statesFooterStates.innerHTML = pack.states.filter(s => s.i && !s.removed).length;
statesFooterCells.innerHTML = pack.cells.h.filter(h => h >= 20).length;
statesFooterBurgs.innerHTML = totalBurgs;
statesFooterArea.innerHTML = si(totalArea) + unit;
statesFooterPopulation.innerHTML = si(totalPopulation);
statesFooterArea.dataset.area = totalArea;
statesFooterPopulation.dataset.population = totalPopulation;
// add listeners
body.querySelectorAll("div.states").forEach(el => el.addEventListener("mouseenter", ev => stateHighlightOn(ev)));
body.querySelectorAll("div.states").forEach(el => el.addEventListener("mouseleave", ev => stateHighlightOff(ev)));
body.querySelectorAll("div.states").forEach(el => el.addEventListener("click", selectStateOnLineClick));
body.querySelectorAll("div > input.stateColor").forEach(el => el.addEventListener("input", stateChangeColor));
body.querySelectorAll("div > input.stateName").forEach(el => el.addEventListener("input", stateChangeName));
body.querySelectorAll("div > input.stateCapital").forEach(el => el.addEventListener("input", stateChangeCapitalName));
body.querySelectorAll("div > span.icon-star-empty").forEach(el => el.addEventListener("click", stateCapitalZoomIn));
body.querySelectorAll("div > select.stateCulture").forEach(el => el.addEventListener("click", stateUpdateCulturesList));
body.querySelectorAll("div > select.cultureType").forEach(el => el.addEventListener("input", stateChangeType));
body.querySelectorAll("div > input.statePower").forEach(el => el.addEventListener("input", stateChangeExpansionism));
body.querySelectorAll("div > span.icon-trash-empty").forEach(el => el.addEventListener("click", stateRemove));
if (body.dataset.type === "percentage") {body.dataset.type = "absolute"; togglePercentageMode();}
applySorting(statesHeader);
$("#statesEditor").dialog();
}
function getCultureOptions(culture) {
let options = "";
pack.cultures.slice(1).forEach(c => options += `<option ${c.i === culture ? "selected" : ""} value="${c.i}">${c.name}</option>`);
return options;
}
function getTypeOptions(type) {
let options = "";
const types = ["Generic", "River", "Lake", "Naval", "Nomadic", "Hunting", "Highland"];
types.forEach(t => options += `<option ${type === t ? "selected" : ""} value="${t}">${t}</option>`);
return options;
}
function stateHighlightOn(event) {
if (!layerIsOn("toggleStates")) return;
const state = +event.target.dataset.id;
if (customization || !state) return;
const path = regions.select("#state"+state).attr("d");
debug.append("path").attr("class", "highlighted").attr("d", path)
.attr("stroke", "red").attr("stroke-width", 1).attr("opacity", 1)
.attr("filter", "url(#blur1)").call(transition);
}
function transition(path) {
const duration = (path.node().getTotalLength() + 5000) / 2;
path.transition().duration(duration).attrTween("stroke-dasharray", tweenDash);
}
function tweenDash() {
const l = this.getTotalLength();
const i = d3.interpolateString("0," + l, l + "," + l);
return t => i(t);
}
function removePath(path) {
path.transition().duration(1000).attr("opacity", 0).remove();
}
function stateHighlightOff() {
debug.selectAll(".highlighted").each(function(el) {
d3.select(this).call(removePath);
});
}
function stateChangeColor() {
const state = +this.parentNode.dataset.id;
pack.states[state].color = this.value;
regions.select("#state"+state).attr("fill", this.value);
regions.select("#state-gap"+state).attr("stroke", this.value);
regions.select("#state-border"+state).attr("stroke", d3.color(this.value).darker().hex());
}
function stateChangeName() {
const state = +this.parentNode.dataset.id;
this.parentNode.dataset.name = this.value;
pack.states[state].name = this.value;
document.querySelector("#stateLabel"+state+" > textPath").textContent = this.value;
}
function stateChangeCapitalName() {
const state = +this.parentNode.dataset.id;
this.parentNode.dataset.capital = this.value;
const capital = pack.states[state].capital;
if (!capital) return;
pack.burgs[capital].name = this.value;
document.querySelector("#burgLabel"+capital).textContent = this.value;
}
function stateCapitalZoomIn() {
const state = +this.parentNode.dataset.id;
const capital = pack.states[state].capital;
const l = burgLabels.select("[data-id='" + capital + "']");
const x = +l.attr("x"), y = +l.attr("y");
zoomTo(x, y, 8, 2000);
}
function stateUpdateCulturesList() {
const state = +this.parentNode.dataset.id;
const v = +this.value;
this.parentNode.dataset.base = pack.states[state].culture = v;
this.options.length = 0;
pack.cultures.slice(1).forEach(c => this.options.add(new Option(c.name, c.i, false, c.i === v)));
}
function stateChangeType() {
const state = +this.parentNode.dataset.id;
this.parentNode.dataset.type = this.value;
pack.states[state].type = this.value;
recalculateStates();
}
function stateChangeExpansionism() {
const state = +this.parentNode.dataset.id;
this.parentNode.dataset.expansionism = this.value;
pack.states[state].expansionism = +this.value;
recalculateStates();
}
function stateRemove() {
if (customization) return;
const state = +this.parentNode.dataset.id;
regions.select("#state"+state).remove();
regions.select("#state-gap"+state).remove();
regions.select("#state-border"+state).remove();
document.querySelector("#stateLabel"+state+" > textPath").remove();
pack.burgs.forEach(b => {if(b.state === state) b.state = 0;});
pack.cells.state.forEach((s, i) => {if(s === state) pack.cells.state[i] = 0;});
pack.states[state].removed = true;
const capital = pack.states[state].capital;
pack.burgs[capital].capital = false;
pack.burgs[capital].state = 0;
moveBurgToGroup(capital, "towns");
if (!layerIsOn("toggleStates")) toggleStates(); else drawStatesWithBorders();
refreshStatesEditor();
}
function togglePercentageMode() {
if (body.dataset.type === "absolute") {
body.dataset.type = "percentage";
const totalCells = +statesFooterCells.innerHTML;
const totalBurgs = +statesFooterBurgs.innerHTML;
const totalArea = +statesFooterArea.dataset.area;
const totalPopulation = +statesFooterPopulation.dataset.population;
body.querySelectorAll(":scope > div").forEach(function(el) {
el.querySelector(".stateCells").innerHTML = rn(+el.dataset.cells / totalCells * 100) + "%";
el.querySelector(".stateBurgs").innerHTML = rn(+el.dataset.burgs / totalBurgs * 100) + "%";
el.querySelector(".biomeArea").innerHTML = rn(+el.dataset.area / totalArea * 100) + "%";
el.querySelector(".culturePopulation").innerHTML = rn(+el.dataset.population / totalPopulation * 100) + "%";
});
} else {
body.dataset.type = "absolute";
statesEditorAddLines();
}
}
function regenerateNames() {
body.querySelectorAll(":scope > div").forEach(function(el) {
const state = +el.dataset.id;
if (!state) return;
const culture = pack.states[state].culture;
const name = Names.getState(Names.getCulture(culture, 4, 7, ""), culture);
el.querySelector(".stateName").value = name;
pack.states[state].name = el.dataset.name = name;
labels.select("#stateLabel"+state+" > textPath").text(name);
});
if (adjustLabels.checked) BurgsAndStates.drawStateLabels();
}
function openRegenerationMenu() {
statesBottom.querySelectorAll(":scope > button").forEach(el => el.style.display = "none");
statesRegenerateButtons.style.display = "block";
statesEditor.querySelectorAll(".hidden").forEach(el => {el.classList.remove("hidden"); el.classList.add("visible");});
$("#statesEditor").dialog({position: {my: "right top", at: "right top", of: $("#statesEditor").parent(), collision: "fit"}});
}
function recalculateStates() {
BurgsAndStates.expandStates();
if (!layerIsOn("toggleStates")) toggleStates(); else drawStatesWithBorders();
if (adjustLabels.checked) BurgsAndStates.drawStateLabels();
refreshStatesEditor();
}
function justifyStates() {
BurgsAndStates.normalizeStates();
if (!layerIsOn("toggleStates")) toggleStates(); else drawStatesWithBorders();
if (adjustLabels.checked) BurgsAndStates.drawStateLabels();
refreshStatesEditor();
}
function randomizeStatesExpansion() {
pack.states.slice(1).forEach(s => {
const expansionism = rn(Math.random() * powerInput.value / 2 + 1, 1);
s.expansionism = expansionism;
body.querySelector("div.states[data-id='"+s.i+"'] > input.statePower").value = expansionism;
});
recalculateStates();
}
function exitRegenerationMenu() {
statesBottom.querySelectorAll(":scope > button").forEach(el => el.style.display = "inline-block");
statesRegenerateButtons.style.display = "none";
statesEditor.querySelectorAll(".visible").forEach(el => {el.classList.remove("visible"); el.classList.add("hidden");});
}
function enterStatesManualAssignent() {
if (!layerIsOn("toggleStates")) toggleStates();
customization = 2;
regions.append("g").attr("id", "temp");
document.querySelectorAll("#statesBottom > button").forEach(el => el.style.display = "none");
document.getElementById("statesManuallyButtons").style.display = "inline-block";
document.getElementById("statesHalo").style.display = "none";
tip("Click on state to select, drag the circle to change state", true);
viewbox.style("cursor", "crosshair").call(d3.drag()
.on("drag", dragStateBrush))
.on("click", selectStateOnMapClick)
.on("touchmove mousemove", moveStateBrush);
body.querySelectorAll("div > *").forEach(e => e.disabled = true);
body.querySelector("div").classList.add("selected");
}
function selectStateOnLineClick(i) {
if (customization !== 2) return;
body.querySelector("div.selected").classList.remove("selected");
this.classList.add("selected");
}
function selectStateOnMapClick() {
const point = d3.mouse(this);
const i = findCell(point[0], point[1]);
if (pack.cells.h[i] < 20) return;
const assigned = regions.select("#temp").select("polygon[data-cell='"+i+"']");
const state = assigned.size() ? +assigned.attr("data-state") : pack.cells.state[i];
body.querySelector("div.selected").classList.remove("selected");
body.querySelector("div[data-id='"+state+"']").classList.add("selected");
}
function dragStateBrush() {
const p = d3.mouse(this);
const r = +statesManuallyBrush.value;
moveCircle(p[0], p[1], r);
const found = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1], r)];
const selection = found.filter(isLand);
if (selection) changeStateForSelection(selection);
}
// change state within selection
function changeStateForSelection(selection) {
const temp = regions.select("#temp");
const selected = body.querySelector("div.selected");
const stateNew = +selected.dataset.id;
const color = pack.states[stateNew].color || "#ffffff";
selection.forEach(function(i) {
const exists = temp.select("polygon[data-cell='"+i+"']");
const stateOld = exists.size() ? +exists.attr("data-state") : pack.cells.state[i];
if (stateNew === stateOld) return;
if (i === pack.states[stateOld].center) return;
// change of append new element
if (exists.size()) exists.attr("data-state", stateNew).attr("fill", color).attr("stroke", color);
else temp.append("polygon").attr("data-cell", i).attr("data-state", stateNew).attr("points", getPackPolygon(i)).attr("fill", color).attr("stroke", color);
});
}
function moveStateBrush() {
showMainTip();
const point = d3.mouse(this);
const radius = +statesManuallyBrush.value;
moveCircle(point[0], point[1], radius);
}
function applyStatesManualAssignent() {
const cells = pack.cells;
const changed = regions.select("#temp").selectAll("polygon");
changed.each(function() {
const i = +this.dataset.cell;
const c = +this.dataset.state;
cells.state[i] = c;
if (cells.burg[i]) pack.burgs[cells.burg[i]].state = c;
});
if (changed.size()) {
refreshStatesEditor();
if (!layerIsOn("toggleStates")) toggleStates(); else drawStatesWithBorders();
if (adjustLabels.checked) BurgsAndStates.drawStateLabels();
}
exitStatesManualAssignment();
}
function exitStatesManualAssignment() {
customization = 0;
regions.select("#temp").remove();
removeCircle();
document.querySelectorAll("#statesBottom > button").forEach(el => el.style.display = "inline-block");
document.getElementById("statesManuallyButtons").style.display = "none";
document.getElementById("statesHalo").style.display = "block";
body.querySelectorAll("div > *").forEach(e => e.disabled = false);
restoreDefaultEvents();
clearMainTip();
const selected = body.querySelector("div.selected");
if (selected) selected.classList.remove("selected");
}
function enterAddStateMode() {
if (this.classList.contains("pressed")) {exitAddStateMode(); return;};
customization = 3;
this.classList.add("pressed");
tip("Click on the map to create a new capital or promote an existing burg", true);
viewbox.style("cursor", "crosshair").on("click", addState);
body.querySelectorAll("div > *").forEach(e => e.disabled = true);
}
function addState() {
const point = d3.mouse(this);
const center = findCell(point[0], point[1]);
if (pack.cells.h[center] < 20) {tip("You cannot place state into the water. Please click on a land cell", false, "error"); return;}
let burg = pack.cells.burg[center];
if (burg && pack.burgs[burg].capital) {tip("Existing capital cannot be selected as a new state capital! Select other cell", false, "error"); return;}
if (!burg) burg = addBurg(point); // add new burg
// turn burg into a capital
pack.burgs[burg].capital = true;
pack.burgs[burg].state = pack.states.length;
moveBurgToGroup(burg, "cities");
exitAddStateMode();
const culture = pack.cells.culture[center];
const basename = center%5 === 0 ? pack.burgs[burg].name : Names.getCulture(culture);
const name = Names.getState(basename, culture);
const color = d3.color(d3.scaleSequential(d3.interpolateRainbow)(Math.random())).hex();
pack.states.push({i:pack.states.length, name, color, expansionism:.5, capital:burg, type:"Generic", center, culture});
recalculateStates();
}
function exitAddStateMode() {
customization = 0;
restoreDefaultEvents();
clearMainTip();
body.querySelectorAll("div > *").forEach(e => e.disabled = false);
if (statesAdd.classList.contains("pressed")) statesAdd.classList.remove("pressed");
}
function downloadStatesData() {
const unit = areaUnit.value === "square" ? distanceUnit.value + "2" : areaUnit.value;
let data = "Id,State,Color,Capital,Culture,Type,Expansionism,Cells,Burgs,Area "+unit+",Population\n"; // headers
body.querySelectorAll(":scope > div").forEach(function(el) {
data += el.dataset.id + ",";
data += el.dataset.name + ",";
data += el.dataset.color + ",";
data += el.dataset.capital + ",";
data += el.dataset.culture + ",";
data += el.dataset.type + ",";
data += el.dataset.expansionism + ",";
data += el.dataset.cells + ",";
data += el.dataset.burgs + ",";
data += el.dataset.area + ",";
data += el.dataset.population + "\n";
});
const dataBlob = new Blob([data], {type: "text/plain"});
const url = window.URL.createObjectURL(dataBlob);
const link = document.createElement("a");
document.body.appendChild(link);
link.download = "states_data" + Date.now() + ".csv";
link.href = url;
link.click();
window.setTimeout(function() {window.URL.revokeObjectURL(url);}, 2000);
}
function closeStatesEditor() {
if (customization === 2) exitStatesManualAssignment();
if (customization === 3) exitAddStateMode();
}
}

222
modules/ui/tools.js Normal file
View file

@ -0,0 +1,222 @@
// module to control the Tools options (click to edit, to re-geenerate, tp add)
"use strict";
toolsContent.addEventListener("click", function() {
if (customization) {tip("Please exit the customization mode first", false, "warning"); return;}
if (event.target.tagName !== "BUTTON") return;
const button = event.target.id;
// Click to open Editor buttons
if (button === "editHeightmapButton") editHeightmap(); else
if (button === "editBiomesButton") editBiomes(); else
if (button === "editStatesButton") editStates(); else
if (button === "editCulturesButton") editCultures(); else
if (button === "editNamesBaseButton") editNamesbase(); else
if (button === "editBurgsButton") editBurgs(); else
if (button === "editUnitsButton") editUnits();
// Click to Regenerate buttons
if (button === "regenerateStateLabels") {BurgsAndStates.drawStateLabels(); if (!layerIsOn("toggleLabels")) toggleLabels();} else
if (button === "regenerateReliefIcons") {ReliefIcons(); if (!layerIsOn("toggleRelief")) toggleRelief();} else
if (button === "regenerateRoutes") {Routes.regenerate(); if (!layerIsOn("toggleRoutes")) toggleRoutes();} else
if (button === "regenerateRivers") {Rivers.generate(); if (!layerIsOn("toggleRivers")) toggleRivers();} else
if (button === "regeneratePopulation") recalculatePopulation();
// Click to Add buttons
if (button === "addLabel") toggleAddLabel(); else
if (button === "addBurgTool") toggleAddBurg(); else
if (button === "addRiver") toggleAddRiver(); else
if (button === "addRoute") toggleAddRoute(); else
if (button === "addMarker") toggleAddMarker();
});
function recalculatePopulation() {
rankCells();
pack.burgs.forEach(b => {
if (!b.i || b.removed) return;
const i = b.cell;
b.population = rn(Math.max((pack.cells.s[i] + pack.cells.road[i]) / 3 + b.i / 1000 + i % 100 / 1000, .1), 3);
if (b.capital) b.population = rn(b.population * 1.3, 3); // increase capital population
if (b.port) b.population = rn(b.population * 1.3, 3); // increase port population
});
}
function unpressClickToAddButton() {
addFeature.querySelectorAll("button.pressed").forEach(b => b.classList.remove("pressed"));
restoreDefaultEvents();
clearMainTip();
}
function toggleAddLabel() {
const pressed = document.getElementById("addLabel").classList.contains("pressed");
if (pressed) {unpressClickToAddButton(); return;}
addFeature.querySelectorAll("button.pressed").forEach(b => b.classList.remove("pressed"));
addLabel.classList.add('pressed');
closeDialogs(".stable");
viewbox.style("cursor", "crosshair").on("click", addLabelOnClick);
tip("Click on map to place label. Hold Shift to add multiple", true);
if (!layerIsOn("toggleLabels")) toggleLabels();
}
function addLabelOnClick() {
const point = d3.mouse(this);
// get culture in clicked point to generate a name
const cell = findCell(point[0], point[1]);
const culture = pack.cells.culture[cell];
const name = Names.getCulture(culture);
const id = getNextId("label");
labels.select("#addedLabels").append("text").attr("id", id)
.append("textPath").attr("xlink:href", "#textPath_"+id).text(name)
.attr("startOffset", "50%").attr("font-size", "100%");
defs.select("#textPaths").append("path").attr("id", "textPath_"+id)
.attr("d", `M${point[0]-60},${point[1]} h${120}`);
if (d3.event.shiftKey === false) unpressClickToAddButton();
}
function toggleAddBurg() {
unpressClickToAddButton();
document.getElementById("addBurgTool").classList.add("pressed");
editBurgs();
document.getElementById("addNewBurg").click();
}
function toggleAddRiver() {
const pressed = document.getElementById("addRiver").classList.contains("pressed");
if (pressed) {unpressClickToAddButton(); return;}
addFeature.querySelectorAll("button.pressed").forEach(b => b.classList.remove("pressed"));
addRiver.classList.add('pressed');
closeDialogs(".stable");
viewbox.style("cursor", "crosshair").on("click", addRiverOnClick);
tip("Click on map to place new river or extend an existing one. Hold Shift to place multiple rivers", true);
if (!layerIsOn("toggleRivers")) toggleRivers();
}
function addRiverOnClick() {
const cells = pack.cells;
const point = d3.mouse(this);
let i = findCell(point[0], point[1]);
if (cells.r[i] || cells.h[i] < 20 || cells.b[i]) return;
const dataRiver = []; // to store river points
const river = +getNextId("river").slice(5); // river id
cells.fl[i] = grid.cells.prec[cells.g[i]]; // initial flux
let render = true;
while (i) {
cells.r[i] = river;
const x = cells.p[i][0], y = cells.p[i][1];
dataRiver.push({x, y, cell:i});
const min = cells.c[i][d3.scan(cells.c[i], (a, b) => cells.h[a] - cells.h[b])]; // downhill cell
if (cells.h[i] <= cells.h[min]) {
tip(`Clicked cell is depressed! To resolve edit the heightmap and allow system to change heights`, false, "error");
render = false;
break;
}
const tx = cells.p[min][0], ty = cells.p[min][1];
if (cells.h[min] < 20) {
const px = (x + tx) / 2;
const py = (y + ty) / 2;
dataRiver.push({x: px, y: py, cell:i});
break;
}
if (!cells.r[min]) {
cells.fl[min] += cells.fl[i];
i = min;
continue;
}
const r = cells.r[min];
const riverCellsUpper = cells.i.filter(i => cells.r[i] === r && cells.h[i] > cells.h[min]);
// new river is not perspective
if (dataRiver.length <= riverCellsUpper.length) {
cells.conf[min] += cells.fl[i];
dataRiver.push({x: tx, y: ty, cell: min});
break;
}
// new river is more perspective
rivers.select("#river"+r).remove();
riverCellsUpper.forEach(i => cells.r[i] = 0);
if (riverCellsUpper.length > 1) {
// redraw upper part of the old river
}
cells.conf[min] = cells.fl[min];
cells.fl[min] = cells.fl[i] + grid.cells.prec[cells.g[min]];
i = min;
}
if (!render) return;
const points = Rivers.addMeandring(dataRiver, Math.random() * .5 + .1);
const width = Math.random() * .5 + .9;
const increment = Math.random() * .4 + .8;
const d = Rivers.getPath(points, width, increment);
rivers.append("path").attr("d", d).attr("id", "river"+river).attr("data-width", width).attr("data-increment", increment);
if (d3.event.shiftKey === false) unpressClickToAddButton();
}
function toggleAddRoute() {
const pressed = document.getElementById("addRoute").classList.contains("pressed");
if (pressed) {unpressClickToAddButton(); return;}
addFeature.querySelectorAll("button.pressed").forEach(b => b.classList.remove("pressed"));
addRoute.classList.add('pressed');
closeDialogs(".stable");
viewbox.style("cursor", "crosshair").on("click", addRouteOnClick);
tip("Click on map to add a first control point", true);
if (!layerIsOn("toggleRoutes")) toggleRoutes();
}
function addRouteOnClick() {
unpressClickToAddButton();
const point = d3.mouse(this);
const id = getNextId("route");
elSelected = routes.select("g").append("path").attr("id", id).attr("data-new", 1).attr("d", `M${point[0]},${point[1]}`);
editRoute(true);
}
function toggleAddMarker() {
const pressed = document.getElementById("addMarker").classList.contains("pressed");
if (pressed) {unpressClickToAddButton(); return;}
addFeature.querySelectorAll("button.pressed").forEach(b => b.classList.remove("pressed"));
addMarker.classList.add('pressed');
closeDialogs(".stable");
viewbox.style("cursor", "crosshair").on("click", addMarkerOnClick);
tip("Click on map to add a marker. Hold Shift to add multiple", true);
if (!layerIsOn("toggleMarkers")) toggleMarkers();
}
function addMarkerOnClick() {
const point = d3.mouse(this);
const x = rn(point[0], 2), y = rn(point[1], 2);
const id = getNextId("markerElement");
const selected = markerSelectGroup.value;
const valid = selected && d3.select("#defs-markers").select("#"+selected).size();
const symbol = valid ? "#"+selected : "#marker0";
const added = markers.select("[data-id='" + symbol + "']").size();
let desired = valid && added ? markers.select("[data-id='" + symbol + "']").attr("data-size") : 1;
if (isNaN(desired)) desired = 1;
const size = desired * 5 + 25 / scale;
markers.append("use").attr("id", id).attr("xlink:href", symbol).attr("data-id", symbol)
.attr("data-x", x).attr("data-y", y).attr("x", x - size / 2).attr("y", y - size)
.attr("data-size", desired).attr("width", size).attr("height", size);
if (d3.event.shiftKey === false) unpressClickToAddButton();
}

167
modules/ui/units-editor.js Normal file
View file

@ -0,0 +1,167 @@
"use strict";
function editUnits() {
closeDialogs("#unitsEditor, .stable");
$("#unitsEditor").dialog();
if (modules.editUnits) return;
modules.editUnits = true;
$("#unitsEditor").dialog({
title: "Units Editor",
position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}
});
// add listeners
document.getElementById("distanceUnit").addEventListener("change", changeDistanceUnit);
document.getElementById("distanceScaleSlider").addEventListener("input", changeDistanceScale);
document.getElementById("distanceScale").addEventListener("change", changeDistanceScale);
document.getElementById("distanceScale").addEventListener("mouseenter", hideDistanceUnitOutput);
document.getElementById("distanceScale").addEventListener("mouseleave", showDistanceUnitOutput);
document.getElementById("heightUnit").addEventListener("change", changeHeightUnit);
document.getElementById("heightExponent").addEventListener("input", changeHeightExponent);
document.getElementById("heightExponentSlider").addEventListener("input", changeHeightExponent);
document.getElementById("temperatureScale").addEventListener("change", () => {if (layerIsOn("toggleTemp")) drawTemp()});
document.getElementById("barSizeSlider").addEventListener("input", changeScaleBarSize);
document.getElementById("barSize").addEventListener("input", changeScaleBarSize);
document.getElementById("barLabel").addEventListener("input", drawScaleBar);
document.getElementById("barPosX").addEventListener("input", fitScaleBar);
document.getElementById("barPosY").addEventListener("input", fitScaleBar);
document.getElementById("barBackOpacity").addEventListener("input", function() {scaleBar.select("rect").attr("opacity", this.value)});
document.getElementById("barBackColor").addEventListener("input", function() {scaleBar.select("rect").attr("fill", this.value)});
document.getElementById("populationRateSlider").addEventListener("input", changePopulationRate);
document.getElementById("populationRate").addEventListener("change", changePopulationRate);
document.getElementById("urbanizationSlider").addEventListener("input", changeUrbanizationRate);
document.getElementById("urbanization").addEventListener("change", changeUrbanizationRate);
document.getElementById("addLinearRuler").addEventListener("click", addAdditionalRuler);
document.getElementById("addOpisometer").addEventListener("click", toggleOpisometerMode);
document.getElementById("addPlanimeter").addEventListener("click", togglePlanimeterMode);
document.getElementById("removeRulers").addEventListener("click", removeAllRulers);
function changeDistanceUnit() {
if (this.value === "custom_name") {
const custom = prompt("Provide a custom name for distance unit");
if (custom) this.options.add(new Option(custom, custom, false, true));
else {this.value = document.getElementById("distanceUnitOutput").innerHTML; return;};
}
document.getElementById("distanceUnitOutput").innerHTML = this.value;
drawScaleBar();
calculateFriendlyGridSize();
}
function changeDistanceScale() {
const scale = +this.value;
if (!scale || isNaN(scale) || scale < 0) {
tip("Distance scale should be a positive number", false, "error");
this.value = document.getElementById("distanceScale").dataset.value;
return;
}
document.getElementById("distanceScaleSlider").value = scale;
document.getElementById("distanceScale").value = scale;
document.getElementById("distanceScale").dataset.value = scale;
drawScaleBar();
calculateFriendlyGridSize();
}
function hideDistanceUnitOutput() {document.getElementById("distanceUnitOutput").style.opacity = .2;}
function showDistanceUnitOutput() {document.getElementById("distanceUnitOutput").style.opacity = 1;}
function changeHeightUnit() {
if (this.value !== "custom_name") return;
const custom = prompt("Provide a custom name for height unit");
if (custom) this.options.add(new Option(custom, custom, false, true));
else this.value = "ft";
}
function changeHeightExponent() {
document.getElementById("heightExponent").value = this.value;
document.getElementById("heightExponentSlider").value = this.value;
calculateTemperatures();
if (layerIsOn("toggleTemp")) drawTemp();
}
function changeScaleBarSize() {
document.getElementById("barSize").value = this.value;
document.getElementById("barSizeSlider").value = this.value;
drawScaleBar();
}
function changePopulationRate() {
const rate = +this.value;
if (!rate || isNaN(rate) || rate <= 0) {
tip("Population rate should be a positive number", false, "error");
this.value = document.getElementById("populationRate").dataset.value;
return;
}
document.getElementById("populationRateSlider").value = rate;
document.getElementById("populationRate").value = rate;
document.getElementById("populationRate").dataset.value = rate;
}
function changeUrbanizationRate() {
const rate = +this.value;
if (!rate || isNaN(rate) || rate < 0) {
tip("Urbanization rate should be a number", false, "error");
this.value = document.getElementById("urbanization").dataset.value;
return;
}
document.getElementById("urbanizationSlider").value = rate;
document.getElementById("urbanization").value = rate;
document.getElementById("urbanization").dataset.value = rate;
}
function addAdditionalRuler() {
if (!layerIsOn("toggleRulers")) toggleRulers();
const y = rn(Math.random() * graphHeight * .5 + graphHeight * .25);
addRuler(graphWidth * .2, y, graphWidth * .8, y);
}
function toggleOpisometerMode() {
if (this.classList.contains("pressed")) {
restoreDefaultEvents();
clearMainTip();
this.classList.remove("pressed");
} else {
if (!layerIsOn("toggleRulers")) toggleRulers();
tip("Draw a curve to measure its length", true);
unitsBottom.querySelectorAll(".pressed").forEach(button => button.classList.remove("pressed"));
this.classList.add("pressed");
viewbox.style("cursor", "crosshair").call(d3.drag().on("start", drawOpisometer));
}
}
function togglePlanimeterMode() {
if (this.classList.contains("pressed")) {
restoreDefaultEvents();
clearMainTip();
this.classList.remove("pressed");
} else {
if (!layerIsOn("toggleRulers")) toggleRulers();
tip("Draw a line to measure its inner area", true);
unitsBottom.querySelectorAll(".pressed").forEach(button => button.classList.remove("pressed"));
this.classList.add("pressed");
viewbox.style("cursor", "crosshair").call(d3.drag().on("start", drawPlanimeter));
}
}
function removeAllRulers() {
if (!ruler.selectAll("g").size()) return;
alertMessage.innerHTML = `Are you sure you want to remove all placed rulers?`;
$("#alert").dialog({resizable: false, title: "Remove all rulers",
buttons: {
Remove: function() {
$(this).dialog("close");
ruler.selectAll("g").remove();
},
Cancel: function() {$(this).dialog("close");}
}
});
}
}

View file

@ -0,0 +1,114 @@
function editWorld() {
if (customization) return;
$("#worldConfigurator").dialog({title: "Configure World", width: 440});
const globe = d3.select("#globe");
const clr = d3.scaleSequential(d3.interpolateSpectral);
const tMax = +temperatureEquatorOutput.max, tMin = +temperatureEquatorOutput.min; // temperature extremes
const projection = d3.geoOrthographic().translate([100, 100]).scale(100);
const path = d3.geoPath(projection);
updateGlobeTemperature();
updateGlobePosition();
if (modules.editWorld) return;
modules.editWorld = true;
document.getElementById("worldControls").addEventListener("input", (e) => updateWorld(e.target));
globe.select("#globeWindArrows").on("click", changeWind);
globe.select("#restoreWind").on("click", restoreDefaultWinds);
globe.select("#globeGraticule").attr("d", round(path(d3.geoGraticule()()))); // globe graticule
updateWindDirections();
function updateWorld(el) {
if (el) {
document.getElementById(el.dataset.stored+"Input").value = el.value;
document.getElementById(el.dataset.stored+"Output").value = el.value;
if (el.dataset.stored) lock(el.dataset.stored);
}
updateGlobeTemperature();
updateGlobePosition();
calculateTemperatures();
generatePrecipitation();
elevateLakes();
Rivers.generate();
defineBiomes();
if (layerIsOn("toggleTemp")) drawTemp();
if (layerIsOn("togglePrec")) drawPrec();
if (layerIsOn("toggleBiomes")) drawBiomes();
if (layerIsOn("toggleCoordinates")) drawCoordinates();
}
function updateGlobePosition() {
const eqY = +document.getElementById("equatorOutput").value;
const equidistance = document.getElementById("equidistanceOutput");
equidistance.min = equidistanceInput.min = Math.max(graphHeight - eqY, eqY);
equidistance.max = equidistanceInput.max = equidistance.min * 10;
const eqD = +equidistance.value;
calculateMapCoordinates();
const mc = mapCoordinates; // shortcut
const scale = +distanceScale.value, unit = distanceUnit.value;
document.getElementById("mapSize").innerHTML = `${graphWidth}x${graphHeight}`;
document.getElementById("mapSizeFriendly").innerHTML = `${rn(graphWidth * scale)}x${rn(graphHeight * scale)} ${unit}`;
document.getElementById("meridianLength").innerHTML = rn(eqD * 2);
document.getElementById("meridianLengthFriendly").innerHTML = `${rn(eqD * 2 * scale)} ${unit}`;
document.getElementById("meridianLengthEarth").innerHTML = toKilometer(eqD * 2 * scale);
document.getElementById("mapCoordinates").innerHTML = `${lat(mc.latN)} ${Math.abs(rn(mc.lonW))}°W; ${lat(mc.latS)} ${rn(mc.lonE)}°E`;
function toKilometer(v) {
let kilometers; // value converted to kilometers
if (unit === "km") kilometers = v;
else if (unit === "mi") kilometers = v * 1.60934;
else if (unit === "lg") kilometers = v * 5.556;
else if (unit === "vr") kilometers = v * 1.0668;
else return ""; // do not show as distanceUnit is custom
return " = " + rn(kilometers / 200) + "%🌏"; // % + Earth icon
}
function lat(lat) {return lat > 0 ? Math.abs(rn(lat)) + "°N" : Math.abs(rn(lat)) + "°S";} // parse latitude value
const area = d3.geoGraticule().extent([[mc.lonW, mc.latN], [mc.lonE, mc.latS]]);
globe.select("#globeArea").attr("d", round(path(area.outline()))); // map area
}
function updateGlobeTemperature() {
const tEq = +document.getElementById("temperatureEquatorOutput").value;
document.getElementById("temperatureEquatorF").innerHTML = rn(tEq * 9/5 + 32);
const tPole = +document.getElementById("temperaturePoleOutput").value;
document.getElementById("temperaturePoleF").innerHTML = rn(tPole * 9/5 + 32);
globe.selectAll(".tempGradient90").attr("stop-color", clr(1 - (tPole - tMin) / (tMax - tMin)));
globe.selectAll(".tempGradient60").attr("stop-color", clr(1 - (tEq - (tEq - tPole) * 2/3 - tMin) / (tMax - tMin)));
globe.selectAll(".tempGradient30").attr("stop-color", clr(1 - (tEq - (tEq - tPole) * 1/3 - tMin) / (tMax - tMin)));
globe.select(".tempGradient0").attr("stop-color", clr(1 - (tEq - tMin) / (tMax - tMin)));
}
function updateWindDirections() {
globe.select("#globeWindArrows").selectAll("path").each(function(d, i) {
const tr = parseTransform(this.getAttribute("transform"));
this.setAttribute("transform", `rotate(${winds[i]} ${tr[1]} ${tr[2]})`);
});
}
function changeWind() {
const arrow = d3.event.target.nextElementSibling;
const tier = +arrow.dataset.tier;
winds[tier] = (winds[tier] + 45) % 360;
const tr = parseTransform(arrow.getAttribute("transform"));
arrow.setAttribute("transform", `rotate(${winds[tier]} ${tr[1]} ${tr[2]})`);
localStorage.setItem("winds", winds);
const mapTiers = d3.range(mapCoordinates.latN, mapCoordinates.latS, -30).map(c => (90-c) / 30 | 0);
if (mapTiers.includes(tier)) updateWorld();
}
function restoreDefaultWinds() {
const defaultWinds = [225, 45, 225, 315, 135, 315];
const mapTiers = d3.range(mapCoordinates.latN, mapCoordinates.latS, -30).map(c => (90-c) / 30 | 0);
const update = mapTiers.some(t => winds[t] != defaultWinds[t]);
winds = defaultWinds;
updateWindDirections();
if (update) updateWorld();
}
}

436
modules/utils.js Normal file
View file

@ -0,0 +1,436 @@
// FMG helper functions
"use strict";
// add boundary points to pseudo-clip voronoi cells
function getBoundaryPoints(width, height, spacing) {
const offset = rn(-1 * spacing);
const bSpacing = spacing * 2;
const w = width - offset * 2;
const h = height - offset * 2;
const numberX = Math.ceil(w / bSpacing) - 1;
const numberY = Math.ceil(h / bSpacing) - 1;
let points = [];
for (let i = 0.5; i < numberX; i++) {
let x = Math.ceil(w * i / numberX + offset);
points.push([x, offset], [x, h + offset]);
}
for (let i = 0.5; i < numberY; i++) {
let y = Math.ceil(h * i / numberY + offset);
points.push([offset, y], [w + offset, y]);
}
return points;
}
// get points on a regular square grid and jitter them a bit
function getJitteredGrid(width, height, spacing) {
const radius = spacing / 2; // square radius
const jittering = radius * 0.9; // max deviation
const jitter = function() {return Math.random() * 2 * jittering - jittering;};
let points = [];
for (let y = radius; y < height; y += spacing) {
for (let x = radius; x < width; x += spacing) {
let xj = rn(x + jitter(), 2);
let yj = rn(y + jitter(), 2);
points.push([xj, yj]);
}
}
return points;
}
// return cell index on a regular square grid
function findGridCell(x, y) {
return Math.floor(Math.min(y / grid.spacing, grid.cellsY -1)) * grid.cellsX + Math.floor(Math.min(x / grid.spacing, grid.cellsX-1));
}
// return array of cell indexes in radius on a regular square grid
function findGridAll(x, y, radius) {
const c = grid.cells.c;
let found = [findGridCell(x, y)];
let r = Math.floor(radius / grid.spacing);
if (r > 0) found = found.concat(c[found[0]]);
if (r > 1) {
let frontier = c[found[0]];
while (r > 1) {
let cycle = frontier.slice();
frontier = [];
cycle.forEach(function(s) {
c[s].forEach(function(e) {
if (found.indexOf(e) !== -1) return;
found.push(e);
frontier.push(e);
});
});
r--;
}
}
return found;
}
// return closest pack points quadtree datum
function find(x, y, radius = Infinity) {
return pack.cells.q.find(x, y, radius);
}
// return closest cell index
function findCell(x, y, radius = Infinity) {
const found = pack.cells.q.find(x, y, radius);
return found ? found[2] : undefined;
}
// return array of cell indexes in radius
function findAll(x, y, radius) {
const found = pack.cells.q.findAll(x, y, radius);
return found.map(r => r[2]);
}
// get polygon points for packed cells knowing cell id
function getPackPolygon(i) {
return pack.cells.v[i].map(v => pack.vertices.p[v]);
}
// get polygon points for initial cells knowing cell id
function getGridPolygon(i) {
return grid.cells.v[i].map(v => grid.vertices.p[v]);
}
// mbostock's poissonDiscSampler
function* poissonDiscSampler(x0, y0, x1, y1, r, k = 3) {
if (!(x1 >= x0) || !(y1 >= y0) || !(r > 0)) throw new Error;
const width = x1 - x0;
const height = y1 - y0;
const r2 = r * r;
const r2_3 = 3 * r2;
const cellSize = r * Math.SQRT1_2;
const gridWidth = Math.ceil(width / cellSize);
const gridHeight = Math.ceil(height / cellSize);
const grid = new Array(gridWidth * gridHeight);
const queue = [];
function far(x, y) {
const i = x / cellSize | 0;
const j = y / cellSize | 0;
const i0 = Math.max(i - 2, 0);
const j0 = Math.max(j - 2, 0);
const i1 = Math.min(i + 3, gridWidth);
const j1 = Math.min(j + 3, gridHeight);
for (let j = j0; j < j1; ++j) {
const o = j * gridWidth;
for (let i = i0; i < i1; ++i) {
const s = grid[o + i];
if (s) {
const dx = s[0] - x;
const dy = s[1] - y;
if (dx * dx + dy * dy < r2) return false;
}
}
}
return true;
}
function sample(x, y) {
queue.push(grid[gridWidth * (y / cellSize | 0) + (x / cellSize | 0)] = [x, y]);
return [x + x0, y + y0];
}
yield sample(width / 2, height / 2);
pick: while (queue.length) {
const i = Math.random() * queue.length | 0;
const parent = queue[i];
for (let j = 0; j < k; ++j) {
const a = 2 * Math.PI * Math.random();
const r = Math.sqrt(Math.random() * r2_3 + r2);
const x = parent[0] + r * Math.cos(a);
const y = parent[1] + r * Math.sin(a);
if (0 <= x && x < width && 0 <= y && y < height && far(x, y)) {
yield sample(x, y);
continue pick;
}
}
const r = queue.pop();
if (i < queue.length) queue[i] = r;
}
}
// filter land cells
function isLand(i) {
return pack.cells.h[i] >= 20;
}
// filter water cells
function isWater(i) {
return pack.cells.h[i] < 20;
}
// sort cells by height: highest go first
function highest(a, b) {
return pack.cells.h[b] - pack.cells.h[a];
}
// convert RGB color string to HEX without #
function toHEX(rgb){
if (rgb.charAt(0) === "#") {return rgb;}
rgb = rgb.match(/^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i);
return (rgb && rgb.length === 4) ? "#" +
("0" + parseInt(rgb[1],10).toString(16)).slice(-2) +
("0" + parseInt(rgb[2],10).toString(16)).slice(-2) +
("0" + parseInt(rgb[3],10).toString(16)).slice(-2) : '';
}
// return array of standard shuffled colors
function getColors(number) {
const c12 = d3.scaleOrdinal(d3.schemeSet3);
const cRB = d3.scaleSequential(d3.interpolateRainbow);
const colors = d3.shuffle(d3.range(number).map(i => i < 12 ? c12(i) : d3.color(cRB((i-12)/(number-12))).hex()));
//debug.selectAll("circle").data(colors).enter().append("circle").attr("r", 15).attr("cx", (d,i) => 60 + i * 40).attr("cy", 20).attr("fill", d => d);
return colors;
}
// conver temperature from °C to other scales
function convertTemperature(c) {
switch(temperatureScale.value) {
case "°C": return c + "°C";
case "°F": return rn(c * 9 / 5 + 32) + "°F";
case "K": return rn(c + 273.15) + "K";
case "°R": return rn((c + 273.15) * 9 / 5) + "°R";
case "°De": return rn((100 - c) * 3 / 2) + "°De";
case "°N": return rn(c * 33 / 100) + "°N";
case "°Ré": return rn(c * 4 / 5) + "°Ré";
case "°Rø": return rn(c * 21 / 40 + 7.5) + "°Rø";
default: return c + "°C";
}
}
// random number in a range
function rand(min, max) {
if (min === undefined && !max === undefined) return Math.random();
if (max === undefined) {max = min; min = 0;}
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function gauss(expected = 100, deviation = 30, min = 0, max = 300, round = 0) {
return rn(Math.max(Math.min(d3.randomNormal(expected, deviation)(), max), min), round);
}
// round value to d decimals
function rn(v, d = 0) {
const m = Math.pow(10, d);
return Math.round(v * m) / m;
}
// round string to d decimals
function round(s, d = 1) {
return s.replace(/[\d\.-][\d\.e-]*/g, function(n) {return rn(n, d);})
}
// corvent number to short string with SI postfix
function si(n) {
if (n >= 1e9) {return rn(n / 1e9, 1) + "B";}
if (n >= 1e8) {return rn(n / 1e6) + "M";}
if (n >= 1e6) {return rn(n / 1e6, 1) + "M";}
if (n >= 1e4) {return rn(n / 1e3) + "K";}
if (n >= 1e3) {return rn(n / 1e3, 1) + "K";}
return rn(n);
}
// getInteger number from user input data
function getInteger(value) {
const metric = value.slice(-1);
if (metric === "K") return parseInt(value.slice(0, -1) * 1e3);
if (metric === "M") return parseInt(value.slice(0, -1) * 1e6);
if (metric === "B") return parseInt(value.slice(0, -1) * 1e9);
return parseInt(value);
}
// remove parent element (usually if child is clicked)
function removeParent() {
this.parentNode.parentNode.removeChild(this.parentNode);
}
// return string with 1st char capitalized
function capitalize(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
// transform string to array [translateX,translateY,rotateDeg,rotateX,rotateY,scale]
function parseTransform(string) {
if (!string) {return [0,0,0,0,0,1];}
const a = string.replace(/[a-z()]/g, "").replace(/[ ]/g, ",").split(",");
return [a[0] || 0, a[1] || 0, a[2] || 0, a[3] || 0, a[4] || 0, a[5] || 1];
}
// findAll d3.quandtree search from https://bl.ocks.org/lwthatcher/b41479725e0ff2277c7ac90df2de2b5e
void function addFindAll() {
const Quad = function(node, x0, y0, x1, y1) {
this.node = node;
this.x0 = x0;
this.y0 = y0;
this.x1 = x1;
this.y1 = y1;
}
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))};
radiusSearchInit(t, radius);
var i = 0;
while (t.q = t.quads.pop()) {
i++;
// Stop searching if this quadrant cant contain a closer node.
if (!(t.node = t.q.node)
|| (t.x1 = t.q.x0) > t.x3
|| (t.y1 = t.q.y0) > t.y3
|| (t.x2 = t.q.x1) < t.x0
|| (t.y2 = t.q.y1) < t.y0) continue;
// Bisect the current quadrant.
if (t.node.length) {
t.node.explored = true;
var xm = (t.x1 + t.x2) / 2,
ym = (t.y1 + t.y2) / 2;
t.quads.push(
new Quad(t.node[3], xm, ym, t.x2, t.y2),
new Quad(t.node[2], t.x1, ym, xm, t.y2),
new Quad(t.node[1], xm, t.y1, t.x2, ym),
new Quad(t.node[0], t.x1, t.y1, xm, ym)
);
// Visit the closest quadrant first.
if (t.i = (y >= ym) << 1 | (x >= xm)) {
t.q = t.quads[t.quads.length - 1];
t.quads[t.quads.length - 1] = t.quads[t.quads.length - 1 - t.i];
t.quads[t.quads.length - 1 - t.i] = t.q;
}
}
// Visit this point. (Visiting coincident points isnt necessary!)
else {
var dx = x - +this._x.call(null, t.node.data),
dy = y - +this._y.call(null, t.node.data),
d2 = dx * dx + dy * dy;
radiusSearchVisit(t, d2);
}
}
return t.result;
}
d3.quadtree.prototype.findAll = tree_filter;
var radiusSearchInit = function(t, radius) {
t.result = [];
t.x0 = t.x - radius, t.y0 = t.y - radius;
t.x3 = t.x + radius, t.y3 = t.y + radius;
t.radius = radius * radius;
}
var radiusSearchVisit = function(t, d2) {
t.node.data.scanned = true;
if (d2 < t.radius) {
do {t.result.push(t.node.data); t.node.data.selected = true;} while (t.node = t.node.next);
}
}
}()
// normalization function
function normalize(val, min, max) {
return Math.min(Math.max((val - min) / (max - min), 0), 1);
}
// return a random integer from min to max biased towards one end based on exponent distribution (the bigger ex the higher bias towards min)
// from https://gamedev.stackexchange.com/a/116875
function biased(min, max, ex) {
return Math.round(min + (max - min) * Math.pow(Math.random(), ex));
}
// return array of values common for both array a and array b
function intersect(a, b) {
const setB = new Set(b);
return [...new Set(a)].filter(a => setB.has(a));
}
// check if char is vowel
function vowel(c) {
return "aeiouy".includes(c);
}
// return the last element of array
function last(array) {
return array[array.length - 1];
}
// return value in range [0, 100] (height range)
function lim(v) {
return Math.max(Math.min(v, 100), 0);
}
// get number from string in format "1-3" or "2" or "0.5"
function getNumberInRange(r) {
if (typeof r !== "string") {console.error("The value should be a string", r); return 0;}
if (!isNaN(+r)) return +r;
const sign = r[0] === "-" ? -1 : 1;
if (isNaN(+r[0])) r = r.slice(1);
const range = r.includes("-") ? r.split("-") : null;
if (!range) {console.error("Cannot parse the number. Check the format", r); return 0;}
const count = rand(range[0] * sign, +range[1]);
if (isNaN(count) || count < 0) {console.error("Cannot parse number. Check the format", r); return 0;}
return count;
}
function analizeNamesbase() {
const result = [];
nameBases.forEach((b,i) => {
const d = nameBase[i];
const size = d.length;
const ar = d.map(n => n.length);
const min = d3.min(ar);
const max = d3.max(ar);
const mean = rn(d3.mean(ar), 1);
const median = d3.median(ar);
const lengths = new Uint8Array(max);
ar.forEach(l => lengths[l]++);
const common = d3.scan(lengths, (a,b) => b-a);
const string = d.join("");
const doubleArray = [];
let double = "";
for (let i=0; i<string.length; i++) {
if (!doubleArray[string[i]]) doubleArray[string[i]] = 0;
if (string[i] === string[i-1]) doubleArray[string[i]]++;
}
for (const l in doubleArray) {if(doubleArray[l] > size/35) double += l;}
const multi = rn(d3.mean(d.map(n => (n.match(/ /g)||[]).length)),2);
result.push({name:b.name, size, min, max, mean, median, common, double, multi});
});
console.table(result);
}
// polyfill for composedPath
function getComposedPath(node) {
let parent;
if (node.parentNode) parent = node.parentNode;
else if (node.host) parent = node.host;
else if (node.defaultView) parent = node.defaultView;
if (parent !== undefined) return [node].concat(getComposedPath(parent));
return [node];
};
// get next unused id
function getNextId(core, i = 1) {
while (document.getElementById(core+i)) i++;
return core + i;
}
function getAbsolutePath(href) {
if (!href) return "";
var link = document.createElement("a");
link.href = href;
return link.href;
}

82
modules/voronoi.js Normal file
View file

@ -0,0 +1,82 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global.Voronoi = factory());
}(this, (function () { 'use strict';
var Voronoi = function Voronoi(delaunay, points, pointsN) {
const cells = {v: [], c: [], b: []}; // voronoi cells: v = cell vertices, c = adjacent cells, b = near-border cell
const vertices = {p: [], v: [], c: []}; // cells vertices: p = vertex coordinates, v = neighboring vertices, c = adjacent cells
for (let e=0; e < delaunay.triangles.length; e++) {
const p = delaunay.triangles[nextHalfedge(e)];
if (p < pointsN && !cells.c[p]) {
const edges = edgesAroundPoint(e);
cells.v[p] = edges.map(e => triangleOfEdge(e)); // cell: adjacent vertex
cells.c[p] = edges.map(e => delaunay.triangles[e]).filter(c => c < pointsN); // cell: adjacent valid cells
cells.b[p] = edges.length > cells.c[p].length ? 1 : 0; // cell: is border
}
const t = triangleOfEdge(e);
if (!vertices.p[t]) {
vertices.p[t] = triangleCenter(t); // vertex: coordinates
vertices.v[t] = trianglesAdjacentToTriangle(t); // vertex: adjacent vertices
vertices.c[t] = pointsOfTriangle(t); // vertex: adjacent cells
}
}
function pointsOfTriangle(t) {
return edgesOfTriangle(t).map(e => delaunay.triangles[e]);
}
function trianglesAdjacentToTriangle(t) {
let triangles = [];
for (let e of edgesOfTriangle(t)) {
let opposite = delaunay.halfedges[e];
triangles.push(triangleOfEdge(opposite));
}
return triangles;
}
function edgesAroundPoint(start) {
let result = [], incoming = start;
do {
result.push(incoming);
const outgoing = nextHalfedge(incoming);
incoming = delaunay.halfedges[outgoing];
} while (incoming !== -1 && incoming !== start && result.length < 20);
return result;
}
function triangleCenter(t) {
let vertices = pointsOfTriangle(t).map(p => points[p]);
return circumcenter(vertices[0], vertices[1], vertices[2]);
}
return {cells, vertices}
}
function edgesOfTriangle(t) {return [3*t, 3*t+1, 3*t+2];}
function triangleOfEdge(e) {return Math.floor(e/3);}
function nextHalfedge(e) {return (e % 3 === 2) ? e-2 : e+1;}
function prevHalfedge(e) {return (e % 3 === 0) ? e+2 : e-1;}
function circumcenter(a, b, c) {
let ad = a[0]*a[0] + a[1]*a[1],
bd = b[0]*b[0] + b[1]*b[1],
cd = c[0]*c[0] + c[1]*c[1];
let D = 2 * (a[0] * (b[1] - c[1]) + b[0] * (c[1] - a[1]) + c[0] * (a[1] - b[1]));
return [
Math.floor(1/D * (ad * (b[1] - c[1]) + bd * (c[1] - a[1]) + cd * (a[1] - b[1]))),
Math.floor(1/D * (ad * (c[0] - b[0]) + bd * (a[0] - c[0]) + cd * (b[0] - a[0])))
];
}
return Voronoi;
})));

10547
script.js

File diff suppressed because one or more lines are too long