using System; using System.Collections.Generic; using Godot; using IslaApocalypse.Core; namespace IslaApocalypse.Tools { /// /// 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. /// 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); /// log(1 + acc) / log(1 + max) over land; ocean / enclosed water flat. 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; } /// /// 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. /// 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; } /// The candidates over a faint terrain. 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. /// /// ⭐ 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. /// public static Image PromotionCandidates(List 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; } /// /// ⭐ 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. /// public static Image PromotedRivers(List 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. /// /// ⭐ THE FIXED WIDTH→DRAINAGE CONSTANT — stem width in px per √(drainage px). /// /// 1/180. 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 2115/180 ≈ 11.8 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 k·√area 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.)* /// public const float StemWidthPerSqrtPx = 1f / 180f; /// /// 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. reports whether any /// drawn river hit it, so the plate never quietly flatters a thread. /// public const int StemWidthMinPx = 2; public const int StemWidthMaxPx = 16; /// The fixed law, evaluated. NEVER normalised against the plate's own contents. public static int StemWidthFixed(long drainagePx) { int w = (int)MathF.Round(MathF.Sqrt(MathF.Max(0f, drainagePx)) * StemWidthPerSqrtPx); return Math.Clamp(w, StemWidthMinPx, StemWidthMaxPx); } /// True when this river is drawn at the legibility floor, i.e. no thinner than shown. public static bool StemWidthAtFloor(long drainagePx) => StemWidthFixed(drainagePx) <= StemWidthMinPx; /// The law as printed on the plate and in the INDEX — the constant is auditable, not implied. public static string StemWidthLaw() => $"W PX = CLAMP(ROUND(SQRT(DRAINAGE PX) X {StemWidthPerSqrtPx:F6}), {StemWidthMinPx}, {StemWidthMaxPx})"; /// Compact drainage label: 2.33M / 736K / 4210 — the font has no lowercase. 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(); /// /// ⭐⭐ ONE COMPOSITION OF N RIVERS, on the shared faint base, at the FIXED width scale, with /// per-river size labels. /// /// Identical in style to — 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. /// public static Image RiverComposition(List 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(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(); 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; int lineH = TinyFont.Height(ls); var placed = new List(); // Reserve the legend block so a river label never lands under the header text. placed.Add(new Rect2I(0, 0, n, 12 + (TinyFont.Height(ls) + 6) * 7)); int dropped = 0; foreach (var c in toLabel) { string txt = $"{DrainageLabel(c.DrainagePx)} R{c.Rank}"; int w = TinyFont.Width(txt, ls), h = lineH; int pad = 4 * (ls >= 4 ? 2 : 1); int gap = mark + 10; // right, left, below, above, then pushed further out — first clear slot wins. var tries = new (int x, int y)[] { (c.TermX + gap, c.TermY - h / 2), (c.TermX - gap - w, c.TermY - h / 2), (c.TermX - w / 2, c.TermY + gap), (c.TermX - w / 2, c.TermY - gap - h), (c.TermX + gap * 2 + w / 2, c.TermY - h / 2), (c.TermX - gap * 2 - w - w / 2, c.TermY - h / 2), (c.TermX - w / 2, c.TermY + gap * 2 + h), (c.TermX - w / 2, c.TermY - gap * 2 - h * 2), }; bool ok = false; 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, ls, c.IsSea ? Trunk : Giant); placed.Add(box); ok = true; break; } if (!ok) 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); } /// /// ⭐ 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. /// public static Image Distribution(List 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); } } } }