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();
}
}
}