using System; using System.Collections.Generic; using Godot; namespace IslaApocalypse.Tools { /// /// Draws a as a labelled plot — the picture the developer reads /// before deciding what the curve should become. /// /// ═══ WHY A PLOT AND NOT JUST THE TABLE ═══ /// /// The per-band mass table is the EVIDENCE; this is what makes the evidence obvious at a glance. /// The three candidate causes of flat upper terrain have three different SHAPES here, and the /// shape is recognisable in a second where a column of numbers takes a minute: /// /// • curve squashing — the SHAPED plot spikes hard at the bench and plateau anchors. /// Mass that was spread out arrives stacked. /// • noise empty up high — the RAW plot's right-hand tail is a long, flat, almost-invisible /// sliver. There is nothing above P96 to redistribute. /// • share allocation — neither plot is odd, and the band overlays simply show that the /// top two bands were only ever allotted 4 % of the land. /// /// ⚠ LINEAR Y, DELIBERATELY. A log axis would make the upper tail look substantial — which is /// precisely the question being asked. If the top of the distribution is a sliver, the plot must /// show a sliver. The peak bin count is printed so the vertical scale is never a mystery. /// /// ⚠ Presentation only, and it cannot be otherwise: it is handed a histogram and returns a PNG. /// It has no access to a height field and no way to produce one. /// /// Text is (5×7 bitmap), specifically so this does not drag in the /// SubViewport capture path — which awaits render frames and is why `--headless` hangs. /// ⚠ The font is uppercase, digits and . - : / ( ) only. Unsupported characters render as /// blanks, so labels here say "PCT" rather than "%" and avoid commas. /// public static class HistogramRenderer { /// A vertical reference line — a knot, or an output anchor. public sealed class Marker { public float Value; public string Label; /// Strong markers get a brighter line; use for the ones that carry the argument. public bool Strong = true; } /// A shaded span between two values, labelled underneath. The curve's bands. public sealed class Band { public float Lo, Hi; public string Label; /// Share of land in this band, in percent. Drawn under the label. public double SharePercent; } public sealed class Options { public string Title = "LAND HEIGHT DISTRIBUTION"; public string Subtitle = ""; public string Footer = ""; public string XAxisLabel = "RAW HEIGHT"; /// Right edge of the x axis, raw. Defaults to the histogram's max land height. public float XTop = 0f; public int Width = 1800; public int Height = 1000; public List Markers = new(); public List Bands = new(); } // A dark plate, so these sit beside the relief renders rather than glaring next to them. private static readonly Color Paper = new(0.098f, 0.106f, 0.125f); private static readonly Color Ink = new(0.941f, 0.949f, 0.961f); private static readonly Color Faint = new(0.565f, 0.596f, 0.643f); private static readonly Color Grid = new(0.192f, 0.208f, 0.243f); private static readonly Color BarColor = new(0.380f, 0.760f, 0.780f); // the distribution itself private static readonly Color MarkStrong = new(0.980f, 0.720f, 0.300f); // knots — the argument private static readonly Color MarkSoft = new(0.620f, 0.560f, 0.780f); // anchors — context private static readonly Color BandA = new(0.145f, 0.161f, 0.196f); private static readonly Color BandB = new(0.118f, 0.129f, 0.157f); private static readonly Color OverColor = new(0.980f, 0.560f, 0.290f); // bars that exceed the clipped axis /// /// The clipped y-axis ceiling: 3× the 90th-percentile non-empty bin, so ordinary structure /// fills the plot while a single dominating spike is cut off and MARKED. /// /// ⚠ Returns unchanged when nothing dominates — a plot is only /// clipped when clipping actually buys legibility, never as a default. The threshold is on /// the bin DISTRIBUTION rather than a fixed number so it adapts to whatever is handed in. /// private static long ClipCap(LandHistogram h, long peak) { var nonEmpty = new List(); for (int i = 0; i < h.BinCount; i++) if (h.BinCountAt(i) > 0) nonEmpty.Add(h.BinCountAt(i)); if (nonEmpty.Count < 8) return peak; nonEmpty.Sort(); long p90 = nonEmpty[(int)(nonEmpty.Count * 0.90)]; long cap = Math.Max(1, p90 * 3); // Only worth clipping if the spike really is off the scale of everything else. On a // well-spread distribution (the RAW plot) this is false and the axis stays true. return cap * 2 < peak ? cap : peak; } /// Render and save. Returns the path written. public static string SavePng(LandHistogram h, Options o, string absolutePath) { Image img = Render(h, o); Error err = img.SavePng(absolutePath); if (err != Error.Ok) GD.PrintErr($"[HistogramRenderer] SavePng failed ({err}) for {absolutePath}"); return absolutePath; } public static Image Render(LandHistogram h, Options o) { int W = o.Width, H = o.Height; var img = Image.CreateEmpty(W, H, false, Image.Format.Rgb8); img.Fill(Paper); // ---- layout, derived from text metrics rather than guessed fractions ---- const int titleScale = 4, labelScale = 2, tickScale = 2; int marginL = 130, marginR = 50; // Room for: title, subtitle, the clip banner, and TWO staggered rows of marker labels. int marginT = 40 + TinyFont.Height(titleScale) + 18 + TinyFont.Height(labelScale) + 20 + (TinyFont.Height(labelScale) + 5) * 3; // Room for: x ticks, then TWO staggered rows of two-line band labels. int marginB = 30 + TinyFont.Height(tickScale) + 14 + ((TinyFont.Height(labelScale) + 4) * 2 + 6) * 2 + 16; int plotX = marginL, plotY = marginT; int plotW = W - marginL - marginR; int plotH = H - marginT - marginB; if (plotW < 64 || plotH < 64) return img; // absurd canvas — a broken plot is worse than none float xLo = h.SeaLevel; float xHi = o.XTop > xLo ? o.XTop : (h.MaxLand > xLo ? h.MaxLand : xLo + 1f); float xSpan = xHi - xLo; int Px(float v) => plotX + (int)MathF.Round((v - xLo) / xSpan * (plotW - 1)); // ---- band shading, behind everything ---- bool alt = false; foreach (Band b in o.Bands) { int x0 = Math.Clamp(Px(b.Lo), plotX, plotX + plotW - 1); int x1 = Math.Clamp(Px(b.Hi), plotX, plotX + plotW - 1); Color c = alt ? BandA : BandB; alt = !alt; for (int x = x0; x <= x1; x++) for (int y = plotY; y < plotY + plotH; y++) img.SetPixel(x, y, c); } // ---- horizontal grid at quarters ---- for (int i = 1; i < 4; i++) { int gy = plotY + plotH - (int)(plotH * (i / 4.0)); for (int x = plotX; x < plotX + plotW; x++) img.SetPixel(x, gy, Grid); } // ---- the bars ---- // // ⚠ THE VERTICAL SCALE IS CLIPPED, AND THE CLIP IS DRAWN. The curve piles 60 % of land // into a 14 m band, so one bin can be 20× its neighbours; at full scale every other // feature — the bench bump, the plateau bump, the whole upper tail — flattens to the // axis and the plot shows one spike and nothing else. Clipping makes the rest readable; // COLOURING the clipped part and printing both numbers is what keeps it honest. A // silently truncated axis would be a lie told in the most trusted artefact in the batch. long peak = h.PeakBinCount(); long cap = ClipCap(h, peak); bool clipped = cap < peak; if (cap > 0) { for (int i = 0; i < h.BinCount; i++) { long c = h.BinCountAt(i); if (c == 0) continue; float lo = h.BinLow(i), hi = lo + h.BinWidth; if (hi < xLo || lo > xHi) continue; int x0 = Math.Clamp(Px(lo), plotX, plotX + plotW - 1); int x1 = Math.Clamp(Px(hi), plotX, plotX + plotW - 1); if (x1 < x0) x1 = x0; bool over = c > cap; int barH = (int)MathF.Round((float)(Math.Min(c, cap) / (double)cap) * (plotH - 1)); // A non-empty bin always paints at least one pixel — otherwise the thin upper // tail vanishes entirely and the plot argues the opposite of the data. if (barH < 1) barH = 1; Color c1 = over ? OverColor : BarColor; for (int x = x0; x <= x1; x++) for (int y = plotY + plotH - barH; y < plotY + plotH; y++) img.SetPixel(x, y, c1); } } // ---- axes ---- for (int x = plotX; x < plotX + plotW; x++) img.SetPixel(x, plotY + plotH, Faint); for (int y = plotY; y <= plotY + plotH; y++) img.SetPixel(plotX, y, Faint); // ---- markers (knots / anchors), over the bars ---- // ⚠ Same two-row stagger as the band labels, for the same reason: K3 and K4 are five // percentiles apart and their labels would otherwise overprint into a false reading. // The LINE is always drawn even when its label is staggered — the position is the data; // the text is the convenience. var mRowRight = new[] { int.MinValue, int.MinValue }; int mRowH = TinyFont.Height(labelScale) + 5; for (int mi = 0; mi < o.Markers.Count; mi++) { Marker m = o.Markers[mi]; if (m.Value < xLo || m.Value > xHi) continue; int mx = Math.Clamp(Px(m.Value), plotX, plotX + plotW - 1); Color c = m.Strong ? MarkStrong : MarkSoft; for (int y = plotY; y <= plotY + plotH; y++) { // Dashed for the soft ones, so a dense cluster stays readable. if (!m.Strong && ((y / 6) & 1) == 0) continue; img.SetPixel(mx, y, c); } int lw = TinyFont.Width(m.Label, labelScale); int lx = mx - lw / 2; int row = 0; if (lx <= mRowRight[0] + 8) { row = 1; if (lx <= mRowRight[1] + 8) continue; // both rows taken — the line still stands } lx = Math.Clamp(lx, plotX, plotX + plotW - lw); TinyFont.Draw(img, m.Label, lx, plotY - TinyFont.Height(labelScale) - 6 - (1 - row) * mRowH, labelScale, c); mRowRight[row] = lx + lw; } // ---- band labels + shares, under the axis ---- // // ⚠ STAGGERED ACROSS TWO ROWS, AND A COLLIDING LABEL IS DROPPED RATHER THAN OVERPRINTED. // The curve's bench and plateau bands are ~6 m wide, so at map scale their labels sit // almost on top of their neighbours: the first cut rendered "TOE/ORANGERED" and // "59.9 PCT19 PCT", which is worse than no label because it reads as a value. // A leader line ties each surviving label to its band, so a dropped one is visibly // dropped rather than silently mis-attributed. int bandLabelY = plotY + plotH + 12 + TinyFont.Height(tickScale) + 10; int rowH = (TinyFont.Height(labelScale) + 4) * 2 + 6; var rowRight = new[] { int.MinValue, int.MinValue }; for (int bi = 0; bi < o.Bands.Count; bi++) { Band b = o.Bands[bi]; int cx = (Px(b.Lo) + Px(b.Hi)) / 2; string l1 = b.Label.ToUpperInvariant(); string l2 = $"{b.SharePercent:F1} PCT"; int w = Math.Max(TinyFont.Width(l1, labelScale), TinyFont.Width(l2, labelScale)); int row = bi & 1; // stagger: alternate rows first int left = cx - w / 2; if (left <= rowRight[row] + 8) // still colliding on that row? try the other { row ^= 1; if (left <= rowRight[row] + 8) continue; // both taken — drop it, do not overprint } left = Math.Clamp(left, 2, W - w - 2); int y = bandLabelY + row * rowH; // Leader line from the band's centre down to its label. for (int ly = plotY + plotH + 2; ly < y - 2; ly++) if ((ly & 1) == 0) img.SetPixel(Math.Clamp(cx, 0, W - 1), ly, Grid); TinyFont.Draw(img, l1, left, y, labelScale, Faint); TinyFont.Draw(img, l2, left, y + TinyFont.Height(labelScale) + 4, labelScale, Ink); rowRight[row] = left + w; } // ---- x ticks ---- int tickCount = 8; for (int i = 0; i <= tickCount; i++) { float v = xLo + xSpan * i / tickCount; int tx = Px(v); for (int t = 0; t < 6; t++) img.SetPixel(tx, plotY + plotH + t, Faint); string lab = v.ToString("0.00"); int lw = TinyFont.Width(lab, tickScale); TinyFont.Draw(img, lab, Math.Clamp(tx - lw / 2, 2, W - lw - 2), plotY + plotH + 10, tickScale, Faint); } // ---- y axis: the scale, and the clip if there is one ---- TinyFont.Draw(img, Thousands(cap), 8, plotY - 4, tickScale, clipped ? OverColor : Faint); TinyFont.Draw(img, "0", 8, plotY + plotH - TinyFont.Height(tickScale), tickScale, Faint); TinyFont.Draw(img, "COUNT", 8, plotY + plotH / 2, tickScale, Faint); if (clipped) { // Said twice, on the image, in the clip's own colour — because a reader who misses // this misreads the whole plot. TinyFont.Draw(img, "CLIPPED", 8, plotY + 6 + TinyFont.Height(tickScale), tickScale, OverColor); TinyFont.Draw(img, $"TRUE PEAK {Thousands(peak)} - AMBER BARS EXCEED THE CLIPPED AXIS", plotX, plotY - TinyFont.Height(labelScale) - 6 - mRowH * 2 - 6, labelScale, OverColor); } // ---- titles ---- TinyFont.Draw(img, o.Title, marginL, 40, titleScale, Ink); if (!string.IsNullOrEmpty(o.Subtitle)) TinyFont.Draw(img, o.Subtitle, marginL, 40 + TinyFont.Height(titleScale) + 14, labelScale, Faint); // ---- footer ---- if (!string.IsNullOrEmpty(o.Footer)) TinyFont.Draw(img, o.Footer, marginL, H - TinyFont.Height(labelScale) - 20, labelScale, Faint); TinyFont.Draw(img, o.XAxisLabel, plotX + plotW - TinyFont.Width(o.XAxisLabel, labelScale), H - TinyFont.Height(labelScale) - 20, labelScale, Faint); return img; } /// /// Group digits with spaces. ⚠ Not commas: the font has no comma glyph, so "1,234" would /// render as "1 234" anyway — better to mean it than to have it happen. /// private static string Thousands(long v) { string s = v.ToString(); var sb = new System.Text.StringBuilder(); for (int i = 0; i < s.Length; i++) { if (i > 0 && (s.Length - i) % 3 == 0) sb.Append(' '); sb.Append(s[i]); } return sb.ToString(); } } }