// Heighmap Template: Volcano function templateVolcano(mod) { addMountain(); modifyHeights("all", 10, 1); addHill(5, 0.35); addRange(3); addRange(-4); } // Heighmap Template: High Island function templateHighIsland(mod) { addMountain(); modifyHeights("all", 10, 1); addRange(6); addHill(12, 0.25); addRange(-3); modifyHeights("land", 0, 0.75); addPit(1); addHill(3, 0.15); } // Heighmap Template: Low Island function templateLowIsland(mod) { addMountain(); modifyHeights("all", 10, 1); smoothHeights(2); addRange(2); addHill(4, 0.4); addHill(12, 0.2); addRange(-8); modifyHeights("land", 0, 0.35); } // Heighmap Template: Continents function templateContinents(mod) { addMountain(); modifyHeights("all", 10, 1); addHill(30, 0.25); const count = Math.ceil(Math.random() * 4 + 4); addStrait(count); addPit(10); addRange(-10); modifyHeights("land", 0, 0.6); smoothHeights(2); addRange(3); } // Heighmap Template: Archipelago function templateArchipelago(mod) { addMountain(); modifyHeights("all", 10, 1); addHill(12, 0.15); addRange(8); const count = Math.ceil(Math.random() * 2 + 2); addStrait(count); addRange(-15); addPit(10); modifyHeights("land", -5, 0.7); smoothHeights(3); } // Heighmap Template: Atoll function templateAtoll(mod) { addMountain(); modifyHeights("all", 10, 1); addHill(2, 0.35); addRange(2); smoothHeights(1); modifyHeights("27-100", 0, 0.1); } // Heighmap Template: Mainland function templateMainland(mod) { addMountain(); modifyHeights("all", 10, 1); addHill(30, 0.2); addRange(10); addPit(20); addHill(10, 0.15); addRange(-10); modifyHeights("land", 0, 0.4); addRange(10); smoothHeights(3); } // Heighmap Template: Peninsulas function templatePeninsulas(mod) { addMountain(); modifyHeights("all", 15, 1); addHill(30, 0); addRange(5); addPit(15); const count = Math.ceil(Math.random() * 5 + 15); addStrait(count); } function addMountain() { const x = Math.floor(Math.random() * graphWidth / 3 + graphWidth / 3); const y = Math.floor(Math.random() * graphHeight * 0.2 + graphHeight * 0.4); const cell = diagram.find(x, y).index; const height = Math.random() * 10 + 90; // 90-99 add(cell, "mountain", height); } // place with shift 0-0.5 function addHill(count, shift) { for (let c = 0; c < count; c++) { let limit = 0, cell, height; do { height = Math.random() * 40 + 10; // 10-50 const x = Math.floor(Math.random() * graphWidth * (1 - shift * 2) + graphWidth * shift); const y = Math.floor(Math.random() * graphHeight * (1 - shift * 2) + graphHeight * shift); cell = diagram.find(x, y).index; limit++; } while (heights[cell] + height > 90 && limit < 100); add(cell, "hill", height); } } function add(start, type, height) { const session = Math.ceil(Math.random() * 1e5); let radius; let hRadius; let mRadius; switch (+graphSize) { case 1: hRadius = 0.991; mRadius = 0.91; break; case 2: hRadius = 0.9967; mRadius = 0.951; break; case 3: hRadius = 0.999; mRadius = 0.975; break; case 4: hRadius = 0.9994; mRadius = 0.98; break; } radius = type === "mountain" ? mRadius : hRadius; const queue = [start]; if (type === "mountain") heights[start] = height; for (let i=0; i < queue.length && height >= 1; i++) { if (type === "mountain") {height = heights[queue[i]] * radius - height / 100;} else {height *= radius;} cells[queue[i]].neighbors.forEach(function(e) { if (cells[e].used === session) return; const mod = Math.random() * 0.2 + 0.9; // 0.9-1.1 random factor heights[e] += height * mod; if (heights[e] > 100) heights[e] = 100; cells[e].used = session; queue.push(e); }); } } function addRange(mod, height, from, to) { const session = Math.ceil(Math.random() * 100000); const count = Math.abs(mod); let range = []; for (let c = 0; c < count; c++) { range = []; let diff = 0, start = from, end = to; if (!start || !end) { do { const xf = Math.floor(Math.random() * (graphWidth * 0.7)) + graphWidth * 0.15; const yf = Math.floor(Math.random() * (graphHeight * 0.6)) + graphHeight * 0.2; start = diagram.find(xf, yf).index; const xt = Math.floor(Math.random() * (graphWidth * 0.7)) + graphWidth * 0.15; const yt = Math.floor(Math.random() * (graphHeight * 0.6)) + graphHeight * 0.2; end = diagram.find(xt, yt).index; diff = Math.hypot(xt - xf, yt - yf); } while (diff < 150 / graphSize || diff > 300 / graphSize) } if (start && end) { for (let l = 0; start != end && l < 10000; l++) { let min = 10000; cells[start].neighbors.forEach(function(e) { diff = Math.hypot(cells[end].data[0] - cells[e].data[0],cells[end].data[1] - cells[e].data[1]); if (Math.random() > 0.8) diff = diff / 2; if (diff < min) {min = diff, start = e;} }); range.push(start); } } const change = height ? height : Math.random() * 10 + 10; range.map(function(r) { let rnd = Math.random() * 0.4 + 0.8; if (mod > 0) heights[r] += change * rnd; else if (heights[r] >= 10) {heights[r] -= change * rnd;} cells[r].neighbors.forEach(function(e) { if (cells[e].used === session) return; cells[e].used = session; rnd = Math.random() * 0.4 + 0.8; const ch = change / 2 * rnd; if (mod > 0) {heights[e] += ch;} else if (heights[e] >= 10) {heights[e] -= ch;} if (heights[e] > 100) heights[e] = mod > 0 ? 100 : 5; }); if (heights[r] > 100) heights[r] = mod > 0 ? 100 : 5; }); } return range; } function addStrait(width) { const session = Math.ceil(Math.random() * 100000); const top = Math.floor(Math.random() * graphWidth * 0.35 + graphWidth * 0.3); const bottom = Math.floor((graphWidth - top) - (graphWidth * 0.1) + (Math.random() * graphWidth * 0.2)); let start = diagram.find(top, graphHeight * 0.1).index; const end = diagram.find(bottom, graphHeight * 0.9).index; let range = []; for (let l = 0; start !== end && l < 1000; l++) { let min = 10000; // dummy value cells[start].neighbors.forEach(function(e) { let diff = Math.hypot(cells[end].data[0] - cells[e].data[0], cells[end].data[1] - cells[e].data[1]); if (Math.random() > 0.8) {diff = diff / 2} if (diff < min) {min = diff; start = e;} }); range.push(start); } const query = []; for (; width > 0; width--) { range.map(function(r) { cells[r].neighbors.forEach(function(e) { if (cells[e].used === session) {return;} cells[e].used = session; query.push(e); heights[e] *= 0.23; if (heights[e] > 100 || heights[e] < 5) heights[e] = 5; }); range = query.slice(); }); } } function addPit(count, height, cell) { const session = Math.ceil(Math.random() * 1e5); for (let c = 0; c < count; c++) { let change = height ? height + 10 : Math.random() * 10 + 20; let start = cell; if (!start) { const lowlands = $.grep(cells, function(e) {return (heights[e.index] >= 20);}); if (!lowlands.length) return; const rnd = Math.floor(Math.random() * lowlands.length); start = lowlands[rnd].index; } let query = [start],newQuery= []; // depress pit center heights[start] -= change; if (heights[start] < 5 || heights[start] > 100) heights[start] = 5; cells[start].used = session; for (let i = 1; i < 10000; i++) { const rnd = Math.random() * 0.4 + 0.8; change -= i / 0.6 * rnd; if (change < 1) break; query.map(function(p) { cells[p].neighbors.forEach(function(e) { if (cells[e].used === session) return; cells[e].used = session; if (Math.random() > 0.8) return; newQuery.push(e); heights[e] -= change; if (heights[e] < 5 || heights[e] > 100) heights[e] = 5; }); }); query = newQuery.slice(); newQuery = []; } } } // Modify heights adding or multiplying by value function modifyHeights(range, add, mult) { function modify(v) { if (add) v += add; if (mult !== 1) { if (mult === "^2") mult = (v - 20) / 100; if (mult === "^3") mult = ((v - 20) * (v - 20)) / 100; if (range === "land") {v = 20 + (v - 20) * mult;} else {v *= mult;} } if (v < 0) v = 0; if (v > 100) v = 100; return v; } const limMin = range === "land" ? 20 : range === "all" ? 0 : +range.split("-")[0]; const limMax = range === "land" || range === "all" ? 100 : +range.split("-")[1]; for (let i=0; i < heights.length; i++) { if (heights[i] < limMin || heights[i] > limMax) continue; heights[i] = modify(heights[i]); } } // Smooth heights using mean of neighbors function smoothHeights(fraction) { const fr = fraction || 2; for (let i=0; i < heights.length; i++) { const nHeights = [heights[i]]; cells[i].neighbors.forEach(function(e) {nHeights.push(heights[e]);}); heights[i] = (heights[i] * (fr - 1) + d3.mean(nHeights)) / fr; } } // Randomize heights a bit function disruptHeights() { for (let i=0; i < heights.length; i++) { if (heights[i] < 18) continue; if (Math.random() < 0.5) continue; heights[i] += 2 - Math.random() * 4; } } // Mark features (ocean, lakes, islands) function markFeatures() { console.time("markFeatures"); Math.seedrandom(seed); // reset seed to get the same result on heightmap edit for (let i=0, queue=[0]; queue.length > 0; i++) { const cell = cells[queue[0]]; cell.fn = i; // feature number const land = heights[queue[0]] >= 20; let border = cell.type === "border"; if (border && land) cell.ctype = 2; while (queue.length) { const q = queue.pop(); if (cells[q].type === "border") { border = true; if (land) cells[q].ctype = 2; } cells[q].neighbors.forEach(function(e) { const eLand = heights[e] >= 20; if (land === eLand && cells[e].fn === undefined) { cells[e].fn = i; queue.push(e); } if (land && !eLand) { cells[q].ctype = 2; cells[e].ctype = -1; cells[q].harbor = cells[q].harbor ? cells[q].harbor + 1 : 1; } }); } features.push({i, land, border}); // find unmarked cell for (let c=0; c < cells.length; c++) { if (cells[c].fn === undefined) { queue[0] = c; break; } } } console.timeEnd("markFeatures"); } // remove closed lakes near ocean function reduceClosedLakes() { console.time("reduceClosedLakes"); const fs = JSON.parse(JSON.stringify(features)); let lakesInit = lakesNow = features.reduce(function(s, f) { return !f.land && !f.border ? s + 1 : s; }, 0); for (let c=0; c < cells.length && lakesNow > 0; c++) { if (heights[c] < 20) continue; // not land if (cells[c].ctype !== 2) continue; // not near water let ocean = null, lake = null; // find land cells with lake and ocean nearby cells[c].neighbors.forEach(function(n) { if (heights[n] >= 20) return; const fn = cells[n].fn; if (features[fn].border !== false) ocean = fn; if (fs[fn].border === false) lake = fn; }); // if found, make it water and turn lake to ocean if (ocean !== null && lake !== null) { //debug.append("circle").attr("cx", cells[c].data[0]).attr("cy", cells[c].data[1]).attr("r", 2).attr("fill", "red"); lakesNow --; fs[lake].border = ocean; heights[c] = 19; cells[c].fn = ocean; cells[c].ctype = -1; cells[c].neighbors.forEach(function(e) {if (heights[e] >= 20) cells[e].ctype = 2;}); } } if (lakesInit === lakesNow) return; // nothing was changed for (let i=0; i < cells.length; i++) { if (heights[i] >= 20) continue; // not water const fn = cells[i].fn; if (fs[fn].border !== features[fn].border) { cells[i].fn = fs[fn].border; //debug.append("circle").attr("cx", cells[i].data[0]).attr("cy", cells[i].data[1]).attr("r", 1).attr("fill", "blue"); } } console.timeEnd("reduceClosedLakes"); } function drawOcean() { console.time("drawOcean"); let limits = []; let odd = 0.8; // initial odd for ocean layer is 80% // Define type of ocean cells based on cell distance form land let frontier = $.grep(cells, function(e) {return e.ctype === -1;}); if (Math.random() < odd) {limits.push(-1); odd = 0.2;} for (let c = -2; frontier.length > 0 && c > -10; c--) { if (Math.random() < odd) {limits.unshift(c); odd = 0.2;} else {odd += 0.2;} frontier.map(function(i) { i.neighbors.forEach(function(e) { if (!cells[e].ctype) cells[e].ctype = c; }); }); frontier = $.grep(cells, function(e) {return e.ctype === c;}); } if (outlineLayersInput.value === "none") return; if (outlineLayersInput.value !== "random") limits = outlineLayersInput.value.split(","); // Define area edges const opacity = rn(0.4 / limits.length, 2); for (let l=0; l < limits.length; l++) { const edges = []; const lim = +limits[l]; for (let i = 0; i < cells.length; i++) { if (cells[i].ctype < lim || cells[i].ctype === undefined) continue; if (cells[i].ctype > lim && cells[i].type !== "border") continue; const cell = diagram.cells[i]; cell.halfedges.forEach(function(e) { const edge = diagram.edges[e]; const start = edge[0].join(" "); const end = edge[1].join(" "); if (edge.left && edge.right) { const ea = edge.left.index === i ? edge.right.index : edge.left.index; if (cells[ea].ctype < lim) edges.push({start, end}); } else { edges.push({start, end}); } }); } lineGen.curve(d3.curveBasis); let relax = 0.8 - l / 10; if (relax < 0.2) relax = 0.2; const line = getContinuousLine(edges, 0, relax); oceanLayers.append("path").attr("d", line).attr("fill", "#ecf2f9").style("opacity", opacity); } console.timeEnd("drawOcean"); } // recalculate Voronoi Graph to pack cells function reGraph() { console.time("reGraph"); const tempCells = [], newPoints = []; // to store new data // get average precipitation based on graph size const avPrec = precInput.value / 5000; const smallLakesMax = 500; let smallLakes = 0; const evaporation = 2; cells.map(function(i, d) { let height = i.height || heights[d]; if (height > 100) height = 100; const pit = i.pit; const ctype = i.ctype; if (ctype !== -1 && ctype !== -2 && height < 20) return; // exclude all deep ocean points const x = rn(i.data[0],1), y = rn(i.data[1],1); const fn = i.fn; const harbor = i.harbor; let lake = i.lake; // mark potential cells for small lakes to add additional point there if (smallLakes < smallLakesMax && !lake && pit > evaporation && ctype !== 2) { lake = 2; smallLakes++; } const region = i.region; // handle value for edit heightmap mode only const culture = i.culture; // handle value for edit heightmap mode only let copy = $.grep(newPoints, function(e) {return (e[0] == x && e[1] == y);}); if (!copy.length) { newPoints.push([x, y]); tempCells.push({index:tempCells.length, data:[x, y],height, pit, ctype, fn, harbor, lake, region, culture}); } // add additional points for cells along coast if (ctype === 2 || ctype === -1) { if (i.type === "border") return; if (!features[fn].land && !features[fn].border) return; i.neighbors.forEach(function(e) { if (cells[e].ctype === ctype) { let x1 = (x * 2 + cells[e].data[0]) / 3; let y1 = (y * 2 + cells[e].data[1]) / 3; x1 = rn(x1, 1), y1 = rn(y1, 1); copy = $.grep(newPoints, function(e) {return e[0] === x1 && e[1] === y1;}); if (copy.length) return; newPoints.push([x1, y1]); tempCells.push({index:tempCells.length, data:[x1, y1],height, pit, ctype, fn, harbor, lake, region, culture}); } }); } if (lake === 2) { // add potential small lakes polygons[i.index].forEach(function(e) { if (Math.random() > 0.8) return; let rnd = Math.random() * 0.6 + 0.8; const x1 = rn((e[0] * rnd + i.data[0]) / (1 + rnd), 2); rnd = Math.random() * 0.6 + 0.8; const y1 = rn((e[1] * rnd + i.data[1]) / (1 + rnd), 2); copy = $.grep(newPoints, function(c) {return x1 === c[0] && y1 === c[1];}); if (copy.length) return; newPoints.push([x1, y1]); tempCells.push({index:tempCells.length, data:[x1, y1],height, pit, ctype, fn, region, culture}); }); } }); console.log( "small lakes candidates: " + smallLakes); cells = tempCells; // use tempCells as the only cells array calculateVoronoi(newPoints); // recalculate Voronoi diagram using new points let gridPath = ""; // store grid as huge single path string cells.map(function(i, d) { if (i.height >= 20) { // calc cell area i.area = rn(Math.abs(d3.polygonArea(polygons[d])), 2); const prec = rn(avPrec * i.area, 2); i.flux = i.lake ? prec * 10 : prec; } const neighbors = []; // re-detect neighbors diagram.cells[d].halfedges.forEach(function(e) { const edge = diagram.edges[e]; if (edge.left === undefined || edge.right === undefined) { if (i.height >= 20) i.ctype = 99; // border cell return; } const ea = edge.left.index === d ? edge.right.index : edge.left.index; neighbors.push(ea); if (d < ea && i.height >= 20 && i.lake !== 1 && cells[ea].height >= 20 && cells[ea].lake !== 1) { gridPath += "M" + edge[0][0] + "," + edge[0][1] + "L" + edge[1][0] + "," + edge[1][1]; } }); i.neighbors = neighbors; if (i.region === undefined) delete i.region; if (i.culture === undefined) delete i.culture; }); grid.append("path").attr("d", gridPath); console.timeEnd("reGraph"); } // redraw all cells for Customization 1 mode function mockHeightmap() { let landCells = 0; $("#landmass").empty(); const limit = renderOcean.checked ? 1 : 20; for (let i=0; i < heights.length; i++) { if (heights[i] < limit) continue; if (heights[i] > 100) heights[i] = 100; const clr = color(1 - heights[i] / 100); landmass.append("path").attr("id", "cell"+i) .attr("d", "M" + polygons[i].join("L") + "Z") .attr("fill", clr).attr("stroke", clr); } } $("#renderOcean").click(mockHeightmap); // draw or update all cells function updateHeightmap() { const limit = renderOcean.checked ? 1 : 20; for (let i=0; i < heights.length; i++) { if (heights[i] > 100) heights[i] = 100; let cell = landmass.select("#cell"+i); const clr = color(1 - heights[i] / 100); if (cell.size()) { if (heights[i] < limit) {cell.remove();} else {cell.attr("fill", clr).attr("stroke", clr);} } else if (heights[i] >= limit) { cell = landmass.append("path").attr("id", "cell"+i) .attr("d", "M" + polygons[i].join("L") + "Z") .attr("fill", clr).attr("stroke", clr); } } } // draw or update cells from the selection function updateHeightmapSelection(selection) { if (selection === undefined) return; const limit = renderOcean.checked ? 1 : 20; selection.map(function(s) { if (heights[s] > 100) heights[s] = 100; let cell = landmass.select("#cell"+s); const clr = color(1 - heights[s] / 100); if (cell.size()) { if (heights[s] < limit) {cell.remove();} else {cell.attr("fill", clr).attr("stroke", clr);} } else if (heights[s] >= limit) { cell = landmass.append("path").attr("id", "cell"+s) .attr("d", "M" + polygons[s].join("L") + "Z") .attr("fill", clr).attr("stroke", clr); } }); } function updateHistory() { let landCells = 0; // count number of land cells if (renderOcean.checked) { landCells = heights.reduce(function(s, v) {if (v >= 20) {return s + 1;} else {return s;}}, 0); } else { landCells = landmass.selectAll("*").size(); } history = history.slice(0, historyStage); history[historyStage] = heights.slice(); historyStage++; undo.disabled = templateUndo.disabled = historyStage <= 1; redo.disabled = templateRedo.disabled = true; const landMean = Math.trunc(d3.mean(heights)); const landRatio = rn(landCells / heights.length * 100); landmassCounter.innerHTML = landCells; landmassRatio.innerHTML = landRatio; landmassAverage.innerHTML = landMean; // if perspective view dialog is opened, update it if ($("#perspectivePanel").is(":visible")) drawPerspective(); } // restoreHistory function restoreHistory(step) { historyStage = step; redo.disabled = templateRedo.disabled = historyStage >= history.length; undo.disabled = templateUndo.disabled = historyStage <= 1; if (history[historyStage - 1] === undefined) return; heights = history[historyStage - 1].slice(); updateHeightmap(); } // restart history from 1st step function restartHistory() { history = []; historyStage = 0; redo.disabled = templateRedo.disabled = true; undo.disabled = templateUndo.disabled = true; updateHistory(); } // Detect and draw the coasline function drawCoastline() { console.time('drawCoastline'); Math.seedrandom(seed); // reset seed to get the same result on heightmap edit const shape = defs.append("mask").attr("id", "shape").attr("fill", "black").attr("x", 0).attr("y", 0).attr("width", "100%").attr("height", "100%"); $("#landmass").empty(); let minX = graphWidth, maxX = 0; // extreme points let minXedge, maxXedge; // extreme edges const oceanEdges = [],lakeEdges = []; for (let i=0; i < land.length; i++) { const id = land[i].index, cell = diagram.cells[id]; const f = land[i].fn; land[i].height = Math.trunc(land[i].height); if (!oceanEdges[f]) {oceanEdges[f] = []; lakeEdges[f] = [];} cell.halfedges.forEach(function(e) { const edge = diagram.edges[e]; const start = edge[0].join(" "); const end = edge[1].join(" "); if (edge.left && edge.right) { const ea = edge.left.index === id ? edge.right.index : edge.left.index; cells[ea].height = Math.trunc(cells[ea].height); if (cells[ea].height < 20) { cells[ea].ctype = -1; if (land[i].ctype !== 1) { land[i].ctype = 1; // mark coastal land cells // move cell point closer to coast const x = (land[i].data[0] + cells[ea].data[0]) / 2; const y = (land[i].data[1] + cells[ea].data[1]) / 2; land[i].haven = ea; // harbor haven (oposite water cell) land[i].coastX = rn(x + (land[i].data[0] - x) * 0.1, 1); land[i].coastY = rn(y + (land[i].data[1] - y) * 0.1, 1); land[i].data[0] = rn(x + (land[i].data[0] - x) * 0.5, 1); land[i].data[1] = rn(y + (land[i].data[1] - y) * 0.5, 1); } if (features[cells[ea].fn].border) { oceanEdges[f].push({start, end}); // island extreme points if (edge[0][0] < minX) {minX = edge[0][0]; minXedge = edge[0]} if (edge[1][0] < minX) {minX = edge[1][0]; minXedge = edge[1]} if (edge[0][0] > maxX) {maxX = edge[0][0]; maxXedge = edge[0]} if (edge[1][0] > maxX) {maxX = edge[1][0]; maxXedge = edge[1]} } else { const l = cells[ea].fn; if (!lakeEdges[f][l]) lakeEdges[f][l] = []; lakeEdges[f][l].push({start, end}); } } } else { oceanEdges[f].push({start, end}); } }); } for (let f = 0; f < features.length; f++) { if (!oceanEdges[f]) continue; if (!oceanEdges[f].length && lakeEdges[f].length) { const m = lakeEdges[f].indexOf(d3.max(lakeEdges[f])); oceanEdges[f] = lakeEdges[f][m]; lakeEdges[f][m] = []; } lineGen.curve(d3.curveCatmullRomClosed.alpha(0.1)); const oceanCoastline = getContinuousLine(oceanEdges[f],3, 0); if (oceanCoastline) { shape.append("path").attr("d", oceanCoastline).attr("fill", "white"); // draw the mask coastline.append("path").attr("d", oceanCoastline); // draw the coastline } lineGen.curve(d3.curveBasisClosed); lakeEdges[f].forEach(function(l) { const lakeCoastline = getContinuousLine(l, 3, 0); if (lakeCoastline) { shape.append("path").attr("d", lakeCoastline).attr("fill", "black"); // draw the mask lakes.append("path").attr("d", lakeCoastline); // draw the lakes } }); } landmass.append("rect").attr("x", 0).attr("y", 0).attr("width", graphWidth).attr("height", graphHeight); // draw the landmass drawDefaultRuler(minXedge, maxXedge); console.timeEnd('drawCoastline'); } // draw default scale bar function drawScaleBar() { if ($("#scaleBar").hasClass("hidden")) return; // no need to re-draw hidden element svg.select("#scaleBar").remove(); // fully redraw every time // get size const size = +barSize.value; const dScale = distanceScale.value; const unit = distanceUnit.value; const scaleBar = svg.append("g").attr("id", "scaleBar") .on("click", editScale) .on("mousemove", function () { tip("Click to open Scale Editor, drag to move"); }) .call(d3.drag().on("start", elementDrag)); const init = 100; // actual length in pixels if scale, dScale and size = 1; 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 const x = 0, y = 0; // initial position scaleBar.append("line").attr("x1", x+0.5).attr("y1", y).attr("x2", x+l+size-0.5).attr("y2", y).attr("stroke-width", size).attr("stroke", "white"); scaleBar.append("line").attr("x1", x).attr("y1", y + size).attr("x2", x+l+size).attr("y2", y + size).attr("stroke-width", size).attr("stroke", "#3d3d3d"); const dash = size + " " + rn(l / 5 - size, 2); scaleBar.append("line").attr("x1", x).attr("y1", y).attr("x2", x+l+size).attr("y2", y) .attr("stroke-width", rn(size * 3, 2)).attr("stroke-dasharray", dash).attr("stroke", "#3d3d3d"); // big scale for (let b = 0; b < 6; b++) { const value = rn(b * l / 5, 2); const label = rn(value * dScale / scale); if (b === 5) { scaleBar.append("text").attr("x", x + value).attr("y", y - 2 * size).attr("font-size", rn(5 * size, 1)).text(label + " " + unit); } else { scaleBar.append("text").attr("x", x + value).attr("y", y - 2 * size).attr("font-size", rn(5 * size, 1)).text(label); } } if (barLabel.value !== "") { scaleBar.append("text").attr("x", x + (l+1) / 2).attr("y", y + 2 * size) .attr("dominant-baseline", "text-before-edge") .attr("font-size", rn(5 * size, 1)).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(); } // draw default ruler measiring land x-axis edges function drawDefaultRuler(minXedge, maxXedge) { const rulerNew = ruler.append("g").attr("class", "linear").call(d3.drag().on("start", elementDrag)); if (!minXedge) minXedge = [0, 0]; if (!maxXedge) maxXedge = [svgWidth, svgHeight]; const x1 = rn(minXedge[0],2), y1 = rn(minXedge[1],2), x2 = rn(maxXedge[0],2), y2 = rn(maxXedge[1],2); rulerNew.append("line").attr("x1", x1).attr("y1", y1).attr("x2", x2).attr("y2", y2).attr("class", "white"); rulerNew.append("line").attr("x1", x1).attr("y1", y1).attr("x2", x2).attr("y2", y2).attr("class", "gray").attr("stroke-dasharray", 10); rulerNew.append("circle").attr("r", 2).attr("cx", x1).attr("cy", y1).attr("stroke-width", 0.5).attr("data-edge", "left").call(d3.drag().on("drag", rulerEdgeDrag)); rulerNew.append("circle").attr("r", 2).attr("cx", x2).attr("cy", y2).attr("stroke-width", 0.5).attr("data-edge", "rigth").call(d3.drag().on("drag", rulerEdgeDrag)); const x0 = rn((x1 + x2) / 2, 2), y0 = rn((y1 + y2) / 2, 2); rulerNew.append("circle").attr("r", 1.2).attr("cx", x0).attr("cy", y0).attr("stroke-width", 0.3).attr("class", "center").call(d3.drag().on("start", rulerCenterDrag)); const angle = Math.atan2(y2 - y1, x2 - x1) * 180 / Math.PI; const tr = "rotate(" + angle + " " + x0 + " " + y0 +")"; const dist = rn(Math.hypot(x1 - x2, y1 - y2)); const label = rn(dist * distanceScale.value) + " " + distanceUnit.value; rulerNew.append("text").attr("x", x0).attr("y", y0).attr("dy", -1).attr("transform", tr).attr("data-dist", dist).text(label).on("click", removeParent).attr("font-size", 10); } // drag any element changing transform function elementDrag() { const el = d3.select(this); const tr = parseTransform(el.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)}) rotate(${tr[2]} ${tr[3]} ${tr[4]})`; el.attr("transform", transform); const pp = this.parentNode.parentNode.id; if (pp === "burgIcons" || pp === "burgLabels") { tip('Use dragging for fine-tuning only, to move burg to a different cell use "Relocate" button'); } if (pp === "labels") { // also transform curve control circle debug.select("circle").attr("transform", transform); } }); d3.event.on("end", function() { // remember scaleBar bottom-right position if (el.attr("id") === "scaleBar") { const xEnd = d3.event.x, yEnd = d3.event.y; const diff = Math.abs(dx - xEnd) + Math.abs(dy - yEnd); if (diff > 5) { const bbox = el.node().getBoundingClientRect(); sessionStorage.setItem("scaleBar", [bbox.right, bbox.bottom]); } } }); } // draw ruler circles and update label function rulerEdgeDrag() { const group = d3.select(this.parentNode); const edge = d3.select(this).attr("data-edge"); const x = d3.event.x, y = d3.event.y; let x0, y0; d3.select(this).attr("cx", x).attr("cy", y); const line = group.selectAll("line"); if (edge === "left") { line.attr("x1", x).attr("y1", y); x0 = +line.attr("x2"), y0 = +line.attr("y2"); } else { line.attr("x2", x).attr("y2", y); x0 = +line.attr("x1"), y0 = +line.attr("y1"); } const xc = rn((x + x0) / 2, 2), yc = rn((y + y0) / 2, 2); group.select(".center").attr("cx", xc).attr("cy", yc); const dist = rn(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 tr = "rotate(" + angle + " " + xc + " " + yc + ")"; group.select("text").attr("x", xc).attr("y", yc).attr("transform", tr).attr("data-dist", dist).text(label); } // draw ruler center point to split ruler into 2 parts function rulerCenterDrag() { let xc1, yc1, xc2, yc2; const group = d3.select(this.parentNode); // current ruler group let x = d3.event.x, y = d3.event.y; // current coords const line = group.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 rulerNew = ruler.insert("g", ":first-child"); rulerNew.attr("transform", group.attr("transform")).call(d3.drag().on("start", elementDrag)); const factor = rn(1 / Math.pow(scale, 0.3), 1); rulerNew.append("line").attr("class", "white").attr("stroke-width", factor); const dash = +group.select(".gray").attr("stroke-dasharray"); rulerNew.append("line").attr("class", "gray").attr("stroke-dasharray", dash).attr("stroke-width", factor); rulerNew.append("text").attr("dy", -1).on("click", removeParent).attr("font-size", 10 * factor).attr("stroke-width", factor); d3.event.on("drag", function() { x = d3.event.x, y = d3.event.y; d3.select(this).attr("cx", x).attr("cy", y); // change first part line.attr("x1", x1).attr("y1", y1).attr("x2", x).attr("y2", y); 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); let tr = "rotate(" + rn(atan * 180 / Math.PI, 3) + " " + xc1 + " " + yc1 + ")"; group.select("text").attr("x", xc1).attr("y", yc1).attr("transform", tr).attr("data-dist", dist).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); tr = "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", tr).attr("data-dist", dist).text(label); }); d3.event.on("end", function() { // circles for 1st part group.selectAll("circle").remove(); group.append("circle").attr("cx", x1).attr("cy", y1).attr("r", 2 * factor).attr("stroke-width", 0.5 * factor).attr("data-edge", "left").call(d3.drag().on("drag", rulerEdgeDrag)); group.append("circle").attr("cx", x).attr("cy", y).attr("r", 2 * factor).attr("stroke-width", 0.5 * factor).attr("data-edge", "rigth").call(d3.drag().on("drag", rulerEdgeDrag)); group.append("circle").attr("cx", xc1).attr("cy", yc1).attr("r", 1.2 * factor).attr("stroke-width", 0.3 * factor).attr("class", "center").call(d3.drag().on("start", rulerCenterDrag)); // circles for 2nd part rulerNew.append("circle").attr("cx", x).attr("cy", y).attr("r", 2 * factor).attr("stroke-width", 0.5 * factor).attr("data-edge", "left").call(d3.drag().on("drag", rulerEdgeDrag)); rulerNew.append("circle").attr("cx", x2).attr("cy", y2).attr("r", 2 * factor).attr("stroke-width", 0.5 * factor).attr("data-edge", "rigth").call(d3.drag().on("drag", rulerEdgeDrag)); rulerNew.append("circle").attr("cx", xc2).attr("cy", yc2).attr("r", 1.2 * factor).attr("stroke-width", 0.3 * factor).attr("class", "center").call(d3.drag().on("start", rulerCenterDrag)); }); } function opisometerEdgeDrag() { const el = d3.select(this); const x0 = +el.attr("cx"), y0 = +el.attr("cy"); const group = d3.select(this.parentNode); const curve = group.select(".white"); const curveGray = group.select(".gray"); const text = group.select("text"); const points = JSON.parse(text.attr("data-points")); if (x0 === points[0].scX && y0 === points[0].scY) {points.reverse();} d3.event.on("drag", function() { const x = d3.event.x, y = d3.event.y; el.attr("cx", x).attr("cy", y); const l = points[points.length - 1]; const diff = Math.hypot(l.scX - x, l.scY - y); if (diff > 5) {points.push({scX: x, scY: y});} else {return;} lineGen.curve(d3.curveBasis); const d = round(lineGen(points)); curve.attr("d", d); curveGray.attr("d", d); const dist = rn(curve.node().getTotalLength()); const label = rn(dist * distanceScale.value) + " " + distanceUnit.value; text.attr("x", x).attr("y", y).text(label); }); d3.event.on("end", function() { const dist = rn(curve.node().getTotalLength()); const c = curve.node().getPointAtLength(dist / 2); const p = curve.node().getPointAtLength((dist / 2) - 1); const label = rn(dist * distanceScale.value) + " " + distanceUnit.value; 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 tr = "rotate(" + angle + " " + c.x + " " + c.y + ")"; text.attr("data-points", JSON.stringify(points)).attr("data-dist", dist).attr("x", c.x).attr("y", c.y).attr("transform", tr).text(label); }); } function getContinuousLine(edges, indention, relax) { let line = ""; if (edges.length < 3) return ""; while (edges.length > 2) { let edgesOrdered = []; // to store points in a correct order let start = edges[0].start; let end = edges[0].end; edges.shift(); let spl = start.split(" "); edgesOrdered.push({scX: +spl[0],scY: +spl[1]}); spl = end.split(" "); edgesOrdered.push({scX: +spl[0],scY: +spl[1]}); let x0 = +spl[0],y0 = +spl[1]; for (let i = 0; end !== start && i < 100000; i++) { let next = null, index = null; for (let e = 0; e < edges.length; e++) { const edge = edges[e]; if (edge.start == end || edge.end == end) { next = edge; end = next.start == end ? next.end : next.start; index = e; break; } } if (!next) { console.error("Next edge is not found"); return ""; } spl = end.split(" "); if (indention || relax) { const dist = Math.hypot(+spl[0] - x0, +spl[1] - y0); if (dist >= indention && Math.random() > relax) { edgesOrdered.push({scX: +spl[0],scY: +spl[1]}); x0 = +spl[0],y0 = +spl[1]; } } else { edgesOrdered.push({scX: +spl[0],scY: +spl[1]}); } edges.splice(index, 1); if (i === 100000-1) { console.error("Line not ended, limit reached"); break; } } line += lineGen(edgesOrdered); } return round(line, 1); } // temporary elevate lakes to min neighbors heights to correctly flux the water function elevateLakes() { console.time('elevateLakes'); const lakes = $.grep(cells, function(e, d) {return heights[d] < 20 && !features[e.fn].border;}); lakes.sort(function(a, b) {return heights[b.index] - heights[a.index];}); for (let i=0; i < lakes.length; i++) { const hs = [],id = lakes[i].index; cells[id].height = heights[id]; // use height on object level lakes[i].neighbors.forEach(function(n) { const nHeight = cells[n].height || heights[n]; if (nHeight >= 20) hs.push(nHeight); }); if (hs.length) cells[id].height = d3.min(hs) - 1; if (cells[id].height < 20) cells[id].height = 20; lakes[i].lake = 1; } console.timeEnd('elevateLakes'); } // Depression filling algorithm (for a correct water flux modeling; phase1) function resolveDepressionsPrimary() { console.time('resolveDepressionsPrimary'); land = $.grep(cells, function(e, d) { if (!e.height) e.height = heights[d]; // use height on object level return e.height >= 20; }); land.sort(function(a, b) {return b.height - a.height;}); const limit = 10; for (let l = 0, depression = 1; depression > 0 && l < limit; l++) { depression = 0; for (let i = 0; i < land.length; i++) { const id = land[i].index; if (land[i].type === "border") continue; const hs = land[i].neighbors.map(function(n) {return cells[n].height;}); const minHigh = d3.min(hs); if (cells[id].height <= minHigh) { depression++; land[i].pit = land[i].pit ? land[i].pit + 1 : 1; cells[id].height = minHigh + 2; } } if (l === 0) console.log(" depressions init: " + depression); } console.timeEnd('resolveDepressionsPrimary'); } // Depression filling algorithm (for a correct water flux modeling; phase2) function resolveDepressionsSecondary() { console.time('resolveDepressionsSecondary'); land = $.grep(cells, function(e) {return e.height >= 20;}); land.sort(function(a, b) {return b.height - a.height;}); const limit = 100; for (let l = 0, depression = 1; depression > 0 && l < limit; l++) { depression = 0; for (let i = 0; i < land.length; i++) { if (land[i].ctype === 99) continue; const nHeights = land[i].neighbors.map(function(n) {return cells[n].height}); const minHigh = d3.min(nHeights); if (land[i].height <= minHigh) { depression++; land[i].pit = land[i].pit ? land[i].pit + 1 : 1; land[i].height = Math.trunc(minHigh + 2); } } if (l === 0) console.log(" depressions reGraphed: " + depression); if (l === limit - 1) console.error("Error: resolveDepressions iteration limit"); } console.timeEnd('resolveDepressionsSecondary'); } // restore initial heights if user don't want system to change heightmap function restoreCustomHeights() { land.forEach(function(l) { if (!l.pit) return; l.height = Math.trunc(l.height - l.pit * 2); if (l.height < 20) l.height = 20; }); } function flux() { console.time('flux'); riversData = []; let riverNext = 0; land.sort(function(a, b) {return b.height - a.height;}); for (let i = 0; i < land.length; i++) { const id = land[i].index; const sx = land[i].data[0]; const sy = land[i].data[1]; let fn = land[i].fn; if (land[i].ctype === 99) { if (land[i].river !== undefined) { let x, y; const min = Math.min(sy, graphHeight - sy, sx, graphWidth - sx); if (min === sy) {x = sx; y = 0;} if (min === graphHeight - sy) {x = sx; y = graphHeight;} if (min === sx) {x = 0; y = sy;} if (min === graphWidth - sx) {x = graphWidth; y = sy;} riversData.push({river: land[i].river, cell: id, x, y}); } continue; } if (features[fn].river !== undefined) { if (land[i].river !== features[fn].river) { land[i].river = undefined; land[i].flux = 0; } } let minHeight = 1000, min; land[i].neighbors.forEach(function(e) { if (cells[e].height < minHeight) { minHeight = cells[e].height; min = e; } }); // Define river number if (min !== undefined && land[i].flux > 1) { if (land[i].river === undefined) { // State new River land[i].river = riverNext; riversData.push({river: riverNext, cell: id, x: sx, y: sy}); riverNext += 1; } // Assing existing River to the downhill cell if (cells[min].river == undefined) { cells[min].river = land[i].river; } else { const riverTo = cells[min].river; const iRiver = $.grep(riversData, function (e) { return (e.river == land[i].river); }); const minRiver = $.grep(riversData, function (e) { return (e.river == riverTo); }); let iRiverL = iRiver.length; let minRiverL = minRiver.length; // re-assing river nunber if new part is greater if (iRiverL >= minRiverL) { cells[min].river = land[i].river; iRiverL += 1; minRiverL -= 1; } // mark confluences if (cells[min].height >= 20 && iRiverL > 1 && minRiverL > 1) { if (!cells[min].confluence) { cells[min].confluence = minRiverL-1; } else { cells[min].confluence += minRiverL-1; } } } } if (cells[min].flux) cells[min].flux += land[i].flux; if (land[i].river !== undefined) { const px = cells[min].data[0]; const py = cells[min].data[1]; if (cells[min].height < 20) { // pour water to the sea const x = (px + sx) / 2 + (px - sx) / 10; const y = (py + sy) / 2 + (py - sy) / 10; riversData.push({river: land[i].river, cell: id, x, y}); } else { if (cells[min].lake === 1) { fn = cells[min].fn; if (features[fn].river === undefined) features[fn].river = land[i].river; } // add next River segment riversData.push({river: land[i].river, cell: min, x: px, y: py}); } } } console.timeEnd('flux'); drawRiverLines(riverNext); } function drawRiverLines(riverNext) { console.time('drawRiverLines'); for (let i = 0; i < riverNext; i++) { const dataRiver = $.grep(riversData, function (e) { return e.river === i; }); if (dataRiver.length > 1) { const riverAmended = amendRiver(dataRiver, 1); const width = rn(0.8 + Math.random() * 0.4, 1); const increment = rn(0.8 + Math.random() * 0.4, 1); const d = drawRiver(riverAmended, width, increment); rivers.append("path").attr("d", d).attr("id", "river"+i).attr("data-width", width).attr("data-increment", increment); } } rivers.selectAll("path").on("click", editRiver); console.timeEnd('drawRiverLines'); } // add more river points on 1/3 and 2/3 of length function amendRiver(dataRiver, rndFactor) { const riverAmended = []; let side = 1; for (let r = 0; r < dataRiver.length; r++) { const dX = dataRiver[r].x; const dY = dataRiver[r].y; const cell = dataRiver[r].cell; const c = cells[cell].confluence || 0; riverAmended.push([dX, dY, c]); if (r+1 < dataRiver.length) { const eX = dataRiver[r + 1].x; const eY = dataRiver[r + 1].y; const angle = Math.atan2(eY - dY, eX - dX); const serpentine = 1 / (r + 1); const meandr = serpentine + 0.3 + Math.random() * 0.3 * rndFactor; if (Math.random() > 0.5) { side *= -1 } const dist = Math.hypot(eX - dX, eY - dY); // if dist is big or river is small add 2 extra points if (dist > 8 || (dist > 4 && dataRiver.length < 6)) { let stX = (dX * 2 + eX) / 3; let stY = (dY * 2 + eY) / 3; let enX = (dX + eX * 2) / 3; let enY = (dY + eY * 2) / 3; stX += -Math.sin(angle) * meandr * side; stY += Math.cos(angle) * meandr * side; if (Math.random() > 0.8) { side *= -1 } enX += Math.sin(angle) * meandr * side; enY += -Math.cos(angle) * meandr * side; riverAmended.push([stX, stY],[enX, enY]); // if dist is medium or river is small add 1 extra point } else if (dist > 4 || dataRiver.length < 6) { let scX = (dX + eX) / 2; let scY = (dY + eY) / 2; scX += -Math.sin(angle) * meandr * side; scY += Math.cos(angle) * meandr * side; riverAmended.push([scX, scY]); } } } return riverAmended; } // draw river polygon using arrpoximation function drawRiver(points, width, increment) { lineGen.curve(d3.curveCatmullRom.alpha(0.1)); let extraOffset = 0.03; // start offset to make river source visible width = width || 1; // river width modifier increment = increment || 1; // river bed widening modifier let riverLength = 0; points.map(function(p, i) { if (i === 0) {return 0;} riverLength += Math.hypot(p[0] - points[i-1][0],p[1] - points[i-1][1]); }); const widening = rn((1000 + (riverLength * 30)) * increment); const riverPointsLeft = [], riverPointsRight = []; 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 xLeft = x + -Math.sin(angle) * extraOffset, yLeft = y + Math.cos(angle) * extraOffset; riverPointsLeft.push({scX:xLeft, scY:yLeft}); let xRight = x + Math.sin(angle) * extraOffset, yRight = y + -Math.cos(angle) * extraOffset; riverPointsRight.unshift({scX:xRight, scY:yRight}); // middle points for (let p = 1; p < last; p ++) { x = points[p][0],y = points[p][1],c = points[p][2]; if (c) {extraOffset += Math.atan(c * 10 / widening);} // confluence 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); var offset = (Math.atan(Math.pow(p * factor, 2) / widening) / 2 * width) + extraOffset; xLeft = x + -Math.sin(angle) * offset, yLeft = y + Math.cos(angle) * offset; riverPointsLeft.push({scX:xLeft, scY:yLeft}); xRight = x + Math.sin(angle) * offset, yRight = y + -Math.cos(angle) * offset; riverPointsRight.unshift({scX:xRight, scY:yRight}); } // end point x = points[last][0],y = points[last][1],c = points[last][2]; if (c) {extraOffset += Math.atan(c * 10 / widening);} // confluence angle = Math.atan2(points[last-1][1] - y, points[last-1][0] - x); xLeft = x + -Math.sin(angle) * offset, yLeft = y + Math.cos(angle) * offset; riverPointsLeft.push({scX:xLeft, scY:yLeft}); xRight = x + Math.sin(angle) * offset, yRight = y + -Math.cos(angle) * offset; riverPointsRight.unshift({scX:xRight, scY:yRight}); // generate path and return const right = lineGen(riverPointsRight); let left = lineGen(riverPointsLeft); left = left.substring(left.indexOf("C")); return round(right + left, 2); } // draw river polygon with best quality function drawRiverSlow(points, width, increment) { lineGen.curve(d3.curveCatmullRom.alpha(0.1)); width = width || 1; const extraOffset = 0.02 * width; increment = increment || 1; const riverPoints = points.map(function (p) { return {scX: p[0], scY: p[1]}; }); const river = defs.append("path").attr("d", lineGen(riverPoints)); const riverLength = river.node().getTotalLength(); const widening = rn((1000 + (riverLength * 30)) * increment); const riverPointsLeft = [], riverPointsRight = []; for (let l = 0; l < riverLength; l++) { var point = river.node().getPointAtLength(l); var from = river.node().getPointAtLength(l - 0.1); const to = river.node().getPointAtLength(l + 0.1); var angle = Math.atan2(from.y - to.y, from.x - to.x); var offset = (Math.atan(Math.pow(l, 2) / widening) / 2 * width) + extraOffset; var xLeft = point.x + -Math.sin(angle) * offset; var yLeft = point.y + Math.cos(angle) * offset; riverPointsLeft.push({scX:xLeft, scY:yLeft}); var xRight = point.x + Math.sin(angle) * offset; var yRight = point.y + -Math.cos(angle) * offset; riverPointsRight.unshift({scX:xRight, scY:yRight}); } var point = river.node().getPointAtLength(riverLength); var from = river.node().getPointAtLength(riverLength - 0.1); var angle = Math.atan2(from.y - point.y, from.x - point.x); var offset = (Math.atan(Math.pow(riverLength, 2) / widening) / 2 * width) + extraOffset; var xLeft = point.x + -Math.sin(angle) * offset; var yLeft = point.y + Math.cos(angle) * offset; riverPointsLeft.push({scX:xLeft, scY:yLeft}); var xRight = point.x + Math.sin(angle) * offset; var yRight = point.y + -Math.cos(angle) * offset; riverPointsRight.unshift({scX:xRight, scY:yRight}); river.remove(); // generate path and return const right = lineGen(riverPointsRight); let left = lineGen(riverPointsLeft); left = left.substring(left.indexOf("C")); return round(right + left, 2); } // add lakes on depressed points on river course function addLakes() { console.time('addLakes'); let smallLakes = 0; for (let i=0; i < land.length; i++) { // elavate all big lakes if (land[i].lake === 1) { land[i].height = 19; land[i].ctype = -1; } // define eligible small lakes if (land[i].lake === 2 && smallLakes < 100) { if (land[i].river !== undefined) { land[i].height = 19; land[i].ctype = -1; land[i].fn = -1; smallLakes++; } else { land[i].lake = undefined; land[i].neighbors.forEach(function(n) { if (cells[n].lake !== 1 && cells[n].river !== undefined) { cells[n].lake = 2; cells[n].height = 19; cells[n].ctype = -1; cells[n].fn = -1; smallLakes++; } else if (cells[n].lake === 2) { cells[n].lake = undefined; } }); } } } console.log( "small lakes: " + smallLakes); // mark small lakes let unmarked = $.grep(land, function(e) {return e.fn === -1}); while (unmarked.length) { let fn = -1, queue = [unmarked[0].index],lakeCells = []; unmarked[0].session = "addLakes"; while (queue.length) { const q = queue.pop(); lakeCells.push(q); if (cells[q].fn !== -1) fn = cells[q].fn; cells[q].neighbors.forEach(function(e) { if (cells[e].lake && cells[e].session !== "addLakes") { cells[e].session = "addLakes"; queue.push(e); } }); } if (fn === -1) { fn = features.length; features.push({i: fn, land: false, border: false}); } lakeCells.forEach(function(c) {cells[c].fn = fn;}); unmarked = $.grep(land, function(e) {return e.fn === -1}); } land = $.grep(cells, function(e) {return e.height >= 20;}); console.timeEnd('addLakes'); } function editLabel() { if (customization) return; unselect(); closeDialogs("#labelEditor, .stable"); elSelected = d3.select(this).call(d3.drag().on("start", elementDrag)).classed("draggable", true); // update group parameters let group = d3.select(this.parentNode); updateGroupOptions(); labelGroupSelect.value = group.attr("id"); labelFontSelect.value = fonts.indexOf(group.attr("data-font")); labelSize.value = group.attr("data-size"); labelColor.value = toHEX(group.attr("fill")); labelOpacity.value = group.attr("opacity"); labelText.value = elSelected.text(); const tr = parseTransform(elSelected.attr("transform")); labelAngle.value = tr[2]; labelAngleValue.innerHTML = Math.abs(+tr[2]) + "°"; $("#labelEditor").dialog({ title: "Edit Label: " + labelText.value, minHeight: 30, width: "auto", maxWidth: 275, resizable: false, position: {my: "center top+10", at: "bottom", of: this}, close: unselect }); if (modules.editLabel) return; modules.editLabel = true; loadDefaultFonts(); function updateGroupOptions() { labelGroupSelect.innerHTML = ""; labels.selectAll("g:not(#burgLabels)").each(function(d) { if (this.parentNode.id === "burgLabels") return; let id = d3.select(this).attr("id"); let opt = document.createElement("option"); opt.value = opt.innerHTML = id; labelGroupSelect.add(opt); }); } $("#labelGroupButton").click(function() { $("#labelEditor > button").not(this).toggle(); $("#labelGroupButtons").toggle(); }); // on group change document.getElementById("labelGroupSelect").addEventListener("change", function() { document.getElementById(this.value).appendChild(elSelected.remove().node()); }); // toggle inputs to declare a new group document.getElementById("labelGroupNew").addEventListener("click", function() { if ($("#labelGroupInput").css("display") === "none") { $("#labelGroupInput").css("display", "inline-block"); $("#labelGroupSelect").css("display", "none"); labelGroupInput.focus(); } else { $("#labelGroupSelect").css("display", "inline-block"); $("#labelGroupInput").css("display", "none"); } }); // toggle inputs to select a group document.getElementById("labelExternalFont").addEventListener("click", function() { if ($("#labelFontInput").css("display") === "none") { $("#labelFontInput").css("display", "inline-block"); $("#labelFontSelect").css("display", "none"); labelFontInput.focus(); } else { $("#labelFontSelect").css("display", "inline-block"); $("#labelFontInput").css("display", "none"); } }); // on new group creation document.getElementById("labelGroupInput").addEventListener("change", function() { 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 el with this id exists, add size to id while (labels.selectAll("#"+group).size()) {group += "_new";} createNewLabelGroup(group); }); function createNewLabelGroup(g) { let group = elSelected.node().parentNode.cloneNode(false); let groupNew = labels.append(f => group).attr("id", g); groupNew.append(f => elSelected.remove().node()); updateGroupOptions(); $("#labelGroupSelect, #labelGroupInput").toggle(); labelGroupInput.value = ""; labelGroupSelect.value = g; updateLabelGroups(); } // remove label group on click document.getElementById("labelGroupRemove").addEventListener("click", function() { let group = d3.select(elSelected.node().parentNode); let id = group.attr("id"); let count = group.selectAll("text").size(); // remove group with < 2 label without ask if (count < 2) { removeAllLabelsInGroup(id); $("#labelEditor").dialog("close"); return; } alertMessage.innerHTML = "Are you sure you want to remove all labels (" + count + ") of that group?"; $("#alert").dialog({resizable: false, title: "Remove label group", buttons: { Remove: function() { $(this).dialog("close"); removeAllLabelsInGroup(id); $("#labelEditor").dialog("close"); }, Cancel: function() {$(this).dialog("close");} } }); }); $("#labelTextButton").click(function() { $("#labelEditor > button").not(this).toggle(); $("#labelTextButtons").toggle(); }); // on label text change document.getElementById("labelText").addEventListener("input", function() { if (!this.value) { tip("Name should not be blank, set opacity to 0 to hide label or click remove button to delete"); return; } // change Label text if (elSelected.select("textPath").size()) elSelected.select("textPath").text(this.value); else elSelected.text(this.value); $("div[aria-describedby='labelEditor'] .ui-dialog-title").text("Edit Label: " + this.value); // check if label is a country name let id = elSelected.attr("id") || ""; if (id.includes("regionLabel")) { let state = +elSelected.attr("id").slice(11); states[state].name = this.value; } }); // generate a random country name document.getElementById("labelTextRandom").addEventListener("click", function() { let name = elSelected.text(); let id = elSelected.attr("id") || ""; if (id.includes("regionLabel")) { // label is a country name let state = +elSelected.attr("id").slice(11); name = generateStateName(state.i); states[state].name = name; } else { // label is not a country name, use random culture let c = elSelected.node().getBBox(); let closest = cultureTree.find((c.x + c.width / 2), (c.y + c.height / 2)); let culture = Math.floor(Math.random() * cultures.length); name = generateName(culture); } labelText.value = name; $("div[aria-describedby='labelEditor'] .ui-dialog-title").text("Edit Label: " + name); // change Label text if (elSelected.select("textPath").size()) elSelected.select("textPath").text(name); else elSelected.text(name); }); $("#labelFontButton").click(function() { $("#labelEditor > button").not(this).toggle(); $("#labelFontButtons").toggle(); }); // on label font change document.getElementById("labelFontSelect").addEventListener("change", function() { let group = elSelected.node().parentNode; let font = fonts[this.value].split(':')[0].replace(/\+/g, " "); group.setAttribute("font-family", font); group.setAttribute("data-font", fonts[this.value]); }); // on adding custom font document.getElementById("labelFontInput").addEventListener("change", function() { fetchFonts(this.value).then(fetched => { if (!fetched) return; labelExternalFont.click(); labelFontInput.value = ""; if (fetched === 1) $("#labelFontSelect").val(fonts.length - 1).change(); }); }); // on label size input document.getElementById("labelSize").addEventListener("input", function() { let group = elSelected.node().parentNode; let size = +this.value; group.setAttribute("data-size", size); group.setAttribute("font-size", rn((size + (size / scale)) / 2, 2)) }); $("#labelStyleButton").click(function() { $("#labelEditor > button").not(this).toggle(); $("#labelStyleButtons").toggle(); }); // on label fill color input document.getElementById("labelColor").addEventListener("input", function() { let group = elSelected.node().parentNode; group.setAttribute("fill", this.value); }); // on label opacity input document.getElementById("labelOpacity").addEventListener("input", function() { let group = elSelected.node().parentNode; group.setAttribute("opacity", this.value); }); $("#labelAngleButton").click(function() { $("#labelEditor > button").not(this).toggle(); $("#labelAngleButtons").toggle(); }); // on label angle input document.getElementById("labelAngle").addEventListener("input", function() { const tr = parseTransform(elSelected.attr("transform")); labelAngleValue.innerHTML = Math.abs(+this.value) + "°"; const c = elSelected.node().getBBox(); const angle = +this.value; const transform = `translate(${tr[0]},${tr[1]}) rotate(${angle} ${(c.x+c.width/2)} ${(c.y+c.height/2)})`; elSelected.attr("transform", transform); }); // display control points to curve label (place on path) document.getElementById("labelCurve").addEventListener("click", function() { let c = elSelected.node().getBBox(); let cx = c.x + c.width / 2, cy = c.y + c.height / 2; if (!elSelected.select("textPath").size()) { let id = elSelected.attr("id"); let pathId = "#textPath_" + id; let path = `M${cx-c.width},${cy} q${c.width},0 ${c.width * 2},0`; let text = elSelected.text(), x = elSelected.attr("x"), y = elSelected.attr("y"); elSelected.text(null).attr("data-x", x).attr("data-y", y).attr("x", null).attr("y", null); defs.append("path").attr("id", "textPath_" + id).attr("d", path); elSelected.append("textPath").attr("href", pathId).attr("startOffset", "50%").text(text); } if (!debug.select("circle").size()) { debug.append("circle").attr("id", "textPathControl").attr("r", 1.6) .attr("cx", cx).attr("cy", cy).attr("transform", elSelected.attr("transform") || null) .call(d3.drag().on("start", textPathControlDrag)); } }); // drag textPath controle point to curve the label function textPathControlDrag() { let textPath = defs.select("#textPath_" + elSelected.attr("id")); let path = textPath.attr("d").split(" "); let M = path[0].split(","); let q = path[1].split(","); // +q[1] to get qy - the only changeble value let y = d3.event.y; d3.event.on("drag", function() { let dy = d3.event.y - y; let total = +q[1] + dy * 8; d3.select(this).attr("cy", d3.event.y); textPath.attr("d", `${M[0]},${+M[1] - dy} ${q[0]},${total} ${path[2]}`); }); } // cancel label curvature document.getElementById("labelCurveCancel").addEventListener("click", function() { if (!elSelected.select("textPath").size()) return; let text = elSelected.text(), x = elSelected.attr("data-x"), y = elSelected.attr("data-y"); elSelected.text(); elSelected.attr("x", x).attr("y", y).attr("data-x", null).attr("data-y", null).text(text); defs.select("#textPath_" + elSelected.attr("id")).remove(); debug.select("circle").remove(); }); // open legendsEditor document.getElementById("labelLegend").addEventListener("click", function() { let id = elSelected.attr("id"); let name = elSelected.text(); editLegends(id, name); }); // copy label on click document.getElementById("labelCopy").addEventListener("click", function() { let group = d3.select(elSelected.node().parentNode); copy = group.append(f => elSelected.node().cloneNode(true)); let id = "label" + Date.now().toString().slice(7); copy.attr("id", id).attr("class", null).on("click", editLabel); let shift = +group.attr("font-size") + 1; if (copy.select("textPath").size()) { let path = defs.select("#textPath_" + elSelected.attr("id")).attr("d"); let textPath = defs.append("path").attr("id", "textPath_" + id); copy.select("textPath").attr("href", "#textPath_" + id); let pathArray = path.split(" "); let x = +pathArray[0].split(",")[0].slice(1); let y = +pathArray[0].split(",")[1]; textPath.attr("d", `M${x-shift},${y-shift} ${pathArray[1]} ${pathArray[2]}`);shift } else { let x = +elSelected.attr("x") - shift; let y = +elSelected.attr("y") - shift; while (group.selectAll("text[x='" + x + "']").size()) {x -= shift; y -= shift;} copy.attr("x", x).attr("y", y); } }); // remove label on click document.getElementById("labelRemoveSingle").addEventListener("click", function() { alertMessage.innerHTML = "Are you sure you want to remove the label?"; $("#alert").dialog({resizable: false, title: "Remove label", buttons: { Remove: function() { $(this).dialog("close"); elSelected.remove(); defs.select("#textPath_" + elSelected.attr("id")).remove(); $("#labelEditor").dialog("close"); }, Cancel: function() {$(this).dialog("close");} } }); }); } function editRiver() { if (customization) return; if (elSelected) { const self = d3.select(this).attr("id") === elSelected.attr("id"); const point = d3.mouse(this); if (elSelected.attr("data-river") === "new") { addRiverPoint([point[0],point[1]]); completeNewRiver(); return; } else if (self) { riverAddControlPoint(point); return; } } unselect(); closeDialogs("#riverEditor, .stable"); elSelected = d3.select(this); elSelected.call(d3.drag().on("start", riverDrag)); const tr = parseTransform(elSelected.attr("transform")); riverAngle.value = tr[2]; riverAngleValue.innerHTML = Math.abs(+tr[2]) + "°"; riverScale.value = tr[5]; riverWidthInput.value = +elSelected.attr("data-width"); riverIncrement.value = +elSelected.attr("data-increment"); $("#riverEditor").dialog({ title: "Edit River", minHeight: 30, width: "auto", resizable: false, position: {my: "center top+20", at: "top", of: d3.event}, close: function() { if ($("#riverNew").hasClass('pressed')) completeNewRiver(); unselect(); } }); if (!debug.select(".controlPoints").size()) debug.append("g").attr("class", "controlPoints"); riverDrawPoints(); if (modules.editRiver) {return;} modules.editRiver = true; function riverAddControlPoint(point) { let dists = []; debug.select(".controlPoints").selectAll("circle").each(function() { const x = +d3.select(this).attr("cx"); const y = +d3.select(this).attr("cy"); dists.push(Math.hypot(point[0] - x, point[1] - y)); }); let index = dists.length; if (dists.length > 1) { const sorted = dists.slice(0).sort(function(a, b) {return 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", 0.35) .call(d3.drag().on("drag", riverPointDrag)) .on("click", function(d) { $(this).remove(); redrawRiver(); }); redrawRiver(); } function riverDrawPoints() { const node = elSelected.node(); // river is a polygon, so divide length by 2 to get course length const l = node.getTotalLength() / 2; const parts = (l / 5) >> 0; // number of points let inc = l / parts; // increment if (inc === Infinity) {inc = l;} // 2 control points for short rivers // draw control points for (let i = l, c = l; i > 0; i -= inc, c += inc) { const p1 = node.getPointAtLength(i); const p2 = node.getPointAtLength(c); const p = [(p1.x + p2.x) / 2, (p1.y + p2.y) / 2]; addRiverPoint(p); } // last point should be accurate const lp1 = node.getPointAtLength(0); const lp2 = node.getPointAtLength(l * 2); const p = [(lp1.x + lp2.x) / 2, (lp1.y + lp2.y) / 2]; addRiverPoint(p); } function addRiverPoint(point) { debug.select(".controlPoints").append("circle") .attr("cx", point[0]).attr("cy", point[1]).attr("r", 0.35) .call(d3.drag().on("drag", riverPointDrag)) .on("click", function(d) { $(this).remove(); redrawRiver(); }); } function riverPointDrag() { d3.select(this).attr("cx", d3.event.x).attr("cy", d3.event.y); redrawRiver(); } function riverDrag() { 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 redrawRiver() { let points = []; debug.select(".controlPoints").selectAll("circle").each(function() { const el = d3.select(this); points.push([+el.attr("cx"), +el.attr("cy")]); }); const width = +riverWidthInput.value; const increment = +riverIncrement.value; const d = drawRiverSlow(points, width, increment); elSelected.attr("d", d); } $("#riverWidthInput, #riverIncrement").change(function() { const width = +riverWidthInput.value; const increment = +riverIncrement.value; elSelected.attr("data-width", width).attr("data-increment", increment); redrawRiver(); }); $("#riverRegenerate").click(function() { let points = [],amended = [],x, y, p1, p2; const node = elSelected.node(); const l = node.getTotalLength() / 2; const parts = (l / 8) >> 0; // number of points let inc = l / parts; // increment if (inc === Infinity) {inc = l;} // 2 control points for short rivers for (let i = l, e = l; i > 0; i -= inc, e += inc) { p1 = node.getPointAtLength(i); p2 = node.getPointAtLength(e); x = (p1.x + p2.x) / 2, y = (p1.y + p2.y) / 2; points.push([x, y]); } // last point should be accurate p1 = node.getPointAtLength(0); p2 = node.getPointAtLength(l * 2); x = (p1.x + p2.x) / 2, y = (p1.y + p2.y) / 2; points.push([x, y]); // amend points const rndFactor = 0.3 + Math.random() * 1.4; // random factor in range 0.2-1.8 for (let i = 0; i < points.length; i++) { x = points[i][0],y = points[i][1]; amended.push([x, y]); // add additional semi-random point if (i + 1 < points.length) { const x2 = points[i+1][0],y2 = points[i+1][1]; let side = Math.random() > 0.5 ? 1 : -1; const angle = Math.atan2(y2 - y, x2 - x); const serpentine = 2 / (i+1); const meandr = serpentine + 0.3 + Math.random() * rndFactor; x = (x + x2) / 2, y = (y + y2) / 2; x += -Math.sin(angle) * meandr * side; y += Math.cos(angle) * meandr * side; amended.push([x, y]); } } const width = +riverWidthInput.value * 0.6 + Math.random(); const increment = +riverIncrement.value * 0.9 + Math.random() * 0.2; riverWidthInput.value = width; riverIncrement.value = increment; elSelected.attr("data-width", width).attr("data-increment", increment); const d = drawRiverSlow(amended, width, increment); elSelected.attr("d", d).attr("data-width", width).attr("data-increment", increment); debug.select(".controlPoints").selectAll("*").remove(); amended.map(function(p) {addRiverPoint(p);}); }); $("#riverAngle").on("input", function() { const tr = parseTransform(elSelected.attr("transform")); riverAngleValue.innerHTML = Math.abs(+this.value) + "°"; 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); }); $("#riverReset").click(function() { elSelected.attr("transform", ""); debug.select(".controlPoints").attr("transform", ""); riverAngle.value = 0; riverAngleValue.innerHTML = "0°"; riverScale.value = 1; }); $("#riverScale").change(function() { 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); }); $("#riverNew").click(function() { if ($(this).hasClass('pressed')) { completeNewRiver(); } else { // enter creation mode $(".pressed").removeClass('pressed'); $(this).addClass('pressed'); if (elSelected) elSelected.call(d3.drag().on("drag", null)); debug.select(".controlPoints").selectAll("*").remove(); viewbox.style("cursor", "crosshair").on("click", newRiverAddPoint); } }); function newRiverAddPoint() { const point = d3.mouse(this); addRiverPoint([point[0],point[1]]); if (!elSelected || elSelected.attr("data-river") !== "new") { const id = +$("#rivers > path").last().attr("id").slice(5) + 1; elSelected = rivers.append("path").attr("data-river", "new").attr("id", "river"+id) .attr("data-width", 2).attr("data-increment", 1).on("click", completeNewRiver); } else { redrawRiver(); let cell = diagram.find(point[0],point[1]).index; let f = cells[cell].fn; let ocean = !features[f].land && features[f].border; if (ocean && debug.select(".controlPoints").selectAll("circle").size() > 5) completeNewRiver(); } } function completeNewRiver() { $("#riverNew").removeClass('pressed'); restoreDefaultEvents(); if (!elSelected || elSelected.attr("data-river") !== "new") return; redrawRiver(); elSelected.attr("data-river", ""); elSelected.call(d3.drag().on("start", riverDrag)).on("click", editRiver); const r = +elSelected.attr("id").slice(5); debug.select(".controlPoints").selectAll("circle").each(function() { const x = +d3.select(this).attr("cx"); const y = +d3.select(this).attr("cy"); const cell = diagram.find(x, y, 3); if (!cell) return; if (cells[cell.index].river === undefined) cells[cell.index].river = r; }); unselect(); debug.append("g").attr("class", "controlPoints"); } $("#riverCopy").click(function() { 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]})`; } const river = +$("#rivers > path").last().attr("id").slice(5) + 1; rivers.append("path").attr("d", d) .attr("transform", transform) .attr("id", "river"+river).on("click", editRiver) .attr("data-width", elSelected.attr("data-width")) .attr("data-increment", elSelected.attr("data-increment")); unselect(); }); // open legendsEditor document.getElementById("riverLegend").addEventListener("click", function() { let id = elSelected.attr("id"); editLegends(id, id); }); $("#riverRemove").click(function() { alertMessage.innerHTML = `Are you sure you want to remove the river?`; $("#alert").dialog({resizable: false, title: "Remove river", buttons: { Remove: function() { $(this).dialog("close"); const river = +elSelected.attr("id").slice(5); const avPrec = rn(precInput.value / Math.sqrt(cells.length), 2); land.map(function(l) { if (l.river === river) { l.river = undefined; l.flux = avPrec; } }); elSelected.remove(); unselect(); $("#riverEditor").dialog("close"); }, Cancel: function() {$(this).dialog("close");} } }) }); } function editRoute() { if (customization) {return;} if (elSelected) { const self = d3.select(this).attr("id") === elSelected.attr("id"); const point = d3.mouse(this); if (elSelected.attr("data-route") === "new") { addRoutePoint({x:point[0],y:point[1]}); completeNewRoute(); return; } else if (self) { routeAddControlPoint(point); return; } } unselect(); closeDialogs("#routeEditor, .stable"); if (this && this !== window) { elSelected = d3.select(this); if (!debug.select(".controlPoints").size()) debug.append("g").attr("class", "controlPoints"); routeDrawPoints(); routeUpdateGroups(); let routeType = d3.select(this.parentNode).attr("id"); routeGroup.value = routeType; $("#routeEditor").dialog({ title: "Edit Route", minHeight: 30, width: "auto", resizable: false, position: {my: "center top+20", at: "top", of: d3.event}, close: function() { if ($("#addRoute").hasClass('pressed')) completeNewRoute(); if ($("#routeSplit").hasClass('pressed')) $("#routeSplit").removeClass('pressed'); unselect(); } }); } else {elSelected = null;} if (modules.editRoute) {return;} modules.editRoute = true; function routeAddControlPoint(point) { let dists = []; debug.select(".controlPoints").selectAll("circle").each(function() { const x = +d3.select(this).attr("cx"); const y = +d3.select(this).attr("cy"); dists.push(Math.hypot(point[0] - x, point[1] - y)); }); let index = dists.length; if (dists.length > 1) { const sorted = dists.slice(0).sort(function(a, b) {return 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", 0.35) .call(d3.drag().on("drag", routePointDrag)) .on("click", function(d) { $(this).remove(); routeRedraw(); }); routeRedraw(); } function routeDrawPoints() { if (!elSelected.size()) return; const node = elSelected.node(); const l = node.getTotalLength(); const parts = (l / 5) >> 0; // number of points let inc = l / parts; // increment if (inc === Infinity) inc = l; // 2 control points for short routes // draw control points for (let i = 0; i <= l; i += inc) { const p = node.getPointAtLength(i); addRoutePoint(p); } // convert length to distance routeLength.innerHTML = rn(l * distanceScale.value) + " " + distanceUnit.value; } function addRoutePoint(point) { const controlPoints = debug.select(".controlPoints").size() ? debug.select(".controlPoints") : debug.append("g").attr("class", "controlPoints"); controlPoints.append("circle") .attr("cx", point.x).attr("cy", point.y).attr("r", 0.35) .call(d3.drag().on("drag", routePointDrag)) .on("click", function(d) { if ($("#routeSplit").hasClass('pressed')) { routeSplitInPoint(this); } else { $(this).remove(); routeRedraw(); } }); } function routePointDrag() { d3.select(this).attr("cx", d3.event.x).attr("cy", d3.event.y); routeRedraw(); } function routeRedraw() { let points = []; debug.select(".controlPoints").selectAll("circle").each(function() { const el = d3.select(this); points.push({scX: +el.attr("cx"), scY: +el.attr("cy")}); }); lineGen.curve(d3.curveCatmullRom.alpha(0.1)); elSelected.attr("d", lineGen(points)); // get route distance const l = elSelected.node().getTotalLength(); routeLength.innerHTML = rn(l * distanceScale.value) + " " + distanceUnit.value; } function addNewRoute() { let routeType = elSelected && elSelected.node() ? elSelected.node().parentNode.id : "searoutes"; const group = routes.select("#"+routeType); const id = routeType + "" + group.selectAll("*").size(); elSelected = group.append("path").attr("data-route", "new").attr("id", id).on("click", editRoute); routeUpdateGroups(); $("#routeEditor").dialog({ title: "Edit Route", minHeight: 30, width: "auto", resizable: false, close: function() { if ($("#addRoute").hasClass('pressed')) completeNewRoute(); if ($("#routeSplit").hasClass('pressed')) $("#routeSplit").removeClass('pressed'); unselect(); } }); } function newRouteAddPoint() { const point = d3.mouse(this); const x = rn(point[0],2), y = rn(point[1],2); addRoutePoint({x, y}); routeRedraw(); } function completeNewRoute() { $("#routeNew, #addRoute").removeClass('pressed'); restoreDefaultEvents(); if (!elSelected.size()) return; if (elSelected.attr("data-route") === "new") { routeRedraw(); elSelected.attr("data-route", ""); const node = elSelected.node(); const l = node.getTotalLength(); let pathCells = []; for (let i = 0; i <= l; i ++) { const p = node.getPointAtLength(i); const cell = diagram.find(p.x, p.y); if (!cell) {return;} pathCells.push(cell.index); } const uniqueCells = [...new Set(pathCells)]; uniqueCells.map(function(c) { if (cells[c].path !== undefined) {cells[c].path += 1;} else {cells[c].path = 1;} }); } tip("", true); } function routeUpdateGroups() { routeGroup.innerHTML = ""; routes.selectAll("g").each(function() { const opt = document.createElement("option"); opt.value = opt.innerHTML = this.id; routeGroup.add(opt); }); } function routeSplitInPoint(clicked) { const group = d3.select(elSelected.node().parentNode); $("#routeSplit").removeClass('pressed'); const points1 = [],points2 = []; let points = points1; debug.select(".controlPoints").selectAll("circle").each(function() { const el = d3.select(this); points.push({scX: +el.attr("cx"), scY: +el.attr("cy")}); if (this === clicked) { points = points2; points.push({scX: +el.attr("cx"), scY: +el.attr("cy")}); } el.remove(); }); lineGen.curve(d3.curveCatmullRom.alpha(0.1)); elSelected.attr("d", lineGen(points1)); const id = routeGroup.value + "" + group.selectAll("*").size(); group.append("path").attr("id", id).attr("d", lineGen(points2)).on("click", editRoute); routeDrawPoints(); } $("#routeGroup").change(function() { $(elSelected.node()).detach().appendTo($("#"+this.value)); }); // open legendsEditor document.getElementById("routeLegend").addEventListener("click", function() { let id = elSelected.attr("id"); editLegends(id, id); }); $("#routeNew").click(function() { if ($(this).hasClass('pressed')) { completeNewRoute(); } else { // enter creation mode $(".pressed").removeClass('pressed'); $("#routeNew, #addRoute").addClass('pressed'); debug.select(".controlPoints").selectAll("*").remove(); addNewRoute(); viewbox.style("cursor", "crosshair").on("click", newRouteAddPoint); tip("Click on map to add route point", true); } }); $("#routeRemove").click(function() { 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");} } }) }); }