247 lines
7.2 KiB
Go
247 lines
7.2 KiB
Go
package field
|
|
|
|
import (
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"math"
|
|
"sort"
|
|
)
|
|
|
|
// False-colour maps of the fields a run works from, as opposed to the field it produces.
|
|
//
|
|
// preview.png answers "does this look like a landscape". These answer the question that comes next, which is
|
|
// "why does it look like that" — the uplift field, the erodibility, the slope and the basins are the inputs
|
|
// and the structure, and when a run comes out wrong it is almost always one of them that says so first. The
|
|
// uplift map in particular is the one that would have shown, without any arithmetic, that the plains were
|
|
// being raised at mountain rates.
|
|
|
|
// DataMapOptions controls one false-colour render.
|
|
type DataMapOptions struct {
|
|
// Sea marks cells to render as flat water rather than data. Optional.
|
|
Sea []bool
|
|
// Size is the output width in pixels; the field is point-sampled down to it and the height follows the
|
|
// field's own aspect.
|
|
Size int
|
|
// Log renders log10 of the value, for anything with a heavy tail — drainage area spans seven decades and
|
|
// is unreadable linearly.
|
|
Log bool
|
|
// Lo and Hi bound the colour ramp. Left at zero they are taken from the 1st and 99th percentile of the
|
|
// data, which keeps one outlier cell from flattening the whole image.
|
|
Lo, Hi float64
|
|
// Palette maps 0..1 to a colour. Nil is Viridis.
|
|
Palette func(float64) [3]float64
|
|
}
|
|
|
|
// WriteDataMap renders one scalar field as a false-colour PNG.
|
|
func WriteDataMap(path string, f *Field, opt DataMapOptions) error {
|
|
size := opt.Size
|
|
if size <= 0 || size > f.W {
|
|
size = f.W
|
|
}
|
|
pal := opt.Palette
|
|
if pal == nil {
|
|
pal = Viridis
|
|
}
|
|
|
|
vals := make([]float64, len(f.Data))
|
|
for i, v := range f.Data {
|
|
x := float64(v)
|
|
if opt.Log {
|
|
if x < 1 {
|
|
x = 1
|
|
}
|
|
x = math.Log10(x)
|
|
}
|
|
vals[i] = x
|
|
}
|
|
|
|
lo, hi := opt.Lo, opt.Hi
|
|
if lo == 0 && hi == 0 {
|
|
lo, hi = percentiles(vals, opt.Sea, 1, 99)
|
|
}
|
|
span := hi - lo
|
|
if span < 1e-12 {
|
|
span = 1
|
|
}
|
|
|
|
sizeH := aspectH(f, size)
|
|
img := image.NewRGBA(image.Rect(0, 0, size, sizeH))
|
|
for y := 0; y < sizeH; y++ {
|
|
sy := y * f.H / sizeH
|
|
for x := 0; x < size; x++ {
|
|
sx := x * f.W / size
|
|
i := sy*f.W + sx
|
|
if opt.Sea != nil && opt.Sea[i] {
|
|
img.Set(x, y, color.RGBA{24, 44, 74, 255})
|
|
continue
|
|
}
|
|
t := (vals[i] - lo) / span
|
|
if t < 0 {
|
|
t = 0
|
|
} else if t > 1 {
|
|
t = 1
|
|
}
|
|
c := pal(t)
|
|
img.Set(x, y, color.RGBA{clamp8(c[0]), clamp8(c[1]), clamp8(c[2]), 255})
|
|
}
|
|
}
|
|
return encode(path, img, png.DefaultCompression)
|
|
}
|
|
|
|
// WriteBasinMap colours each drainage basin, which is the one picture that shows whether the solve produced a
|
|
// *network* rather than a set of scratches: real basins tile the land, meet along divides that sit where the
|
|
// two catchments either side put them, and come in a spread of sizes. A map of noisy speckle means the router
|
|
// is re-deciding where the water goes every few cells.
|
|
//
|
|
// receiver is the D8 receiver array; a cell whose receiver is itself is a basin root.
|
|
func WriteBasinMap(path string, w, h int, receiver []int32, sea []bool, size int) error {
|
|
if size <= 0 || size > w {
|
|
size = w
|
|
}
|
|
// Walk each cell down to its root with path compression, so the whole thing stays O(n).
|
|
root := make([]int32, w*h)
|
|
for i := range root {
|
|
root[i] = -1
|
|
}
|
|
var stack []int32
|
|
for i := range root {
|
|
if root[i] >= 0 {
|
|
continue
|
|
}
|
|
stack = stack[:0]
|
|
c := int32(i)
|
|
for root[c] < 0 && receiver[c] != c {
|
|
stack = append(stack, c)
|
|
c = receiver[c]
|
|
}
|
|
r := root[c]
|
|
if r < 0 {
|
|
r = c
|
|
root[c] = r
|
|
}
|
|
for _, s := range stack {
|
|
root[s] = r
|
|
}
|
|
}
|
|
|
|
sizeH := int(float64(size)*float64(h)/float64(w) + 0.5)
|
|
if sizeH < 1 {
|
|
sizeH = 1
|
|
}
|
|
img := image.NewRGBA(image.Rect(0, 0, size, sizeH))
|
|
for y := 0; y < sizeH; y++ {
|
|
sy := y * h / sizeH
|
|
for x := 0; x < size; x++ {
|
|
sx := x * w / size
|
|
i := sy*w + sx
|
|
if sea != nil && sea[i] {
|
|
img.Set(x, y, color.RGBA{24, 44, 74, 255})
|
|
continue
|
|
}
|
|
// A hash of the root id, so neighbouring basins get unrelated colours and a divide is a hard
|
|
// edge rather than a gradient.
|
|
k := uint64(uint32(root[i]))*0x9e3779b97f4a7c15 + 0x2545f4914f6cdd1d
|
|
k ^= k >> 29
|
|
k *= 0xbf58476d1ce4e5b9
|
|
k ^= k >> 32
|
|
c := hsv(float64(k%3600)/3600, 0.45+float64((k>>12)%40)/100, 0.55+float64((k>>24)%40)/100)
|
|
img.Set(x, y, color.RGBA{clamp8(c[0]), clamp8(c[1]), clamp8(c[2]), 255})
|
|
}
|
|
}
|
|
return encode(path, img, png.DefaultCompression)
|
|
}
|
|
|
|
// Viridis, sampled at sixteen stops. Perceptually uniform and legible in greyscale, which matters because
|
|
// these get pasted into notes and printed.
|
|
var viridisStops = [][3]float64{
|
|
{68, 1, 84}, {72, 26, 108}, {71, 47, 125}, {65, 68, 135},
|
|
{57, 86, 140}, {49, 104, 142}, {42, 120, 142}, {35, 136, 142},
|
|
{31, 152, 139}, {34, 168, 132}, {53, 183, 121}, {84, 197, 104},
|
|
{122, 209, 81}, {165, 219, 54}, {210, 226, 27}, {253, 231, 37},
|
|
}
|
|
|
|
func Viridis(t float64) [3]float64 { return sampleStops(viridisStops, t) }
|
|
|
|
// Inferno, for anything where "how much" reads better as heat: slope and local relief.
|
|
var infernoStops = [][3]float64{
|
|
{0, 0, 4}, {12, 8, 38}, {36, 12, 79}, {66, 10, 104},
|
|
{93, 18, 110}, {120, 28, 109}, {147, 38, 103}, {174, 48, 92},
|
|
{199, 62, 76}, {221, 81, 58}, {237, 105, 37}, {247, 133, 17},
|
|
{251, 164, 10}, {249, 196, 41}, {243, 228, 96}, {252, 255, 164},
|
|
}
|
|
|
|
func Inferno(t float64) [3]float64 { return sampleStops(infernoStops, t) }
|
|
|
|
// Divergent is for a field with a meaningful zero and a sign: cool below, near-white at zero, warm above.
|
|
// The change map is the one that needs it — where the surf cut and where it laid are the same magnitude and
|
|
// opposite in meaning, and a sequential ramp renders them as the same colour.
|
|
func Divergent(t float64) [3]float64 { return sampleStops(divergentStops, t) }
|
|
|
|
var divergentStops = [][3]float64{
|
|
{30, 64, 120}, {64, 126, 180}, {150, 196, 220}, {238, 238, 236},
|
|
{236, 196, 140}, {206, 132, 62}, {140, 66, 22},
|
|
}
|
|
|
|
func sampleStops(s [][3]float64, t float64) [3]float64 {
|
|
if t <= 0 {
|
|
return s[0]
|
|
}
|
|
if t >= 1 {
|
|
return s[len(s)-1]
|
|
}
|
|
x := t * float64(len(s)-1)
|
|
i := int(x)
|
|
u := x - float64(i)
|
|
a, b := s[i], s[i+1]
|
|
return [3]float64{a[0] + (b[0]-a[0])*u, a[1] + (b[1]-a[1])*u, a[2] + (b[2]-a[2])*u}
|
|
}
|
|
|
|
// HSV is exported because the region map colours its regions the same way the basin map colours its basins:
|
|
// a hash of the id straight to a hue, so neighbours get unrelated colours and a boundary is a hard edge.
|
|
func HSV(hue, sat, val float64) [3]float64 { return hsv(hue, sat, val) }
|
|
|
|
func hsv(hue, sat, val float64) [3]float64 {
|
|
h6 := hue * 6
|
|
i := int(h6)
|
|
f := h6 - float64(i)
|
|
p := val * (1 - sat)
|
|
q := val * (1 - sat*f)
|
|
t := val * (1 - sat*(1-f))
|
|
var r, g, b float64
|
|
switch i % 6 {
|
|
case 0:
|
|
r, g, b = val, t, p
|
|
case 1:
|
|
r, g, b = q, val, p
|
|
case 2:
|
|
r, g, b = p, val, t
|
|
case 3:
|
|
r, g, b = p, q, val
|
|
case 4:
|
|
r, g, b = t, p, val
|
|
default:
|
|
r, g, b = val, p, q
|
|
}
|
|
return [3]float64{r * 255, g * 255, b * 255}
|
|
}
|
|
|
|
func percentiles(vals []float64, sea []bool, loPct, hiPct float64) (float64, float64) {
|
|
keep := make([]float64, 0, len(vals))
|
|
for i, v := range vals {
|
|
if sea != nil && sea[i] {
|
|
continue
|
|
}
|
|
keep = append(keep, v)
|
|
}
|
|
if len(keep) == 0 {
|
|
return 0, 1
|
|
}
|
|
sort.Float64s(keep)
|
|
at := func(p float64) float64 {
|
|
i := int(p / 100 * float64(len(keep)-1))
|
|
return keep[i]
|
|
}
|
|
return at(loPct), at(hiPct)
|
|
}
|