The previous post was mostly devoted to the Fluvial Network calculation and rivers rendering was not covered enough. I stayed on the straightforward and even unexpected solution: draw rivers based on their length only. It’s rather elegant, but tributaries don’t increase the main stem volume and this may be a problem. In real world tributaries usually don’t do it too, at least not in a direct way. But from a world-building perspective my implementation looks a bit boring. I want rivers to be not so predictable.
The problem is that we had amended rivers with bends and rivers’ control points don’t coincide with map polygons anymore. Usually we have 3 river elements for a polygon, so we need to interpolate the related polygon’s flux to have different width for each element.
Another solution is to combine flux and length approaches. On a regular stream width should get constantly increasing based on length, but on confluence river width should be completely recalculated based on the polygon’s flux value.
Visually it is obvious, but system doesn’t know where the confluences are. To detect this we need to go back to flux calculation function and locate polygons with already defined river, where additional flux is poured into. These are confluences, it worth nothing to add these points to array to check it on river rendering. If the element’s endpoint exists in the confluence array, river width should be recalculated based on flux.
Another problem and this one is crucial for me, is that river width changes drastically and it looks not good on zooming. I had to reduce the flux value by using root function, it helped a bit on major rivers junction, but still I was not satisfied.
I came to a conclusion that we need not only to change the width, but also shift the thinner (previous) element endpoint to constitute a smooth curve with the wider element. In this case the shift length should be equal to the half of the difference between old and new widths. This part is easy, but it is not easy at all to calculate a correct coordinates. I didn’t get any Math classes besides of school, so it took me a lot of time to solve this issue.
We know the shift distance, but to detect new coordinate we also need a shift direction (vector). Actually direction is controlled by angle. Confluence is just a point and hence don’t have an angle, so we need to find one more point to constitute a vector.
It needs to be noted that I want to define new points right on the river element rendering. At this moment of time next element is not yet created, even bends are not yet added, so we have to use the points of the already rendered segment. And the closest point to the confluence is the element’s last control point. Two points are enough to define the angle. There is a classical formula to calculate an arctangent between points and X axis:
var angle = Math.atan2(endY - startY, endX - startX);
The returned value represents a river curvature on a confluence point. Actually, it’s not correct as we don’t consider the next element, but from the real usage perspective it’s very close and we can make things much easier calling it just an angle.
Using angle and the shift length we are able to define two points lying on the vector that almost perpendicular to a river direction on a confluence spot. One to represent left shift (xLeft, yLeft), another – right shift (xRight, yRight):
var xLeft = -Math.sin(angle) * shift + confluenceX; var yLeft = Math.cos(angle) * shift + confluenceY; var xRight = Math.sin(angle) * shift + confluenceX; var yRight = -Math.cos(angle) * shift + confluenceY;
Ok, got two points. But how to detect which point should be used? First I just selected random point, then the closest point by one axis, result was not good. Main stem looked fine, but tributary got shifted in a wrong side. To do it in a correct way we have to identify if tributary is a right-bank or a left-bank. Right-bank one’s should be shifted to the right, right-bank – to the left. Replacing the element’s endpoints by appropriate shifted coordinate we guarantee the confluence will be visually smooth.
This can’t be done without additional information. We need to know either next element coordinates, either coordinates of all tributaries. I’ve selected the second approach. The idea is to go back to confluence array creation and push references on source points for all tributaries to the array. Considering that our confluences usually are a junction of 2 rivers, we can create a bisector between the confluence point and the midpoint of the tributaries’ sources. This middle-line will help to define whether the tributary is a left-bank or a right-bank. If tributary’s source point is on the right from that line, we consider the tributary as a right-bank and the same for the left ones.
Formula is pretty straightforward, startX and startY represent a midpoint (start of bisector), endX and endY – coordinates of confluence (bisector’s end), pointX and pointY – coordinates of the point to check (tributary’s source point for the array). If the resulted value is greater than 0, the tributary is right, else it’s left:
var side = (startX-endX) * (pointY-endY) – (startY-endY) * (pointX-endX);
All that calculations are held on river rendering and this cause one more problem. As was mentioned above, we need to know not only whether the stem is right or left, but also the correct confluence angle. We already calculated it for the main stems, but for tributaries angle will be not precise as basic line interpolation is done for main stems only. So for each and every tributary we have to use not its own angle, but an angle of the related main stem.
The issue with this approach is that we render rivers in the same order they were created and tributaries could be rendered first, before main stems. At this moment we don’t know the correct angle. To avoid the pain with river elements indexing and re-visiting, we need always draw the main stem before its tributaries.
Obviously, the tributary is the one that flows into another river. So, in flow calculation function we assign each river Order number equal to zero. When river flows into another river we increase Order number of the target. Initially I’ve incremented the Order number by 1, but actually a lot of rivers got the same number that caused the situation when main flow was rendered after its tributary. The better way is to increase the Order number by river’s length, the value already used for tributary/main stem definition.
At the end of the process we have Order number for all rivers and just need to sort the array and then render rivers based on their Order, starting from the greater values. I wasn’t able to reproduce the issue with undefined angle anymore. But to be on the safe side I remain the angle validation and if it’s still not defined, calculate angle based on tributary’s points itself.
As was mentioned above, the post-confluence segment is a natural continuation of the main flow. Unlike the real world, tributaries don’t affect this segment at all. So confluences may be a bit weird as we don’t control tributaries course, just shift their end points. To minimize this problem we need to change the original Bézier curves produced by D3 interpolation replacing the second control point with the midpoint of this and first control point. This will relax tributary curve and make confluence look more natural. Meanwhile, it doesn’t mean we start control all over the river’s course, which is much more complex idea.
Despite there are still a lot of things that could be improved I’m quite satisfied with the current results. In general I believe I should describe the already implemented features first, but every time I try to create a post I stuck for a week or more with rather insignificant enhancements. At least it’s all for rivers as of now.
Here is a JSFiddle for the current version, fell flee to fork and amend. I’ve also added the download option based on d3-save-svg library, so you are can save and edit maps (there are some issues with layers, but they are easy to fix directly in svg editor). And, as always, I’m glad to hear advice and critique.