NOT a port. Each fix corrects a faithful reference behaviour that produced a
physically-wrong result, on the developer's explicit call. basinHasLake is KEPT
as the sort. Courses only: no height mutated, no water filled or created —
asserted per seed by a raw-bit digest of both height fields.
- FIX 1 rim cap (ISLA_RIM_CAP_M, default 30 m): a route to the sea that must
climb higher than this above its terminal is refused; the river ends at its
own terminal. Reference routes at any cost (rivers/03 found a 66.7 m one).
- FIX 3 lake targets: a router stops at the nearer of {ocean, significant lake},
so it cannot skirt a lake to reach a distant coast. Reference targets ocean only.
- FIX 2 confluence: courses laid biggest-first join on TRUE cell intersection
(never proximity); the smaller becomes a tributary and adopts the bigger one's
downstream and terminus. Reference lays routes independently — rivers/03 found
two rivers at the identical ocean cell on every seed.
All three are off by default (RiverRouting.Options.Faithful), so rivers/03 still
reproduces bit-for-bit from the same tool.
Taste gate: nothing locked, nothing graduated.
847 lines
41 KiB
C#
847 lines
41 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using Godot;
|
|
using IslaApocalypse.Core;
|
|
|
|
namespace IslaApocalypse.Tools
|
|
{
|
|
/// <summary>
|
|
/// THE DRAINAGE MAPS (chat2/12) — presentation only, for eyeballing that the flow is sane:
|
|
///
|
|
/// • the LOG-SCALED ACCUMULATION map — drainage spans orders of magnitude, so log(1+acc) over land;
|
|
/// the dendritic uplands and the trunks read as bright channels on dark hillslopes; the ocean is a
|
|
/// flat dark blue and enclosed (non-ocean) water a dark teal, so the ocean identity is visible too;
|
|
/// • the PROMOTED-CANDIDATES overlay — a faint grey terrain, the sea-reaching trunks in cyan (outlet
|
|
/// square, mountain-exit white ring, lean tributaries thin), the endorheic giants in orange (pooling
|
|
/// terminal disc, lean tributaries thin), the lean endorheic terminals as red rings. Provisional
|
|
/// routes are NOT drawn (routing is a later task). Nothing here touches data.
|
|
/// </summary>
|
|
public static class DrainageRenderer
|
|
{
|
|
private static readonly Color Ocean = new(0.055f, 0.110f, 0.235f);
|
|
private static readonly Color Enclosed = new(0.060f, 0.220f, 0.230f);
|
|
private static readonly Color Trunk = new(0.250f, 0.900f, 1.000f);
|
|
private static readonly Color Giant = new(1.000f, 0.600f, 0.150f);
|
|
private static readonly Color Endo = new(1.000f, 0.250f, 0.250f);
|
|
private static readonly Color Exit = new(1.000f, 1.000f, 1.000f);
|
|
private static readonly Color Ink = new(0.941f, 0.949f, 0.961f);
|
|
|
|
/// <summary>log(1 + acc) / log(1 + max) over land; ocean / enclosed water flat.</summary>
|
|
public static Image Accumulation(int[] acc, bool[] isOcean, float[,] render, int n, float sea)
|
|
{
|
|
long max = 1;
|
|
for (int i = 0; i < acc.Length; i++) if (acc[i] > max) max = acc[i];
|
|
double lmax = Math.Log(1.0 + max);
|
|
var img = Image.CreateEmpty(n, n, false, Image.Format.Rgb8);
|
|
for (int x = 0; x < n; x++)
|
|
for (int y = 0; y < n; y++)
|
|
{
|
|
int i = x * n + y;
|
|
if (isOcean[i]) { img.SetPixel(x, y, Ocean); continue; }
|
|
if (render[x, y] < sea) { img.SetPixel(x, y, Enclosed); continue; }
|
|
float v = (float)(Math.Log(1.0 + acc[i]) / lmax);
|
|
// a dark-to-bright ramp with a cool tint in the channels
|
|
float r = 0.06f + 0.94f * v * v, g = 0.08f + 0.92f * v, b = 0.12f + 0.88f * MathF.Sqrt(v);
|
|
img.SetPixel(x, y, new Color(MathF.Min(1f, r), MathF.Min(1f, g), MathF.Min(1f, b)));
|
|
}
|
|
return img;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The faint grey terrain every overlay map is drawn on — ocean flat dark blue, enclosed
|
|
/// (non-ocean) water dark teal, land a shallow sqrt ramp. Factored out at rivers/02 so the
|
|
/// promotion maps sit on the SAME base as the chat2/12 candidates map and can be compared
|
|
/// without the eye correcting for two different backgrounds.
|
|
/// </summary>
|
|
public static Image TerrainBase(bool[] isOcean, float[,] render, int n, float sea, float hMax)
|
|
{
|
|
var img = Image.CreateEmpty(n, n, false, Image.Format.Rgb8);
|
|
float span = MathF.Max(1e-6f, hMax - sea);
|
|
for (int x = 0; x < n; x++)
|
|
for (int y = 0; y < n; y++)
|
|
{
|
|
int i = x * n + y;
|
|
if (isOcean[i]) { img.SetPixel(x, y, Ocean); continue; }
|
|
if (render[x, y] < sea) { img.SetPixel(x, y, Enclosed); continue; }
|
|
float t = MathF.Min(1f, (render[x, y] - sea) / span);
|
|
float g = 0.30f + 0.45f * MathF.Sqrt(t);
|
|
img.SetPixel(x, y, new Color(g, g, g * 0.96f));
|
|
}
|
|
return img;
|
|
}
|
|
|
|
/// <summary>The candidates over a faint terrain.</summary>
|
|
public static Image Candidates(DrainageAnalysis.Plan plan, bool[] isOcean, float[,] render, int n, float sea, float hMax, string title)
|
|
{
|
|
var img = TerrainBase(isOcean, render, n, sea, hMax);
|
|
|
|
int thick = n >= 4096 ? 5 : 3, thin = n >= 4096 ? 3 : 2, mark = n >= 4096 ? 18 : 10;
|
|
foreach (var g in plan.Giants)
|
|
{
|
|
foreach (var tr in g.Tributaries) Polyline(img, tr.Course, n, Giant, thin);
|
|
Polyline(img, g.Course, n, Giant, thick);
|
|
Disc(img, (int)g.Terminal.x, (int)g.Terminal.y, mark, n, Giant);
|
|
Ring(img, (int)g.Terminal.x, (int)g.Terminal.y, mark + 8, n, Ink, 3);
|
|
if (g.ExitFound) Ring(img, (int)g.MountainExit.x, (int)g.MountainExit.y, mark, n, Exit, 4);
|
|
}
|
|
foreach (var t in plan.Trunks)
|
|
{
|
|
foreach (var tr in t.Tributaries) Polyline(img, tr.Course, n, Trunk, thin);
|
|
Polyline(img, t.Course, n, Trunk, thick);
|
|
Square(img, (int)t.Outlet.x, (int)t.Outlet.y, mark, n, Trunk);
|
|
if (t.ExitFound) Ring(img, (int)t.MountainExit.x, (int)t.MountainExit.y, mark, n, Exit, 4);
|
|
}
|
|
foreach (var e in plan.Endorheics)
|
|
Ring(img, (int)e.Terminal.x, (int)e.Terminal.y, mark + 4, n, Endo, 4);
|
|
|
|
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
|
|
TinyFont.Draw(img, title, 12, 12, s, Ink);
|
|
TinyFont.Draw(img, $"CYAN: SEA-REACHING TRUNKS ({plan.Trunks.Count}) - SQUARE = OUTLET WHITE RING = MOUNTAIN EXIT", 12, 12 + lh, s, Ink);
|
|
TinyFont.Draw(img, $"ORANGE: ENDORHEIC GIANTS ({plan.Giants.Count}) - DISC = POOLING TERMINAL (EXPECTED, NOT AN ERROR)", 12, 12 + lh * 2, s, Ink);
|
|
TinyFont.Draw(img, $"RED RING: LEAN ENDORHEIC TERMINALS ({plan.Endorheics.Count}) THIN LINES: LEAN TRIBUTARIES NOTHING CARVED - ANALYSIS ONLY", 12, 12 + lh * 3, s, Ink);
|
|
return img;
|
|
}
|
|
|
|
// ═══ ⭐ THE PROMOTION MAPS (rivers/02) — the count decision, on the map ═══════════════════
|
|
//
|
|
// Two views, same base, same colour law:
|
|
// SEA-REACHING cyan (as chat2/12's trunks)
|
|
// ENDORHEIC orange (as chat2/12's giants)
|
|
// so a reader carrying chat2/12 in their eye reads these without relearning anything.
|
|
//
|
|
// ⚠⚠ NEITHER MAP DRAWS `Giant.ProvisionalRoute`. That steepest-descent placeholder — the
|
|
// visible "comb" of parallel threads on the flats — is rivers/03's job to replace, and drawing
|
|
// it here would make a count look like a river network it is not. What IS drawn is the REAL
|
|
// upland stem: the max-accumulation course traced through erosion-carved valleys.
|
|
|
|
/// <summary>
|
|
/// ⭐ THE DIAGNOSTIC MAP — every candidate above the floor, marker AREA ∝ drainage area,
|
|
/// colour by terminus. Answers "where are the big drainages, and is the spread north/south?"
|
|
/// before any count is chosen.
|
|
///
|
|
/// ⚠ Marker radius scales as √area so the MARKER'S AREA is proportional to the drainage area —
|
|
/// scaling the radius linearly would exaggerate the big ones quadratically and make a knee look
|
|
/// like a cliff.
|
|
/// </summary>
|
|
public static Image PromotionCandidates(List<RiverCandidate> ranked, Image img, int n, string title, int[] ladder)
|
|
{
|
|
if (ranked.Count == 0) return img;
|
|
|
|
long maxArea = 1;
|
|
foreach (var c in ranked) if (c.DrainagePx > maxArea) maxArea = c.DrainagePx;
|
|
float rMax = n >= 4096 ? 46f : 22f, rMin = n >= 4096 ? 6f : 3f;
|
|
int ringW = n >= 4096 ? 4 : 2;
|
|
|
|
// Draw smallest-first so a big marker never hides behind a small one.
|
|
for (int i = ranked.Count - 1; i >= 0; i--)
|
|
{
|
|
var c = ranked[i];
|
|
float f = MathF.Sqrt((float)c.DrainagePx / maxArea); // area ∝ drainage
|
|
int r = (int)MathF.Round(rMin + (rMax - rMin) * f);
|
|
Color col = c.IsSea ? Trunk : Giant;
|
|
Disc(img, c.X, c.Y, r, n, col);
|
|
Ring(img, c.X, c.Y, r + ringW + 1, n, Ink, ringW); // ink halo: legible on any ground
|
|
}
|
|
|
|
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
|
|
int nSea = 0; foreach (var c in ranked) if (c.IsSea) nSea++;
|
|
TinyFont.Draw(img, title, 12, 12, s, Ink);
|
|
TinyFont.Draw(img, $"ALL {ranked.Count} CANDIDATES ABOVE THE FLOOR - MARKER AREA IS PROPORTIONAL TO DRAINAGE AREA", 12, 12 + lh, s, Ink);
|
|
TinyFont.Draw(img, $"CYAN: SEA-REACHING ({nSea}) ORANGE: ENDORHEIC ({ranked.Count - nSea}) - AN INLAND TERMINUS IS A PASS, NOT A FALLBACK", 12, 12 + lh * 2, s, Ink);
|
|
TinyFont.Draw(img, $"NOTHING IS PROMOTED HERE - THIS IS THE DISTRIBUTION THE COUNT ({Join(ladder)}) IS CHOSEN FROM", 12, 12 + lh * 3, s, Ink);
|
|
return img;
|
|
}
|
|
|
|
/// <summary>
|
|
/// ⭐ THE A/B PLATE — the unified top-N promoted, real upland stems, width ∝ drainage area,
|
|
/// terminus markers coloured by type. One plate per N; the developer picks by comparing them.
|
|
/// </summary>
|
|
public static Image PromotedRivers(List<RiverCandidate> promoted, Image img, int n,
|
|
int nPromoted, long floorPx, string title)
|
|
{
|
|
if (promoted.Count == 0) return img;
|
|
|
|
long maxArea = 1;
|
|
foreach (var c in promoted) if (c.DrainagePx > maxArea) maxArea = c.DrainagePx;
|
|
float wMax = n >= 4096 ? 11f : 6f, wMin = n >= 4096 ? 3f : 2f;
|
|
int mark = n >= 4096 ? 18 : 10;
|
|
|
|
// Smallest first, so the biggest rivers finish on top.
|
|
for (int i = promoted.Count - 1; i >= 0; i--)
|
|
{
|
|
var c = promoted[i];
|
|
if (c.Course == null || c.Course.Count < 2) continue;
|
|
float f = MathF.Sqrt((float)c.DrainagePx / maxArea);
|
|
int w = (int)MathF.Round(wMin + (wMax - wMin) * f);
|
|
Polyline(img, c.Course, n, c.IsSea ? Trunk : Giant, w);
|
|
}
|
|
// ⚠ Marked at the RIVER's terminus (where its stem pools), NOT at the basin's deepest cell —
|
|
// on a flat basin floor those differ, and marking the deepest cell draws the stem visibly
|
|
// detached from its own endpoint. → RiverCandidate.TermX.
|
|
foreach (var c in promoted)
|
|
{
|
|
if (c.IsSea) Square(img, c.TermX, c.TermY, mark, n, Trunk);
|
|
else { Disc(img, c.TermX, c.TermY, mark, n, Giant); Ring(img, c.TermX, c.TermY, mark + 8, n, Ink, 3); }
|
|
}
|
|
|
|
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
|
|
int nSea = 0; foreach (var c in promoted) if (c.IsSea) nSea++;
|
|
TinyFont.Draw(img, title, 12, 12, s, Ink);
|
|
TinyFont.Draw(img, $"UNIFIED TOP {nPromoted} BY DRAINAGE AREA - THE SEA/ENDORHEIC SPLIT FELL OUT, IT WAS NOT QUOTA'D", 12, 12 + lh, s, Ink);
|
|
TinyFont.Draw(img, $"CYAN SQUARE: SEA OUTLET ({nSea}) ORANGE DISC: ENDORHEIC TERMINUS ({promoted.Count - nSea}) STEM WIDTH IS PROPORTIONAL TO DRAINAGE", 12, 12 + lh * 2, s, Ink);
|
|
TinyFont.Draw(img, $"REAL UPLAND STEMS ONLY - NO LOWLAND ROUTING, NO WATER, NOTHING CARVED (FLOOR {floorPx:N0} PX)", 12, 12 + lh * 3, s, Ink);
|
|
return img;
|
|
}
|
|
|
|
// ═══ ⭐⭐ THE COMPOSITION PLATE (rivers/02b) — pure ranking vs a gameplay sea-river floor ═══
|
|
//
|
|
// rivers/02 established that the count is a DESIGN choice (the distribution is a power law) and
|
|
// that this terrain's honest top-of-distribution is INLAND-DOMINANT. rivers/02b keeps the total
|
|
// fixed and asks one question the developer stated: **do 3 FORCED sea rivers read as real
|
|
// rivers, just smaller — or as sad thin threads beside the big inland ones?**
|
|
//
|
|
// ⚠⚠ THAT QUESTION CANNOT BE ASKED ON A PER-PLATE-NORMALISED PLATE, and `PromotedRivers` above
|
|
// normalises to the widest river ON ITS OWN PLATE. Under that law the quota plate would rescale
|
|
// itself around whatever it happens to contain, so a forced sea river drawn "thin" would be
|
|
// reporting the plate's contents, not the river's size — and drawn beside a plate that rescaled
|
|
// differently, the comparison is meaningless. THE ONE THING THIS PLATE MUST NOT DO.
|
|
//
|
|
// So the composition plates use ONE ABSOLUTE width→drainage constant, below, shared by both
|
|
// compositions and all four seeds. A thin river is thin because it IS smaller. The constant is
|
|
// printed on every plate and reported in the INDEX, so a reader can check the claim.
|
|
|
|
/// <summary>
|
|
/// ⭐ THE FIXED WIDTH→DRAINAGE CONSTANT — stem width in px per √(drainage px).
|
|
///
|
|
/// <c>1/180</c>. Chosen once, from the measured population rather than per plate: the largest
|
|
/// candidate on ANY of the eight gallery seeds is 4,474,342 px (seed `17320508`), whose √ is
|
|
/// 2,115 — so <c>2115/180 ≈ 11.8</c> lands the biggest drainage the terrain produces just under
|
|
/// the 16 px ceiling, with no clipping anywhere in the population and headroom left over.
|
|
///
|
|
/// ⭐ THE LAW IS SCALE-FREE WITH NO MAP-SIZE TERM IN IT, and that is not an oversight. Drainage
|
|
/// area scales as n², so √(drainage) scales as n — meaning <c>k·√area</c> already draws a stem
|
|
/// at the same FRACTION of the map at any size. Multiplying by n/8192 on top would make width
|
|
/// scale as n² and collapse every river onto the floor on a smaller smoke.
|
|
/// *(rivers/02 established this analysis is only valid at 8192 regardless — the params are
|
|
/// absolute pixel counts — so a smaller render is a pipeline check, never a comparison.)*
|
|
/// </summary>
|
|
public const float StemWidthPerSqrtPx = 1f / 180f;
|
|
|
|
/// <summary>
|
|
/// Legibility clamp on the fixed law. ⚠ The MINIMUM is a deliberate, reported distortion: a
|
|
/// 1 px line at 8192 is invisible at any zoom a person actually looks at a plate with, so the
|
|
/// smallest rivers are drawn at 2 px rather than truthfully thinner. Any river AT the floor is
|
|
/// therefore "at least this thin, possibly thinner" — which matters here, because the floor is
|
|
/// exactly where the "thread" verdict lives. <see cref="StemWidthAtFloor"/> reports whether any
|
|
/// drawn river hit it, so the plate never quietly flatters a thread.
|
|
/// </summary>
|
|
public const int StemWidthMinPx = 2;
|
|
public const int StemWidthMaxPx = 16;
|
|
|
|
/// <summary>The fixed law, evaluated. NEVER normalised against the plate's own contents.</summary>
|
|
public static int StemWidthFixed(long drainagePx)
|
|
{
|
|
int w = (int)MathF.Round(MathF.Sqrt(MathF.Max(0f, drainagePx)) * StemWidthPerSqrtPx);
|
|
return Math.Clamp(w, StemWidthMinPx, StemWidthMaxPx);
|
|
}
|
|
|
|
/// <summary>True when this river is drawn at the legibility floor, i.e. no thinner than shown.</summary>
|
|
public static bool StemWidthAtFloor(long drainagePx) => StemWidthFixed(drainagePx) <= StemWidthMinPx;
|
|
|
|
/// <summary>The law as printed on the plate and in the INDEX — the constant is auditable, not implied.</summary>
|
|
public static string StemWidthLaw() =>
|
|
$"W PX = CLAMP(ROUND(SQRT(DRAINAGE PX) X {StemWidthPerSqrtPx:F6}), {StemWidthMinPx}, {StemWidthMaxPx})";
|
|
|
|
/// <summary>Compact drainage label: 2.33M / 736K / 4210 — the font has no lowercase.</summary>
|
|
public static string DrainageLabel(long px) =>
|
|
px >= 1_000_000 ? $"{px / 1e6:F2}M" : px >= 1_000 ? $"{(long)Math.Round(px / 1000.0)}K" : px.ToString();
|
|
|
|
/// <summary>
|
|
/// ⭐⭐ ONE COMPOSITION OF N RIVERS, on the shared faint base, at the FIXED width scale, with
|
|
/// per-river size labels.
|
|
///
|
|
/// Identical in style to <see cref="PromotedRivers"/> — same colours (cyan sea / orange
|
|
/// endorheic), same real upland stems, same terminus markers, `Giant.ProvisionalRoute` still
|
|
/// never drawn — and differs in exactly the two ways rivers/02b needs:
|
|
///
|
|
/// 1. THE FIXED WIDTH SCALE above, instead of per-plate normalisation.
|
|
/// 2. PER-RIVER LABELS: drainage area and rank in the FULL candidate distribution, so
|
|
/// "real river vs thin thread" has numbers behind the eyeball. A forced sea river reading
|
|
/// `272K R29` beside an inland `2.33M R1` tells the story before the eye does.
|
|
///
|
|
/// ⚠ Labels are placed with greedy collision avoidance against already-placed labels, on a dark
|
|
/// backing box so they are legible over both bright terrain and dark ocean. A label that cannot
|
|
/// be placed clear of the others is DROPPED rather than drawn illegibly on top of one — and the
|
|
/// legend says how many were dropped, so a missing number is never silent.
|
|
/// </summary>
|
|
public static Image RiverComposition(List<RiverCandidate> promoted, Image img, int n,
|
|
string title, string compositionLine, int candidateCount, long floorPx, bool labelAll)
|
|
{
|
|
if (promoted.Count == 0) return img;
|
|
|
|
int mark = n >= 4096 ? 18 : 10;
|
|
|
|
// Smallest first, so the biggest rivers finish on top.
|
|
var byArea = new List<RiverCandidate>(promoted);
|
|
byArea.Sort((a, b) => b.DrainagePx.CompareTo(a.DrainagePx));
|
|
for (int i = byArea.Count - 1; i >= 0; i--)
|
|
{
|
|
var c = byArea[i];
|
|
if (c.Course == null || c.Course.Count < 2) continue;
|
|
Polyline(img, c.Course, n, c.IsSea ? Trunk : Giant, StemWidthFixed(c.DrainagePx));
|
|
}
|
|
// ⚠ Marked at the RIVER's terminus (where its stem pools), NOT the basin's deepest cell —
|
|
// they differ on a flat basin floor. → RiverCandidate.TermX.
|
|
foreach (var c in byArea)
|
|
{
|
|
if (c.IsSea) Square(img, c.TermX, c.TermY, mark, n, Trunk);
|
|
else { Disc(img, c.TermX, c.TermY, mark, n, Giant); Ring(img, c.TermX, c.TermY, mark + 8, n, Ink, 3); }
|
|
}
|
|
|
|
// ---- the labels ----
|
|
// Which rivers get one: all of them when the plate can carry it, otherwise the 3 the
|
|
// judgment turns on — every sea river — plus the largest inland, for scale.
|
|
var toLabel = new List<RiverCandidate>();
|
|
if (labelAll) toLabel.AddRange(byArea);
|
|
else
|
|
{
|
|
foreach (var c in byArea) if (c.IsSea) toLabel.Add(c);
|
|
foreach (var c in byArea) if (!c.IsSea) { toLabel.Add(c); break; }
|
|
}
|
|
|
|
int ls = n >= 4096 ? 4 : 3;
|
|
var placer = new LabelPlacer(n, ls, headerLines: 7);
|
|
int dropped = 0;
|
|
foreach (var c in toLabel)
|
|
if (!placer.Place(img, $"{DrainageLabel(c.DrainagePx)} R{c.Rank}", c.TermX, c.TermY, mark,
|
|
c.IsSea ? Trunk : Giant)) dropped++;
|
|
|
|
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
|
|
int nSea = 0; long seaPx = 0, endoPx = 0;
|
|
int atFloor = 0;
|
|
foreach (var c in byArea)
|
|
{
|
|
if (c.IsSea) { nSea++; seaPx += c.DrainagePx; } else endoPx += c.DrainagePx;
|
|
if (StemWidthAtFloor(c.DrainagePx)) atFloor++;
|
|
}
|
|
TinyFont.Draw(img, title, 12, 12, s, Ink);
|
|
TinyFont.Draw(img, compositionLine, 12, 12 + lh, s, Ink);
|
|
TinyFont.Draw(img, $"CYAN SQUARE: SEA OUTLET ({nSea}, {seaPx:N0} PX) ORANGE DISC: ENDORHEIC TERMINUS ({byArea.Count - nSea}, {endoPx:N0} PX)", 12, 12 + lh * 2, s, Ink);
|
|
TinyFont.Draw(img, $"FIXED SHARED WIDTH SCALE: {StemWidthLaw()} - THE SAME CONSTANT ON EVERY PLATE AND EVERY SEED, NEVER PER-PLATE", 12, 12 + lh * 3, s, Ink);
|
|
TinyFont.Draw(img, $"SO A THIN RIVER IS THIN BECAUSE IT IS SMALLER" + (atFloor > 0 ? $" - {atFloor} RIVER(S) AT THE {StemWidthMinPx} PX LEGIBILITY FLOOR: NO THINNER THAN DRAWN" : ""), 12, 12 + lh * 4, s, Ink);
|
|
TinyFont.Draw(img, $"LABEL: DRAINAGE AREA THEN R = RANK AMONG ALL {candidateCount} CANDIDATES ABOVE THE {floorPx:N0} PX FLOOR" + (dropped > 0 ? $" ({dropped} LABEL(S) DROPPED, NO CLEAR SPACE)" : ""), 12, 12 + lh * 5, s, Ink);
|
|
TinyFont.Draw(img, "REAL UPLAND STEMS ONLY - GIANT.PROVISIONALROUTE (THE COMB) NOT DRAWN - NO ROUTING, NO WATER, NOTHING CARVED", 12, 12 + lh * 6, s, Ink);
|
|
return img;
|
|
}
|
|
|
|
private static void FillRect(Image img, Rect2I r, Color c, int n)
|
|
{
|
|
for (int x = r.Position.X; x < r.Position.X + r.Size.X; x++)
|
|
for (int y = r.Position.Y; y < r.Position.Y + r.Size.Y; y++)
|
|
if (x >= 0 && y >= 0 && x < n && y < n) img.SetPixel(x, y, c);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Greedy non-overlapping label placement on a dark backing box, so a number is legible over
|
|
/// both bright terrain and dark ocean. A label that cannot be placed clear of the others is
|
|
/// DROPPED rather than drawn illegibly on top of one — and every caller reports how many, so a
|
|
/// missing number is never silent. Shared by the composition and routed-mix plates.
|
|
/// </summary>
|
|
private sealed class LabelPlacer
|
|
{
|
|
private readonly List<Rect2I> _placed = new();
|
|
private readonly int _n, _scale, _pad;
|
|
|
|
public LabelPlacer(int n, int scale, int headerLines)
|
|
{
|
|
_n = n; _scale = scale; _pad = 4 * (scale >= 4 ? 2 : 1);
|
|
// Reserve the legend block so a river label never lands under the header text.
|
|
_placed.Add(new Rect2I(0, 0, n, 12 + (TinyFont.Height(scale) + 6) * headerLines));
|
|
}
|
|
|
|
public bool Place(Image img, string txt, int atX, int atY, int mark, Color ink)
|
|
{
|
|
int w = TinyFont.Width(txt, _scale), h = TinyFont.Height(_scale);
|
|
int gap = mark + 10;
|
|
// right, left, below, above, then pushed further out — first clear slot wins.
|
|
var tries = new (int x, int y)[]
|
|
{
|
|
(atX + gap, atY - h / 2),
|
|
(atX - gap - w, atY - h / 2),
|
|
(atX - w / 2, atY + gap),
|
|
(atX - w / 2, atY - gap - h),
|
|
(atX + gap * 2 + w / 2, atY - h / 2),
|
|
(atX - gap * 2 - w - w / 2, atY - h / 2),
|
|
(atX - w / 2, atY + gap * 2 + h),
|
|
(atX - w / 2, atY - gap * 2 - h * 2),
|
|
};
|
|
foreach (var (tx, ty) in tries)
|
|
{
|
|
int bx = Math.Clamp(tx - _pad, 0, Math.Max(0, _n - (w + _pad * 2)));
|
|
int by = Math.Clamp(ty - _pad, 0, Math.Max(0, _n - (h + _pad * 2)));
|
|
var box = new Rect2I(bx, by, w + _pad * 2, h + _pad * 2);
|
|
bool hit = false;
|
|
foreach (var q in _placed) if (box.Intersects(q)) { hit = true; break; }
|
|
if (hit) continue;
|
|
FillRect(img, box, new Color(0.04f, 0.05f, 0.07f), _n);
|
|
TinyFont.Draw(img, txt, bx + _pad, by + _pad, _scale, ink);
|
|
_placed.Add(box);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ═══ ⭐⭐ THE ROUTED MIX (rivers/03) — three classes, and where routing added the channel ═══
|
|
|
|
/// <summary>Routed giants: the natural upland stem, muted.</summary>
|
|
private static readonly Color RoutedStem = new(0.250f, 0.620f, 0.330f);
|
|
/// <summary>⭐ The LOWLAND REACH routing added — bright, so the added channel is unmistakable.</summary>
|
|
private static readonly Color RoutedReach = new(0.380f, 1.000f, 0.420f);
|
|
/// <summary>The rim the route climbed over — the point the developer is asked to judge.</summary>
|
|
private static readonly Color RimMark = new(1.000f, 0.930f, 0.350f);
|
|
|
|
/// <summary>
|
|
/// ⭐⭐ THE MIX PLATE — natural ocean trunks, routed-through giants, and inland lake-enders, on
|
|
/// the shared faint base at rivers/02b's FIXED width scale.
|
|
///
|
|
/// The one thing this plate exists to show: **which part of a routed river is terrain and which
|
|
/// part is routing.** So a routed giant is drawn in two tones of one colour — its erosion-carved
|
|
/// upland stem muted, the lowland reach the Dijkstra added bright — and the point where that
|
|
/// reach crosses its rim is ringed. A reader can then see, without reading a table, how far the
|
|
/// river was carried and how high it had to climb to get there.
|
|
///
|
|
/// ⚠⚠ `Giant.ProvisionalRoute` is NOT drawn — the real route is what replaces it.
|
|
/// </summary>
|
|
public static Image RoutedMix(List<RiverRouting.RoutedRiver> rivers, Image img, int n,
|
|
string title, string subtitle, long floorPx)
|
|
{
|
|
if (rivers.Count == 0) return img;
|
|
|
|
int mark = n >= 4096 ? 18 : 10;
|
|
var byArea = new List<RiverRouting.RoutedRiver>(rivers);
|
|
byArea.Sort((a, b) => b.Candidate.DrainagePx.CompareTo(a.Candidate.DrainagePx));
|
|
|
|
// Smallest first, so the biggest rivers finish on top.
|
|
for (int i = byArea.Count - 1; i >= 0; i--)
|
|
{
|
|
var r = byArea[i];
|
|
int w = StemWidthFixed(r.Candidate.DrainagePx);
|
|
Color stemCol = r.Class switch
|
|
{
|
|
RiverRouting.RiverClass.OceanTrunk => Trunk,
|
|
RiverRouting.RiverClass.RoutedGiant => RoutedStem,
|
|
_ => Giant,
|
|
};
|
|
// The upland stem, as erosion made it (head → terminal), reversed out of the analysis.
|
|
var stem = new List<(float x, float y)>(r.Candidate.Course);
|
|
stem.Reverse();
|
|
Polyline(img, stem, n, stemCol, w);
|
|
|
|
// The lowland reach routing added, drawn distinctly on top of its own stem.
|
|
if (r.Lowland != null && r.Lowland.Smoothed != null && r.Lowland.Smoothed.Count > 1)
|
|
{
|
|
Color reachCol = r.Class == RiverRouting.RiverClass.RoutedGiant ? RoutedReach : Giant;
|
|
Polyline(img, r.Lowland.Smoothed, n, reachCol, w);
|
|
}
|
|
}
|
|
|
|
// Terminus markers, and the rim a routed river crossed.
|
|
foreach (var r in byArea)
|
|
{
|
|
var c = r.Candidate;
|
|
switch (r.Class)
|
|
{
|
|
case RiverRouting.RiverClass.OceanTrunk:
|
|
Square(img, c.TermX, c.TermY, mark, n, Trunk);
|
|
break;
|
|
case RiverRouting.RiverClass.RoutedGiant:
|
|
if (r.Lowland != null && r.Lowland.Reached)
|
|
{
|
|
var t = r.Lowland.Target;
|
|
Square(img, (int)t.x, (int)t.y, mark, n, RoutedReach);
|
|
Ring(img, (int)t.x, (int)t.y, mark + 8, n, Ink, 3);
|
|
MarkRim(img, r.Lowland, n, mark);
|
|
}
|
|
// The basin it came FROM stays marked, so the reader sees what was connected.
|
|
Ring(img, c.TermX, c.TermY, mark, n, RoutedStem, 4);
|
|
break;
|
|
default:
|
|
Disc(img, c.TermX, c.TermY, mark, n, Giant);
|
|
Ring(img, c.TermX, c.TermY, mark + 8, n, Ink, 3);
|
|
if (r.Lowland != null && r.Lowland.Reached)
|
|
{
|
|
var t = r.Lowland.Target;
|
|
Ring(img, (int)t.x, (int)t.y, mark, n, Giant, 4);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ---- labels ----
|
|
int ls = n >= 4096 ? 4 : 3;
|
|
var placer = new LabelPlacer(n, ls, headerLines: 8);
|
|
int dropped = 0;
|
|
foreach (var r in byArea)
|
|
{
|
|
var c = r.Candidate;
|
|
string txt = r.Class switch
|
|
{
|
|
RiverRouting.RiverClass.OceanTrunk => $"{DrainageLabel(c.DrainagePx)} R{c.Rank} TRUNK",
|
|
RiverRouting.RiverClass.RoutedGiant => $"{DrainageLabel(c.DrainagePx)} R{c.Rank} RIM {(r.Lowland != null ? r.Lowland.RimClimbM : 0f):F0}M",
|
|
_ => $"{DrainageLabel(c.DrainagePx)} R{c.Rank} LAKE",
|
|
};
|
|
Color ink = r.Class switch
|
|
{
|
|
RiverRouting.RiverClass.OceanTrunk => Trunk,
|
|
RiverRouting.RiverClass.RoutedGiant => RoutedReach,
|
|
_ => Giant,
|
|
};
|
|
if (!placer.Place(img, txt, c.TermX, c.TermY, mark, ink)) dropped++;
|
|
}
|
|
|
|
int trunks = 0, routed = 0, lakes = 0;
|
|
foreach (var r in byArea)
|
|
{
|
|
if (r.Class == RiverRouting.RiverClass.OceanTrunk) trunks++;
|
|
else if (r.Class == RiverRouting.RiverClass.RoutedGiant) routed++;
|
|
else lakes++;
|
|
}
|
|
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
|
|
TinyFont.Draw(img, title, 12, 12, s, Ink);
|
|
TinyFont.Draw(img, subtitle, 12, 12 + lh, s, Ink);
|
|
TinyFont.Draw(img, $"CYAN: NATURAL OCEAN TRUNK ({trunks}) - EROSION ALREADY REACHES THE COAST, NO LOWLAND ROUTE ADDED", 12, 12 + lh * 2, s, Trunk);
|
|
TinyFont.Draw(img, $"GREEN: ROUTED-THROUGH GIANT ({routed}) - DARK = ITS NATURAL UPLAND STEM, BRIGHT = THE LOWLAND REACH ROUTING ADDED", 12, 12 + lh * 3, s, RoutedReach);
|
|
TinyFont.Draw(img, $"YELLOW RING ON A GREEN REACH = THE RIM IT CLIMBED OVER (ROUTE HIGH POINT). LABEL RIM = METRES CLIMBED FROM THE BASIN", 12, 12 + lh * 4, s, RimMark);
|
|
TinyFont.Draw(img, $"ORANGE: INLAND LAKE-ENDER ({lakes}) - DISC = ITS TERMINAL, RING = THE SIGNIFICANT WATER BODY IT JOINS", 12, 12 + lh * 5, s, Giant);
|
|
TinyFont.Draw(img, $"WIDTH: {StemWidthLaw()} - THE SAME FIXED CONSTANT AS RIVERS/02B, EVERY PLATE AND SEED", 12, 12 + lh * 6, s, Ink);
|
|
TinyFont.Draw(img, $"COURSES ONLY - NO HEIGHT MUTATED, NO WATER FILLED, NOTHING CARVED. PROVISIONALROUTE (THE COMB) NOT DRAWN." +
|
|
(dropped > 0 ? $" ({dropped} LABEL(S) DROPPED)" : ""), 12, 12 + lh * 7, s, Ink);
|
|
return img;
|
|
}
|
|
|
|
// ═══ ⭐⭐ THE REFINED MIX (rivers/03b) — five classes, and the dendritic tree ═══════════════
|
|
//
|
|
// Same base, same colours where they carry over, and the SAME fixed width scale as rivers/02b
|
|
// and rivers/03, so this plate can be laid beside `03_lowland_routing/<seed>/routed_mix.png` and
|
|
// read as a before/after rather than as two different pictures.
|
|
//
|
|
// `RoutedMix` above is left exactly as rivers/03 produced it — that batch stays reproducible.
|
|
|
|
/// <summary>⭐ rivers/03b: a router that stopped at a significant lake instead of skirting it.</summary>
|
|
private static readonly Color LakeFedStem = new(0.520f, 0.380f, 0.780f);
|
|
private static readonly Color LakeFedReach = new(0.720f, 0.560f, 1.000f);
|
|
/// <summary>⚠ rivers/03b: refused by the rim cap — it would have been an uphill river.</summary>
|
|
private static readonly Color Walled = new(0.950f, 0.330f, 0.330f);
|
|
/// <summary>Where two courses actually meet.</summary>
|
|
private static readonly Color Junction = new(1.000f, 1.000f, 1.000f);
|
|
|
|
private static (Color stem, Color reach) ClassColours(RiverRouting.RiverClass c) => c switch
|
|
{
|
|
RiverRouting.RiverClass.OceanTrunk => (Trunk, Trunk),
|
|
RiverRouting.RiverClass.RoutedGiant => (RoutedStem, RoutedReach),
|
|
RiverRouting.RiverClass.LakeFed => (LakeFedStem, LakeFedReach),
|
|
RiverRouting.RiverClass.WalledOff => (Walled, Walled),
|
|
_ => (Giant, Giant),
|
|
};
|
|
|
|
/// <summary>
|
|
/// ⭐⭐ THE RESHAPED MIX — natural trunks, routed-through, lake-fed, natural lake-enders and
|
|
/// walled-off lake-enders, drawn as a dendritic TREE rather than as independent courses.
|
|
///
|
|
/// Each river draws only its OWN reach — truncated at its confluence junction if it joined one —
|
|
/// so tributaries merge into a single downstream line instead of running as parallel duplicates.
|
|
/// A white dot marks every junction. Within a river, the natural upland stem is drawn in the
|
|
/// muted tone and the lowland reach routing added in the bright one, exactly as rivers/03.
|
|
/// </summary>
|
|
public static Image RefinedMix(List<RiverRouting.RoutedRiver> rivers, Image img, int n,
|
|
string title, string subtitle, string capLine)
|
|
{
|
|
if (rivers.Count == 0) return img;
|
|
int mark = n >= 4096 ? 18 : 10;
|
|
|
|
var byArea = new List<RiverRouting.RoutedRiver>(rivers);
|
|
byArea.Sort((a, b) => b.Candidate.DrainagePx.CompareTo(a.Candidate.DrainagePx));
|
|
|
|
// Smallest first, so the biggest rivers finish on top.
|
|
for (int i = byArea.Count - 1; i >= 0; i--)
|
|
{
|
|
var r = byArea[i];
|
|
int w = StemWidthFixed(r.Candidate.DrainagePx);
|
|
var (stemCol, reachCol) = ClassColours(r.Class);
|
|
var cells = r.CellPath;
|
|
if (cells == null || cells.Count == 0) continue;
|
|
|
|
// Its OWN reach: everything up to the junction, or the whole course if it kept its mouth.
|
|
int own = r.Joined ? OwnLength(r) : cells.Count;
|
|
int stemEnd = Math.Min(own, Math.Max(1, r.StemCells));
|
|
|
|
Polyline(img, Slice(cells, 0, stemEnd), n, stemCol, w);
|
|
if (own > stemEnd) Polyline(img, Slice(cells, stemEnd - 1, own), n, reachCol, w);
|
|
}
|
|
|
|
// Terminus markers — read through the CONFLUENCE ROOT, because a tributary's mouth is its
|
|
// trunk's mouth and marking its own truncated end would invent a terminus it does not have.
|
|
foreach (var r in byArea)
|
|
{
|
|
var c = r.Candidate;
|
|
if (r.Joined)
|
|
{
|
|
Disc(img, r.JunctionCell.x, r.JunctionCell.y, Math.Max(4, mark / 2), n, Junction);
|
|
continue;
|
|
}
|
|
var (stemCol, reachCol) = ClassColours(r.Class);
|
|
switch (r.Class)
|
|
{
|
|
case RiverRouting.RiverClass.OceanTrunk:
|
|
Square(img, c.TermX, c.TermY, mark, n, Trunk);
|
|
break;
|
|
case RiverRouting.RiverClass.RoutedGiant:
|
|
if (r.Lowland != null && r.Lowland.Reached)
|
|
{
|
|
var t = r.Lowland.Target;
|
|
Square(img, (int)t.x, (int)t.y, mark, n, reachCol);
|
|
Ring(img, (int)t.x, (int)t.y, mark + 8, n, Ink, 3);
|
|
MarkRim(img, r.Lowland, n, mark);
|
|
}
|
|
Ring(img, c.TermX, c.TermY, mark, n, stemCol, 4);
|
|
break;
|
|
case RiverRouting.RiverClass.LakeFed:
|
|
if (r.Lowland != null && r.Lowland.Reached)
|
|
{
|
|
var t = r.Lowland.Target;
|
|
Disc(img, (int)t.x, (int)t.y, mark, n, reachCol);
|
|
Ring(img, (int)t.x, (int)t.y, mark + 8, n, Ink, 3);
|
|
}
|
|
Ring(img, c.TermX, c.TermY, mark, n, stemCol, 4);
|
|
break;
|
|
case RiverRouting.RiverClass.WalledOff:
|
|
// ⚠ It ends at its own terminal. A cross-less ring plus the rim it could not clear.
|
|
Disc(img, c.TermX, c.TermY, mark, n, Walled);
|
|
Ring(img, c.TermX, c.TermY, mark + 8, n, Ink, 3);
|
|
break;
|
|
default:
|
|
Disc(img, c.TermX, c.TermY, mark, n, Giant);
|
|
Ring(img, c.TermX, c.TermY, mark + 8, n, Ink, 3);
|
|
if (r.Lowland != null && r.Lowland.Reached)
|
|
Ring(img, (int)r.Lowland.Target.x, (int)r.Lowland.Target.y, mark, n, Giant, 4);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ---- labels ----
|
|
int ls = n >= 4096 ? 4 : 3;
|
|
var placer = new LabelPlacer(n, ls, headerLines: 9);
|
|
int dropped = 0;
|
|
foreach (var r in byArea)
|
|
{
|
|
var c = r.Candidate;
|
|
var (stemCol, reachCol) = ClassColours(r.Class);
|
|
string tag = r.Class switch
|
|
{
|
|
RiverRouting.RiverClass.OceanTrunk => "TRUNK",
|
|
RiverRouting.RiverClass.RoutedGiant => $"SEA RIM {(r.Lowland != null ? r.Lowland.RimClimbM : 0f):F0}M",
|
|
RiverRouting.RiverClass.LakeFed => "LAKE-FED",
|
|
RiverRouting.RiverClass.WalledOff => $"WALLED {r.CappedRimM:F0}M",
|
|
_ => "LAKE",
|
|
};
|
|
if (r.Joined) tag += $" INTO R{r.ConfluenceParentRank}";
|
|
int lx = r.Joined ? r.JunctionCell.x : c.TermX;
|
|
int ly = r.Joined ? r.JunctionCell.y : c.TermY;
|
|
if (!placer.Place(img, $"{DrainageLabel(c.DrainagePx)} R{c.Rank} {tag}", lx, ly, mark,
|
|
r.Joined ? Junction : reachCol)) dropped++;
|
|
}
|
|
|
|
int trunks = 0, routed = 0, lakeFed = 0, natural = 0, walled = 0, joined = 0;
|
|
foreach (var r in byArea)
|
|
{
|
|
switch (r.Class)
|
|
{
|
|
case RiverRouting.RiverClass.OceanTrunk: trunks++; break;
|
|
case RiverRouting.RiverClass.RoutedGiant: routed++; break;
|
|
case RiverRouting.RiverClass.LakeFed: lakeFed++; break;
|
|
case RiverRouting.RiverClass.WalledOff: walled++; break;
|
|
default: natural++; break;
|
|
}
|
|
if (r.Joined) joined++;
|
|
}
|
|
int s2 = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s2) + 6;
|
|
TinyFont.Draw(img, title, 12, 12, s2, Ink);
|
|
TinyFont.Draw(img, subtitle, 12, 12 + lh, s2, Ink);
|
|
TinyFont.Draw(img, $"CYAN: NATURAL OCEAN TRUNK ({trunks}) GREEN: ROUTED THROUGH TO THE SEA ({routed}) - DARK = NATURAL STEM, BRIGHT = THE REACH ROUTING ADDED", 12, 12 + lh * 2, s2, Trunk);
|
|
TinyFont.Draw(img, $"VIOLET: LAKE-FED ({lakeFed}) - A DRY BASIN THAT MET A SIGNIFICANT LAKE BEFORE THE SEA AND STOPS THERE (FIX 3)", 12, 12 + lh * 3, s2, LakeFedReach);
|
|
TinyFont.Draw(img, $"RED: WALLED OFF ({walled}) - {capLine} (FIX 1)", 12, 12 + lh * 4, s2, Walled);
|
|
TinyFont.Draw(img, $"ORANGE: NATURAL LAKE-ENDER ({natural}) - ITS BASIN ALREADY HOLDS A LAKE, SO ITS RIVER FEEDS IT", 12, 12 + lh * 5, s2, Giant);
|
|
TinyFont.Draw(img, $"WHITE DOT: CONFLUENCE ({joined} JOINED) - A TRIBUTARY MERGING INTO A BIGGER RIVER, NOT A PARALLEL DUPLICATE (FIX 2)", 12, 12 + lh * 6, s2, Junction);
|
|
TinyFont.Draw(img, $"YELLOW RING = THE RIM A ROUTED RIVER CLIMBED OVER. WIDTH: {StemWidthLaw()} - AS RIVERS/02B AND 03", 12, 12 + lh * 7, s2, RimMark);
|
|
TinyFont.Draw(img, "COURSES ONLY - NO HEIGHT MUTATED, NO WATER FILLED OR CREATED, NOTHING CARVED. PROVISIONALROUTE NOT DRAWN." +
|
|
(dropped > 0 ? $" ({dropped} LABEL(S) DROPPED)" : ""), 12, 12 + lh * 8, s2, Ink);
|
|
return img;
|
|
}
|
|
|
|
/// <summary>How many leading cells of a joined river's path are its own, up to the junction.</summary>
|
|
private static int OwnLength(RiverRouting.RoutedRiver r)
|
|
{
|
|
for (int i = 0; i < r.CellPath.Count; i++)
|
|
if (r.CellPath[i].x == r.JunctionCell.x && r.CellPath[i].y == r.JunctionCell.y) return i + 1;
|
|
return r.CellPath.Count;
|
|
}
|
|
|
|
private static List<(float x, float y)> Slice(List<(int x, int y)> cells, int from, int to)
|
|
{
|
|
var outp = new List<(float x, float y)>();
|
|
for (int i = Math.Max(0, from); i < Math.Min(to, cells.Count); i++) outp.Add((cells[i].x, cells[i].y));
|
|
return outp;
|
|
}
|
|
|
|
/// <summary>Ring the route's high point — the rim the channel crosses.</summary>
|
|
private static void MarkRim(Image img, RiverRouting.Route route, int n, int mark)
|
|
{
|
|
if (route.Path == null || route.Path.Count < 2 || route.RimClimbM <= 0.01f) return;
|
|
var p = route.RimPoint;
|
|
Ring(img, (int)p.x, (int)p.y, mark - 4, n, RimMark, 4);
|
|
}
|
|
|
|
/// <summary>
|
|
/// ⭐ THE DISTRIBUTION PLOT — drainage area (log y) against rank (linear x), with the ladder
|
|
/// counts marked vertically and the analysis's own thresholds marked horizontally.
|
|
///
|
|
/// **Log y is not a presentation choice, it is the only honest one:** drainage areas span three
|
|
/// or more orders of magnitude, so on a linear axis every candidate but the top two or three
|
|
/// collapses onto the floor and the knee — the thing this plot exists to show — is invisible.
|
|
/// </summary>
|
|
public static Image Distribution(List<RiverCandidate> ranked, int[] ladder,
|
|
long endorheicMinInflowPx, long stemMinAccPx, long floorPx, string title)
|
|
{
|
|
const int W = 1600, H = 1000, L = 150, R = 40, T = 120, B = 90;
|
|
var img = Image.CreateEmpty(W, H, false, Image.Format.Rgb8);
|
|
var bg = new Color(0.07f, 0.08f, 0.10f);
|
|
for (int x = 0; x < W; x++) for (int y = 0; y < H; y++) img.SetPixel(x, y, bg);
|
|
if (ranked.Count == 0) return img;
|
|
|
|
double loMin = Math.Log10(Math.Max(1.0, Math.Min(floorPx, ranked[ranked.Count - 1].DrainagePx)));
|
|
double hiMax = Math.Log10(Math.Max(10.0, ranked[0].DrainagePx));
|
|
loMin = Math.Floor(loMin); hiMax = Math.Ceiling(hiMax);
|
|
int plotW = W - L - R, plotH = H - T - B;
|
|
int XOf(int rank) => L + (int)((rank - 1) / (double)Math.Max(1, ranked.Count - 1) * plotW);
|
|
int YOf(double area) => T + plotH - (int)((Math.Log10(Math.Max(1.0, area)) - loMin) / Math.Max(1e-9, hiMax - loMin) * plotH);
|
|
|
|
var grid = new Color(0.16f, 0.18f, 0.22f);
|
|
for (int d = (int)loMin; d <= (int)hiMax; d++) // decade gridlines
|
|
{
|
|
int y = YOf(Math.Pow(10, d));
|
|
for (int x = L; x < L + plotW; x++) if (y >= 0 && y < H) img.SetPixel(x, y, grid);
|
|
TinyFont.Draw(img, $"1E{d}", 12, Math.Max(0, y - 6), 2, new Color(0.60f, 0.64f, 0.70f));
|
|
}
|
|
// the analysis's own thresholds — so the ladder is read RELATIVE to them, not in a vacuum
|
|
DashH(img, YOf(endorheicMinInflowPx), L, L + plotW, new Color(1f, 0.45f, 0.45f));
|
|
TinyFont.Draw(img, $"ENDORHEIC MIN INFLOW {endorheicMinInflowPx:N0}", L + 8, YOf(endorheicMinInflowPx) - 22, 2, new Color(1f, 0.45f, 0.45f));
|
|
DashH(img, YOf(stemMinAccPx), L, L + plotW, new Color(0.55f, 0.85f, 0.55f));
|
|
TinyFont.Draw(img, $"STEM MIN ACC {stemMinAccPx:N0}", L + 8, YOf(stemMinAccPx) - 22, 2, new Color(0.55f, 0.85f, 0.55f));
|
|
|
|
foreach (int nn in ladder) // the ladder counts
|
|
{
|
|
if (nn < 1 || nn > ranked.Count) continue;
|
|
int x = XOf(nn);
|
|
for (int y = T; y < T + plotH; y += 6)
|
|
for (int k = 0; k < 3 && y + k < T + plotH; k++) img.SetPixel(x, y + k, new Color(0.95f, 0.90f, 0.35f));
|
|
TinyFont.Draw(img, $"N={nn}", x + 6, T + 6, 3, new Color(0.95f, 0.90f, 0.35f));
|
|
TinyFont.Draw(img, $"{ranked[nn - 1].DrainagePx:N0}", x + 6, T + 6 + TinyFont.Height(3) + 4, 2, new Color(0.95f, 0.90f, 0.35f));
|
|
}
|
|
|
|
for (int i = 0; i < ranked.Count; i++) // the candidates
|
|
{
|
|
var c = ranked[i];
|
|
int x = XOf(i + 1), y = YOf(c.DrainagePx);
|
|
Color col = c.IsSea ? Trunk : Giant;
|
|
for (int ox = -3; ox <= 3; ox++)
|
|
for (int oy = -3; oy <= 3; oy++)
|
|
{
|
|
if (ox * ox + oy * oy > 9) continue;
|
|
int px = x + ox, py = y + oy;
|
|
if (px >= 0 && py >= 0 && px < W && py < H) img.SetPixel(px, py, col);
|
|
}
|
|
}
|
|
|
|
TinyFont.Draw(img, title, 12, 12, 3, Ink);
|
|
TinyFont.Draw(img, "DRAINAGE AREA (PX, LOG) VS UNIFIED RANK - CYAN SEA-REACHING, ORANGE ENDORHEIC", 12, 12 + TinyFont.Height(3) + 8, 2, Ink);
|
|
TinyFont.Draw(img, $"{ranked.Count} CANDIDATES ABOVE THE {floorPx:N0} PX FLOOR - A KNEE IS A SHARP DROP; A SMOOTH CURVE MEANS THE TERRAIN HAS NO NATURAL COUNT", 12, H - 34, 2, new Color(0.70f, 0.74f, 0.80f));
|
|
return img;
|
|
}
|
|
|
|
private static string Join(int[] v)
|
|
{
|
|
var sb = new System.Text.StringBuilder();
|
|
for (int i = 0; i < v.Length; i++) { if (i > 0) sb.Append('/'); sb.Append(v[i]); }
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static void DashH(Image img, int y, int x0, int x1, Color c)
|
|
{
|
|
if (y < 0 || y >= img.GetHeight()) return;
|
|
for (int x = x0; x < x1; x += 14)
|
|
for (int k = 0; k < 8 && x + k < x1; k++) img.SetPixel(x + k, y, c);
|
|
}
|
|
|
|
private static void Polyline(Image img, List<(float x, float y)> pts, int n, Color c, int thick)
|
|
{
|
|
for (int i = 1; i < pts.Count; i++)
|
|
Line(img, (int)pts[i - 1].x, (int)pts[i - 1].y, (int)pts[i].x, (int)pts[i].y, n, c, thick);
|
|
}
|
|
|
|
private static void Line(Image img, int x0, int y0, int x1, int y1, int n, Color c, int thick)
|
|
{
|
|
int dx = Math.Abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
|
|
int dy = -Math.Abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
|
|
int err = dx + dy; int r = thick / 2;
|
|
int guard = 0;
|
|
while (true)
|
|
{
|
|
for (int ox = -r; ox <= r; ox++)
|
|
for (int oy = -r; oy <= r; oy++)
|
|
{
|
|
int px = x0 + ox, py = y0 + oy;
|
|
if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c);
|
|
}
|
|
if (x0 == x1 && y0 == y1) break;
|
|
if (++guard > 4 * n) break;
|
|
int e2 = 2 * err;
|
|
if (e2 >= dy) { err += dy; x0 += sx; }
|
|
if (e2 <= dx) { err += dx; y0 += sy; }
|
|
}
|
|
}
|
|
|
|
private static void Disc(Image img, int cx, int cy, int r, int n, Color c)
|
|
{
|
|
for (int ox = -r; ox <= r; ox++)
|
|
for (int oy = -r; oy <= r; oy++)
|
|
{
|
|
if (ox * ox + oy * oy > r * r) continue;
|
|
int px = cx + ox, py = cy + oy;
|
|
if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c);
|
|
}
|
|
}
|
|
|
|
private static void Ring(Image img, int cx, int cy, int r, int n, Color c, int w)
|
|
{
|
|
for (int ox = -r; ox <= r; ox++)
|
|
for (int oy = -r; oy <= r; oy++)
|
|
{
|
|
int d2 = ox * ox + oy * oy;
|
|
if (d2 > r * r || d2 < (r - w) * (r - w)) continue;
|
|
int px = cx + ox, py = cy + oy;
|
|
if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c);
|
|
}
|
|
}
|
|
|
|
private static void Square(Image img, int cx, int cy, int r, int n, Color c)
|
|
{
|
|
for (int ox = -r; ox <= r; ox++)
|
|
for (int oy = -r; oy <= r; oy++)
|
|
{
|
|
int px = cx + ox, py = cy + oy;
|
|
if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c);
|
|
}
|
|
}
|
|
}
|
|
}
|