diff --git a/readme.md b/readme.md index 9085b14..a823cd6 100644 --- a/readme.md +++ b/readme.md @@ -52,7 +52,14 @@ Convert a file to another format (both formats inferred from their extensions): GeoConverter.Convert("cities.geojson", "cities.kml"); GeoConverter.Convert("roads.shp", "roads.fgb"); ``` -snippet source | anchor +snippet source | anchor + +```cs +// Formats are inferred from the file extensions. +GeoConverter.Convert("cities.geojson", "cities.kml"); +GeoConverter.Convert("roads.shp", "roads.fgb"); +``` +snippet source | anchor Read into the common feature model, then write a different format: @@ -74,7 +81,24 @@ foreach (var feature in collection) // Write it back out as a different format. GeoConverter.Write(collection, "roads.fgb"); ``` -snippet source | anchor +snippet source | anchor + +```cs +// Read any supported format into the common feature model. +var collection = GeoConverter.Read("roads.shp"); + +foreach (var feature in collection) +{ + if (feature.Properties.TryGetValue("name", out var name)) + { + Console.WriteLine(name); + } +} + +// Write it back out as a different format. +GeoConverter.Write(collection, "roads.fgb"); +``` +snippet source | anchor Build a collection in memory and serialize it: @@ -94,7 +118,22 @@ var collection = new FeatureCollection var geoJson = GeoJson.WriteString(collection); ``` -snippet source | anchor +snippet source | anchor + +```cs +var collection = new FeatureCollection +{ + new Feature( + new Point(new(151.21, -33.87)), + new Dictionary + { + ["name"] = "Sydney" + }), +}; + +var geoJson = GeoJson.WriteString(collection); +``` +snippet source | anchor @@ -135,7 +174,36 @@ var countries = GeoConverter.Read("countries.geojson"); var topo = Simplifier.SimplifyTopology(countries, 0.05); GeoConverter.Write(topo, "countries-thin.fgb"); ``` -snippet source | anchor +snippet source | anchor + +```cs +// Reduce vertex count before writing — the highest-leverage way to shrink dense vector data. +// Simplifier.Simplify returns a NEW collection (the input is left untouched), preserving layer +// names, properties, feature ids and structure; only line and polygon-ring vertices are thinned. +var collection = GeoConverter.Read("coastline.geojson"); + +// Douglas–Peucker (the default): tolerance is a perpendicular distance in coordinate units +// (degrees for WGS84). Vertices within that distance of the retained line are dropped. +var coarse = Simplifier.Simplify(collection, 0.01); +GeoConverter.Write(coarse, "coastline.topojson"); + +// Visvalingam–Whyatt: tolerance is an effective triangle area (degrees²) — tends to give a +// smoother, more evenly generalised outline. Points pass through untouched; polygon rings stay +// closed and never collapse below a triangle, so the result is always valid. +var smooth = Simplifier.Simplify(collection, 0.0001, SimplifyMethod.Visvalingam); +GeoConverter.Write(smooth, "coastline-vw.geojson"); + +// SimplifyTopology: same algorithms, but adjacent polygons that share a border get that +// border simplified once — so the two sides stay seamlessly joined. The plain overload thins +// each ring independently; two countries' shared edges then get different chord choices and +// no longer line up, leaving hairline gaps (visible as white stripes between countries) or +// alpha-stacked overlaps when the fill is translucent. Pick this for topologically +// consistent datasets like Natural Earth admin layers where shared boundaries matter. +var countries = GeoConverter.Read("countries.geojson"); +var topo = Simplifier.SimplifyTopology(countries, 0.05); +GeoConverter.Write(topo, "countries-thin.fgb"); +``` +snippet source | anchor @@ -163,7 +231,27 @@ GeoConverter.Convert("countries.geojson", "countries.fgb", progress); var features = GeoConverter.Read("countries.geojson"); MapRenderer.RenderPng(features, "world.png", new() { Progress = progress }); ``` -snippet source | anchor +snippet source | anchor + +```cs +// Read, Write and Convert each take an optional IProgress. Convert reports the +// read half under ProgressPhase.Reading and the write half under ProgressPhase.Writing. Every +// report carries a feature count and a byte count; ConvertProgress.Fraction picks whichever +// total is known (features when writing, bytes when reading a seekable source) and returns null +// when neither is — an honest "indeterminate" rather than a fabricated percentage. +var progress = new Progress(report => +{ + var percent = report.Fraction is { } fraction ? $"{fraction:P0}" : "?"; + Console.WriteLine($"{report.Phase}: {report.Features} features ({percent})"); +}); + +GeoConverter.Convert("countries.geojson", "countries.fgb", progress); + +// PNG rendering reports through RenderOptions.Progress (one report per feature rasterised). +var features = GeoConverter.Read("countries.geojson"); +MapRenderer.RenderPng(features, "world.png", new() { Progress = progress }); +``` +snippet source | anchor @@ -186,7 +274,22 @@ var options = new RenderOptions MapRenderer.RenderPng(features, "europe.png", options); ``` -snippet source | anchor +snippet source | anchor + +```cs +var features = GeoConverter.Read("countries.geojson"); + +// Render a specific bounding box (min lon, min lat, max lon, max lat) to a PNG. +var options = new RenderOptions +{ + Bounds = new Envelope(-10, 35, 30, 60), + Width = 1200, + Height = 900, +}; + +MapRenderer.RenderPng(features, "europe.png", options); +``` +snippet source | anchor `RenderOptions` controls the extent (`Bounds`), pixel `Width`/`Height` (height is derived from the aspect ratio when left at 0), `Padding`, and the `Background`/`Stroke`/`Fill` colors. From the command line, output a `.png` and pass `--bbox` and `--size`: @@ -244,7 +347,29 @@ File.WriteAllText("europe.svg", markup); // Or write straight to a file / stream. MapRenderer.RenderSvg(features, "europe.svg", options); ``` -snippet source | anchor +snippet source | anchor + +```cs +var features = GeoConverter.Read("countries.geojson"); + +// SVG is a vector export: same RenderOptions as PNG (bounds, size, projection, colours, +// labels), but geometry becomes // and labels become native +// , so the output scales crisply at any zoom. RenderSvg returns the markup as a +// string; the path/stream overloads write it out directly. +var options = new RenderOptions +{ + Bounds = new Envelope(-10, 35, 30, 60), + Width = 1200, + Height = 900, +}; + +var markup = MapRenderer.RenderSvg(features, options); +File.WriteAllText("europe.svg", markup); + +// Or write straight to a file / stream. +MapRenderer.RenderSvg(features, "europe.svg", options); +``` +snippet source | anchor The same `RenderOptions` knobs apply (the format-specific `RenderOptions.Png` and `RenderOptions.Svg` sub-options are the exception — `Png` is ignored for SVG output and `Svg` for PNG). Because labels are emitted as native ``, their glyph shapes depend on the fonts available to the viewer; placement and collision are identical to the PNG renderer (which reserves boxes from the hand-rolled stroke font's metrics). From the command line, output a `.svg` and pass `--bbox`/`--size` exactly as for PNG: @@ -272,7 +397,22 @@ var options = new RenderOptions MapRenderer.RenderSvg(features, "world.svg", options); ``` -snippet source | anchor +snippet source | anchor + +```cs +var options = new RenderOptions +{ + Bounds = MapRenderer.WebMercatorWorldBounds, + Width = 1024, + // Half a pixel: invisible at this render size, but collapses the dense sub-pixel + // detail that otherwise bloats the file. A world borders layer drops from ~109 MB + // to ~16 MB. The matching PNG render is unaffected (Svg options are SVG-only). + Svg = new() { SimplifyTolerance = 0.5 }, +}; + +MapRenderer.RenderSvg(features, "world.svg", options); +``` +snippet source | anchor Because the pass runs after projection and the `MinFeaturePixels` selection, it only thins geometry that is actually being drawn; raising the tolerance further yields diminishing returns once the per-vertex spacing drops below a pixel. @@ -309,7 +449,23 @@ var options = new RenderOptions MapRenderer.RenderPng(features, "world.png", options); ``` -snippet source | anchor +snippet source | anchor + +```cs +var features = GeoConverter.Read("countries.geojson"); + +// Web Mercator matches the layout of standard web tile maps. Pair it with +// MapRenderer.WebMercatorWorldBounds for the canonical 1:1 square world view; latitude is +// clamped to ±85.0511° (the cutoff every tile provider uses). +var options = new RenderOptions +{ + Bounds = MapRenderer.WebMercatorWorldBounds, + Projection = MapProjection.WebMercator, +}; + +MapRenderer.RenderPng(features, "world.png", options); +``` +snippet source | anchor From the command line, pass `--projection`: @@ -335,7 +491,22 @@ var options = new RenderOptions MapRenderer.RenderPng(features, "states.png", options); ``` -snippet source | anchor +snippet source | anchor + +```cs +var features = GeoConverter.Read("states.geojson"); + +// Lambert Conformal Conic with standard parallels picked from the data bounds — the textbook +// choice for state/country-scale maps. Conformal and low-distortion across a regional extent, +// so this avoids both plate-carrée's high-latitude squish and Web Mercator's pole stretch. +var options = new RenderOptions +{ + Projection = MapProjection.Lambert, +}; + +MapRenderer.RenderPng(features, "states.png", options); +``` +snippet source | anchor ``` @@ -364,7 +535,27 @@ var options = new RenderOptions MapRenderer.RenderPng(features, "world.png", options); ``` -snippet source | anchor +snippet source | anchor + +```cs +var features = GeoConverter.Read("countries.geojson"); + +// Goode's Homolosine (interrupted into 2 northern and 4 southern lobes along ocean +// meridians, the conventional layout): equal-area, so areas at high latitudes don't blow +// up like they do under Web Mercator or compress like they do under plate carrée, and the +// lobe interrupts keep distortion low on every continent. This is what MapProjection.Auto +// picks for a world map, so the explicit Projection assignment is only needed when you +// want the specific extent — leaving it off and letting Auto pick produces the same result. +// Ocean fills each lobe under the continents so the projection's lobed shape (and the +// inter-lobe gaps) reads clearly. +var options = new RenderOptions +{ + Projection = MapProjection.Goode, +}; + +MapRenderer.RenderPng(features, "world.png", options); +``` +snippet source | anchor ``` @@ -423,7 +614,52 @@ var options = new RenderOptions MapRenderer.RenderPng(basemap, "europe.png", options); ``` -snippet source | anchor +snippet source | anchor + +```cs +// A FeatureCollection with named sub-layers — the renderer walks the tree depth-first, so a +// parent layer paints under its children. RenderOptions.LayerStyle picks per-layer colors; +// any property left null falls back to the defaults on RenderOptions. +var basemap = new FeatureCollection +{ + Name = "basemap" +}; +basemap.Add( + new Feature( + new Polygon( + [ + [new(-10, 35), new(30, 35), new(30, 60), new(-10, 60), new(-10, 35)], + ]))); + +var roads = new FeatureCollection +{ + Name = "roads" +}; +roads.Add(new Feature(new LineString([new(0, 40), new(20, 55)]))); +basemap.Children.Add(roads); + +var options = new RenderOptions +{ + Bounds = new Envelope(-10, 35, 30, 60), + LayerStyle = layer => layer.Name switch + { + "basemap" => new() + { + Fill = new(230, 230, 230), + Stroke = new(180, 180, 180), + }, + "roads" => new() + { + Stroke = new(200, 60, 60), + StrokeWidth = 3, + }, + _ => null, + }, +}; + +MapRenderer.RenderPng(basemap, "europe.png", options); +``` +snippet source | anchor When the layers come from independent sources (typically a basemap file plus an overlay file), pass the collections as a list — they render in order, first under, last on top. Each `FeatureCollection` is a top-level layer for `RenderOptions.LayerStyle`, and the rendered extent defaults to the union of every input's bounds: @@ -461,7 +697,40 @@ var options = new RenderOptions MapRenderer.RenderPng([basemap, roads], "stacked.png", options); ``` -snippet source | anchor +snippet source | anchor + +```cs +// When the layers come from independent sources (a basemap file plus an overlay file, say), +// pass them as a list — they render in order, first under, last on top. Each FeatureCollection +// is a top-level layer for RenderOptions.LayerStyle, so giving each one a Name is enough to +// style them distinctly. When Bounds is null the rendered extent is the union of every input. +var basemap = GeoConverter.Read("countries.geojson"); +basemap.Name = "basemap"; + +var roads = GeoConverter.Read("roads.shp"); +roads.Name = "roads"; + +var options = new RenderOptions +{ + LayerStyle = layer => layer.Name switch + { + "basemap" => new() + { + Fill = new(230, 230, 230), + Stroke = new(180, 180, 180), + }, + "roads" => new() + { + Stroke = new(200, 60, 60), + StrokeWidth = 3, + }, + _ => null, + }, +}; + +MapRenderer.RenderPng([basemap, roads], "stacked.png", options); +``` +snippet source | anchor @@ -507,7 +776,34 @@ var options = new RenderOptions }; MapRenderer.RenderPng(features, "europe-halo.png", options); ``` -snippet source | anchor +snippet source | anchor + +```cs +// Halo treatment: every glyph stroke is first drawn in the halo colour at a slightly +// wider stroke, so the foreground text reads against busy fills as if outlined. The +// halo extends 2 px past the foreground stroke on every side; that's enough to lift +// text off most country-fill colours but a thin border line can still bleed through +// the ring on dense political maps. Default halo is a semi-transparent white, which +// works for dark text on light backgrounds out of the box; pass null to disable. +var features = GeoConverter.Read("countries.geojson"); +var options = new RenderOptions +{ + Bounds = new(-12, 35, 32, 60), + Width = 800, + Projection = MapProjection.Lambert, + Background = new(245, 245, 245), + Fill = new(220, 220, 210), + Stroke = new(120, 120, 120), + StrokeWidth = 1, + Label = feature => + feature.Properties.TryGetValue("NAME", out var value) ? value as string : null, + LabelSize = 14, + LabelColor = new(30, 30, 30), + LabelHalo = new(255, 255, 255, 220), +}; +MapRenderer.RenderPng(features, "europe-halo.png", options); +``` +snippet source | anchor @@ -544,7 +840,35 @@ var options = new RenderOptions }; MapRenderer.RenderPng(features, "europe-knockout.png", options); ``` -snippet source | anchor +snippet source | anchor + +```cs +// Knockout treatment: before the halo and text strokes, a solid rect of the knockout +// colour is painted over the label's bounding box. The geometry underneath is fully +// erased (opaque colour) or dimmed (semi-transparent), so country borders don't bleed +// through the way they can with a halo ring. Typically set to match Background for a +// clean masked look; pair with LabelHalo = null for a flat rectangle, or leave the +// halo on for a knockout-rect with an outline around the text. +var features = GeoConverter.Read("countries.geojson"); +var options = new RenderOptions +{ + Bounds = new(-12, 35, 32, 60), + Width = 800, + Projection = MapProjection.Lambert, + Background = new(245, 245, 245), + Fill = new(220, 220, 210), + Stroke = new(120, 120, 120), + StrokeWidth = 1, + Label = feature => + feature.Properties.TryGetValue("NAME", out var value) ? value as string : null, + LabelSize = 14, + LabelColor = new(30, 30, 30), + LabelHalo = null, + LabelKnockout = new(245, 245, 245), +}; +MapRenderer.RenderPng(features, "europe-knockout.png", options); +``` +snippet source | anchor @@ -610,7 +934,66 @@ options.LabelPriority = feature => return 0; }; ``` -snippet source | anchor +snippet source | anchor + +```cs +// Label every feature with its "name" property. Polygon/line labels sit on the +// centroid / arclength midpoint; point labels walk Imhof's 8-position candidate ring +// around the dot (NE → NW → SE → SW → E → W → N → S) so the label doesn't paint on +// top of the point marker. Collision and off-canvas rejection drop labels silently. +// The single-stroke vector font handles printable ASCII plus the Latin diacritics that +// decompose to an ASCII base + combining mark (grave, acute, circumflex, tilde, +// diaeresis, ring, caron, cedilla); ligatures like ß, æ, ø and the non-Latin blocks +// render as '?'. LabelSize is the cap height in pixels — the font scales continuously, +// so any positive value works (12–16 for 2k canvases, 20+ for high-res). +var features = GeoConverter.Read("cities.geojson"); + +var options = new RenderOptions +{ + Label = feature => + feature.Properties.TryGetValue("name", out var value) ? value as string : null, + LabelSize = 18, + LabelColor = new(20, 20, 20), + LabelHalo = new(255, 255, 255, 220), +}; + +MapRenderer.RenderPng(features, "cities.png", options); + +// Per-layer override: a child layer can carry its own label callback (or scale/color/halo) +// independent of the options-wide default. Setting Label = _ => null on a LayerStyle +// suppresses labelling for that layer. +options.LayerStyle = layer => layer.Name == "annotations" + ? new LayerStyle { Label = feature => feature.Properties["text"] as string } + : null; + +// By default, labels are placed largest-feature-first so when two collide the bigger +// polygon's name wins. Override LabelPriority to drive collision order from anything +// else — a feature property like population, or an external lookup captured in the +// closure. Without this, Natural Earth's "Ireland" would beat "United Kingdom" on file +// order; with population priority, UK (67M) outranks Ireland (5M) and gets the spot. +options.LabelPriority = feature => + feature.Properties.TryGetValue("POP_EST", out var p) ? Convert.ToDouble(p) : 0; + +// Or look priorities up in a separate table — useful when the data and the importance +// ranking live in different files. +var populations = new Dictionary +{ + ["United Kingdom"] = 67_000_000, + ["Ireland"] = 5_000_000, +}; +options.LabelPriority = feature => +{ + if (feature.Properties.TryGetValue("NAME", out var name) && + name is string n && + populations.TryGetValue(n, out var pop)) + { + return pop; + } + + return 0; +}; +``` +snippet source | anchor @@ -652,7 +1035,33 @@ using (var parquet = File.Create("world.parquet")) GeoParquet.Write(parquet, features, ParquetCompression.Gzip, CompressionLevel.SmallestSize); } ``` -snippet source | anchor +snippet source | anchor + +```cs +// PNG: the deflate level for the IDAT chunk is exposed on RenderOptions.Png. +MapRenderer.RenderPng( + features, + "world.png", + new() + { + Bounds = MapRenderer.WebMercatorWorldBounds, + Projection = MapProjection.WebMercator, + Png = new() { Compression = CompressionLevel.Fastest }, + }); + +// KMZ: the doc.kml zip entry's compression level is an optional Write argument. +using (var kmz = File.Create("world.kmz")) +{ + Kmz.Write(kmz, features, CompressionLevel.SmallestSize); +} + +// GeoParquet: pick the codec (default Snappy); CompressionLevel only applies to Gzip. +using (var parquet = File.Create("world.parquet")) +{ + GeoParquet.Write(parquet, features, ParquetCompression.Gzip, CompressionLevel.SmallestSize); +} +``` +snippet source | anchor @@ -741,7 +1150,41 @@ foreach (var feature in root) Console.WriteLine(feature.Geometry); } ``` -snippet source | anchor +snippet source | anchor + +```cs +// A FeatureCollection can hold nested child layers, each with its own Name. Formats with a +// native layer concept (KML folders, TopoJSON objects, KMZ documents, GPX wpt/rte/trk, +// Shapefile bundle directories) round-trip this structure; everything else flattens via the +// recursive enumerator. +var cities = new FeatureCollection +{ + Name = "cities" +}; +cities.Add(new Feature(new Point(new(151.21, -33.87)))); + +var roads = new FeatureCollection +{ + Name = "roads" +}; +roads.Add(new Feature(new LineString([new(151.20, -33.86), new(151.22, -33.88)]))); + +var root = new FeatureCollection +{ + Name = "sydney" +}; +root.Children.Add(cities); +root.Children.Add(roads); + +GeoConverter.Write(root, "sydney.kml"); // emits … + +// Single-layer formats just flatten — iterating any collection always yields every feature. +foreach (var feature in root) +{ + Console.WriteLine(feature.Geometry); +} +``` +snippet source | anchor diff --git a/src/Directory.Build.props b/src/Directory.Build.props index fdb2abc..da3e265 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -16,4 +16,18 @@ + + + + + + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 38b11dd..79b32f3 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -19,6 +19,7 @@ + diff --git a/src/GeoConvert.App.Tests/DiffRenderTests.Overlay.verified.png b/src/GeoConvert.App.Tests/DiffRenderTests.Overlay.verified.png new file mode 100644 index 0000000..8cbc493 Binary files /dev/null and b/src/GeoConvert.App.Tests/DiffRenderTests.Overlay.verified.png differ diff --git a/src/GeoConvert.App.Tests/DiffRenderTests.SideBySide.verified.png b/src/GeoConvert.App.Tests/DiffRenderTests.SideBySide.verified.png new file mode 100644 index 0000000..9340e4c Binary files /dev/null and b/src/GeoConvert.App.Tests/DiffRenderTests.SideBySide.verified.png differ diff --git a/src/GeoConvert.App.Tests/DiffRenderTests.cs b/src/GeoConvert.App.Tests/DiffRenderTests.cs new file mode 100644 index 0000000..fced73b --- /dev/null +++ b/src/GeoConvert.App.Tests/DiffRenderTests.cs @@ -0,0 +1,22 @@ +namespace GeoConvert.App.Tests; + +public class DiffRenderTests +{ + // A small fixed pixel size keeps the snapshots compact and deterministic. + static RenderSettings Settings() => + new() { MaxDimension = 0, Width = 400, Height = 0 }; + + [Test] + public Task Overlay() => + VerifyDiff(DiffMode.Overlay); + + [Test] + public Task SideBySide() => + VerifyDiff(DiffMode.SideBySide); + + static Task VerifyDiff(DiffMode mode) + { + var png = MapDiff.Render(SampleMaps.A(), SampleMaps.B(), Settings(), mode, MapDiff.DefaultColorA, MapDiff.DefaultColorB); + return Verify(Images.DecodePng(png)).UseParameters(mode); + } +} diff --git a/src/GeoConvert.App.Tests/DiffSummaryTests.Summarize.verified.txt b/src/GeoConvert.App.Tests/DiffSummaryTests.Summarize.verified.txt new file mode 100644 index 0000000..3c08392 --- /dev/null +++ b/src/GeoConvert.App.Tests/DiffSummaryTests.Summarize.verified.txt @@ -0,0 +1,19 @@ +Map A: a.geojson + Features: 3 + Layers: 1 + Bounds: 0, 0 .. 20, 12 + Geometry: Polygon 2, LineString 1 + Properties: name, pop + +Map B: b.geojson + Features: 2 + Layers: 1 + Bounds: 1, 1 .. 16, 11 + Geometry: Polygon 1, Point 1 + Properties: iso, name, pop + +Differences: + Features: -1 + Geometry: LineString -1, Point +1, Polygon -1 + Properties only in A: (none) + Properties only in B: iso diff --git a/src/GeoConvert.App.Tests/DiffSummaryTests.cs b/src/GeoConvert.App.Tests/DiffSummaryTests.cs new file mode 100644 index 0000000..6da7db6 --- /dev/null +++ b/src/GeoConvert.App.Tests/DiffSummaryTests.cs @@ -0,0 +1,8 @@ +namespace GeoConvert.App.Tests; + +public class DiffSummaryTests +{ + [Test] + public Task Summarize() => + Verify(MapDiff.Summarize("a.geojson", SampleMaps.A(), "b.geojson", SampleMaps.B())); +} diff --git a/src/GeoConvert.App.Tests/FormsTests.About_dpiPercent=100.verified.png b/src/GeoConvert.App.Tests/FormsTests.About_dpiPercent=100.verified.png new file mode 100644 index 0000000..5352696 Binary files /dev/null and b/src/GeoConvert.App.Tests/FormsTests.About_dpiPercent=100.verified.png differ diff --git a/src/GeoConvert.App.Tests/FormsTests.About_dpiPercent=150.verified.png b/src/GeoConvert.App.Tests/FormsTests.About_dpiPercent=150.verified.png new file mode 100644 index 0000000..d556968 Binary files /dev/null and b/src/GeoConvert.App.Tests/FormsTests.About_dpiPercent=150.verified.png differ diff --git a/src/GeoConvert.App.Tests/FormsTests.DiffWindow_dpiPercent=100.verified.png b/src/GeoConvert.App.Tests/FormsTests.DiffWindow_dpiPercent=100.verified.png new file mode 100644 index 0000000..bf64d6d Binary files /dev/null and b/src/GeoConvert.App.Tests/FormsTests.DiffWindow_dpiPercent=100.verified.png differ diff --git a/src/GeoConvert.App.Tests/FormsTests.DiffWindow_dpiPercent=150.verified.png b/src/GeoConvert.App.Tests/FormsTests.DiffWindow_dpiPercent=150.verified.png new file mode 100644 index 0000000..9f381e7 Binary files /dev/null and b/src/GeoConvert.App.Tests/FormsTests.DiffWindow_dpiPercent=150.verified.png differ diff --git a/src/GeoConvert.App.Tests/FormsTests.MainWindow_dpiPercent=100.verified.png b/src/GeoConvert.App.Tests/FormsTests.MainWindow_dpiPercent=100.verified.png new file mode 100644 index 0000000..dc0af64 Binary files /dev/null and b/src/GeoConvert.App.Tests/FormsTests.MainWindow_dpiPercent=100.verified.png differ diff --git a/src/GeoConvert.App.Tests/FormsTests.MainWindow_dpiPercent=150.verified.png b/src/GeoConvert.App.Tests/FormsTests.MainWindow_dpiPercent=150.verified.png new file mode 100644 index 0000000..19531d8 Binary files /dev/null and b/src/GeoConvert.App.Tests/FormsTests.MainWindow_dpiPercent=150.verified.png differ diff --git a/src/GeoConvert.App.Tests/FormsTests.cs b/src/GeoConvert.App.Tests/FormsTests.cs new file mode 100644 index 0000000..6813f99 --- /dev/null +++ b/src/GeoConvert.App.Tests/FormsTests.cs @@ -0,0 +1,42 @@ +namespace GeoConvert.App.Tests; + +[NotInParallel] +public class FormsTests +{ + // Each window is snapshotted at 100% and 150% scale so DPI-only layout breaks (fixed-pixel sizes that + // don't scale with the font, as the diff window's input rows once did) are caught. + [Test] + [Arguments(100)] + [Arguments(150)] + public Task MainWindow(int dpiPercent) => + // The whole main window: menu, the "no map loaded" bar, the (empty) preview and the fixed-width + // options column on the right. + Verify(WinFormsSnapshot.Render(() => new MainForm(SeededSettings(), null), 1000, 680, dpiPercent / 100f)) + .UseParameters(dpiPercent); + + [Test] + [Arguments(100)] + [Arguments(150)] + public Task DiffWindow(int dpiPercent) => + // The empty compare window: the two file pickers, the mode/projection/colour toolbar, and the + // (empty) preview / summary panes. + Verify(WinFormsSnapshot.Render(() => new DiffForm(), 1000, 680, dpiPercent / 100f)) + .UseParameters(dpiPercent); + + [Test] + [Arguments(100)] + [Arguments(150)] + public Task About(int dpiPercent) => + // The (auto-sizing) About dialog: title, description, the clickable project link and OK button. + Verify(WinFormsSnapshot.Render(() => new AboutForm(), 420, 220, dpiPercent / 100f)) + .UseParameters(dpiPercent); + + static SettingsManager SeededSettings() + { + // Pre-mark the first-run prompt as shown so the briefly-shown MainForm doesn't pop the (blocking) + // association MessageBox. A throwaway temp path keeps it away from the real user settings. + var manager = new SettingsManager(Path.Combine(Path.GetTempPath(), "GeoConvert.App.Tests", "settings.json")); + manager.Write(new() { AssociationsPrompted = true }); + return manager; + } +} diff --git a/src/GeoConvert.App.Tests/GeoConvert.App.Tests.csproj b/src/GeoConvert.App.Tests/GeoConvert.App.Tests.csproj new file mode 100644 index 0000000..92c9d55 --- /dev/null +++ b/src/GeoConvert.App.Tests/GeoConvert.App.Tests.csproj @@ -0,0 +1,26 @@ + + + + + net11.0-windows + Exe + true + false + true + + $(NoWarn);CA1416 + + + + + + + + + + + + diff --git a/src/GeoConvert.App.Tests/GlobalUsings.cs b/src/GeoConvert.App.Tests/GlobalUsings.cs new file mode 100644 index 0000000..66083f3 --- /dev/null +++ b/src/GeoConvert.App.Tests/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using System.Drawing.Imaging; +global using GeoConvert; +global using GeoConvert.App; +global using TUnit.Core; +global using static VerifyTUnit.Verifier; diff --git a/src/GeoConvert.App.Tests/ModuleInitializer.cs b/src/GeoConvert.App.Tests/ModuleInitializer.cs new file mode 100644 index 0000000..5b6022d --- /dev/null +++ b/src/GeoConvert.App.Tests/ModuleInitializer.cs @@ -0,0 +1,22 @@ +public static class ModuleInitializer +{ + [ModuleInitializer] + public static void Init() + { + // Verify.WinForms snapshots WinForms UI as images. Its Control converter renders via a live + // message loop, which deadlocks headless, so the tests render each control to a Bitmap on an STA + // thread (see WinFormsSnapshot) and snapshot that. This converter writes a Bitmap out as a PNG so + // Verify treats it as an image; SSIM comparison then tolerates the small machine-dependent pixel + // differences inherent in rendering WinForms to a bitmap. + VerifyWinForms.Initialize(); + VerifierSettings.UseSsimForPng(); + VerifierSettings.RegisterFileConverter( + (bitmap, _) => + { + var stream = new MemoryStream(); + bitmap.Save(stream, ImageFormat.Png); + stream.Position = 0; + return new(null, "png", stream, null); + }); + } +} diff --git a/src/GeoConvert.App.Tests/OptionsPanelTests.Kml_dpiPercent=100.verified.png b/src/GeoConvert.App.Tests/OptionsPanelTests.Kml_dpiPercent=100.verified.png new file mode 100644 index 0000000..5aaadaa Binary files /dev/null and b/src/GeoConvert.App.Tests/OptionsPanelTests.Kml_dpiPercent=100.verified.png differ diff --git a/src/GeoConvert.App.Tests/OptionsPanelTests.Kml_dpiPercent=150.verified.png b/src/GeoConvert.App.Tests/OptionsPanelTests.Kml_dpiPercent=150.verified.png new file mode 100644 index 0000000..0985253 Binary files /dev/null and b/src/GeoConvert.App.Tests/OptionsPanelTests.Kml_dpiPercent=150.verified.png differ diff --git a/src/GeoConvert.App.Tests/OptionsPanelTests.Png_dpiPercent=100.verified.png b/src/GeoConvert.App.Tests/OptionsPanelTests.Png_dpiPercent=100.verified.png new file mode 100644 index 0000000..10b973c Binary files /dev/null and b/src/GeoConvert.App.Tests/OptionsPanelTests.Png_dpiPercent=100.verified.png differ diff --git a/src/GeoConvert.App.Tests/OptionsPanelTests.Png_dpiPercent=150.verified.png b/src/GeoConvert.App.Tests/OptionsPanelTests.Png_dpiPercent=150.verified.png new file mode 100644 index 0000000..60b7020 Binary files /dev/null and b/src/GeoConvert.App.Tests/OptionsPanelTests.Png_dpiPercent=150.verified.png differ diff --git a/src/GeoConvert.App.Tests/OptionsPanelTests.cs b/src/GeoConvert.App.Tests/OptionsPanelTests.cs new file mode 100644 index 0000000..89dc92d --- /dev/null +++ b/src/GeoConvert.App.Tests/OptionsPanelTests.cs @@ -0,0 +1,33 @@ +namespace GeoConvert.App.Tests; + +// WinForms rendering wants a single UI thread, so keep these off the parallel runner. +[NotInParallel] +public class OptionsPanelTests +{ + // Snapshotted at 100% and 150% scale so DPI-only layout breaks (fixed-pixel sizes that don't scale + // with the font) are caught, not just the 96-DPI layout. + [Test] + [Arguments(100)] + [Arguments(150)] + public Task Kml(int dpiPercent) => + // A plain vector format: the always-on Projection radios plus the Output, Simplify and + // format-note sections (no image options). + Verify(WinFormsSnapshot.Render(() => Panel(GeoFormat.Kml), 480, 560, dpiPercent / 100f)) + .UseParameters(dpiPercent); + + [Test] + [Arguments(100)] + [Arguments(150)] + public Task Png(int dpiPercent) => + // The image formats reveal the full render-options section (projection, strokes, labels, colours) + // plus the PNG sub-section, so this covers most of the options UI and its show/hide logic. + Verify(WinFormsSnapshot.Render(() => Panel(GeoFormat.Png), 480, 1320, dpiPercent / 100f)) + .UseParameters(dpiPercent); + + static OptionsPanel Panel(GeoFormat format) + { + var panel = new OptionsPanel(new(), new(), new(), new()); + panel.SelectFormat(format); + return panel; + } +} diff --git a/src/GeoConvert.App.Tests/SampleMaps.cs b/src/GeoConvert.App.Tests/SampleMaps.cs new file mode 100644 index 0000000..7bb049c --- /dev/null +++ b/src/GeoConvert.App.Tests/SampleMaps.cs @@ -0,0 +1,41 @@ +namespace GeoConvert.App.Tests; + +/// Small, deterministic in-memory maps used across the snapshot tests. +static class SampleMaps +{ + // Two overlapping squares, a triangle and a line — enough geometry to exercise the renderer, the + // diff overlay and the structural summary without depending on any external data file. + public static FeatureCollection A() => + [ + new Feature( + new Polygon([[new(0, 0), new(10, 0), new(10, 10), new(0, 10), new(0, 0)]]), + Props(("name", "Square"), ("pop", 100L))), + new Feature( + new Polygon([[new(12, 0), new(20, 0), new(16, 8), new(12, 0)]]), + Props(("name", "Tri"))), + new Feature( + new LineString([new(0, 12), new(20, 12)]), + Props(("name", "Road"))), + ]; + + public static FeatureCollection B() => + [ + new Feature( + new Polygon([[new(1, 1), new(11, 1), new(11, 11), new(1, 11), new(1, 1)]]), + Props(("name", "Square"), ("pop", 100L), ("iso", "SQ"))), + new Feature( + new Point(new(16, 4)), + Props(("name", "Dot"), ("iso", "DT"))), + ]; + + static IDictionary Props(params (string Key, object? Value)[] pairs) + { + var properties = new Dictionary(); + foreach (var (key, value) in pairs) + { + properties[key] = value; + } + + return properties; + } +} diff --git a/src/GeoConvert.App.Tests/WinFormsSnapshot.cs b/src/GeoConvert.App.Tests/WinFormsSnapshot.cs new file mode 100644 index 0000000..51242fe --- /dev/null +++ b/src/GeoConvert.App.Tests/WinFormsSnapshot.cs @@ -0,0 +1,106 @@ +namespace GeoConvert.App.Tests; + +/// +/// Renders a WinForms control to a for Verify to snapshot. WinForms requires an STA +/// thread, so the control is created, laid out and drawn on a dedicated one; the resulting bitmap is +/// thread-agnostic and handed back. Forms are briefly shown off-screen so their OnLoad (e.g. the +/// diff window's splitter layout) runs before the draw; plain controls just get a handle and a layout +/// pass. Nothing is verified here — the caller passes the bitmap to Verify. +/// +/// A scale > 1 simulates a higher display DPI: WinForms applies DPI scaling through +/// , so calling it directly reproduces the same scaling behaviour +/// (including the class of bug where fixed-pixel sizes don't scale while font-sized controls do) without +/// needing an actual high-DPI monitor. scale 1.25 == 120 DPI / 125%, scale 1.5 == 144 DPI / 150%. +/// +/// +static class WinFormsSnapshot +{ + static bool stylesEnabled; + + public static Bitmap Render(Func factory, int width, int height, float scale = 1f) + { + Bitmap? result = null; + Exception? failure = null; + + var thread = new Thread(() => + { + try + { + EnsureStyles(); + using var control = factory(); + if (control is Form form) + { + // Off-screen + off-taskbar so the brief show is invisible; Show() raises OnLoad/OnShown + // so docked/split layouts settle before the draw. + form.StartPosition = FormStartPosition.Manual; + form.ShowInTaskbar = false; + form.Location = new(-5000, -5000); + // An AutoSize form sizes itself to its content — forcing a size would clip it. + if (!form.AutoSize) + { + form.Size = new(width, height); + } + + Rescale(form, scale); + form.Show(); + Application.DoEvents(); + result = Draw(form); + form.Close(); + } + else + { + control.Size = new(width, height); + _ = control.Handle; + Rescale(control, scale); + control.PerformLayout(); + Application.DoEvents(); + result = Draw(control); + } + } + catch (Exception exception) + { + failure = exception; + } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.IsBackground = true; + thread.Start(); + thread.Join(); + + if (failure != null) + { + throw failure; + } + + return result!; + } + + static void Rescale(Control control, float scale) + { + if (scale != 1f) + { + control.Scale(new SizeF(scale, scale)); + } + } + + static Bitmap Draw(Control control) + { + var bounds = control.ClientRectangle; + var bitmap = new Bitmap(Math.Max(1, bounds.Width), Math.Max(1, bounds.Height)); + control.DrawToBitmap(bitmap, bounds); + return bitmap; + } + + static void EnsureStyles() + { + if (stylesEnabled) + { + return; + } + + // Match the real app's themed rendering (ApplicationConfiguration.Initialize does this). + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + stylesEnabled = true; + } +} diff --git a/src/GeoConvert.App/Cli.cs b/src/GeoConvert.App/Cli.cs new file mode 100644 index 0000000..920ead6 --- /dev/null +++ b/src/GeoConvert.App/Cli.cs @@ -0,0 +1,471 @@ +namespace GeoConvert.App; + +/// +/// The command-line surface of the app, hand-rolled in the same spirit as the geoconvert CLI's +/// Runner (no third-party parser). It owns the headless commands — diff, the file +/// association management, and settings — and the usage text. Interactive conversion/rendering is +/// the GUI's job; the CLI deliberately mirrors only the diff feature plus app management. +/// +public static class Cli +{ + /// A fully-parsed diff invocation. null means "open the diff in + /// the GUI" rather than render headlessly. + public sealed record DiffRequest( + string PathA, + string PathB, + string? Output, + RenderSettings Settings, + DiffMode Mode, + Rgba ColorA, + Rgba ColorB); + + /// + /// Parses the arguments after diff. Returns 0 with set on success, + /// or 2 on a usage error (message already written to ). + /// + public static int ParseDiff(string[] args, out DiffRequest? request, TextWriter error) + { + request = null; + var settings = new RenderSettings(); + var mode = DiffMode.Overlay; + var colorA = MapDiff.DefaultColorA; + var colorB = MapDiff.DefaultColorB; + var positionals = new List(); + + for (var i = 0; i < args.Length; i++) + { + var argument = args[i]; + switch (argument) + { + case "--mode": + if (!TryNext(args, ref i, error, "--mode", out var modeText)) + { + return 2; + } + + if (!TryParseMode(modeText, out mode)) + { + error.WriteLine("--mode must be 'overlay' or 'side-by-side'."); + return 2; + } + + break; + case "--color-a": + if (!TryNext(args, ref i, error, "--color-a", out var colorAText) || + !RequireColor(colorAText, "--color-a", error, out colorA)) + { + return 2; + } + + break; + case "--color-b": + if (!TryNext(args, ref i, error, "--color-b", out var colorBText) || + !RequireColor(colorBText, "--color-b", error, out colorB)) + { + return 2; + } + + break; + case "--bbox": + if (!TryNext(args, ref i, error, "--bbox", out var bboxText)) + { + return 2; + } + + if (!TryParseBounds(bboxText, out var bounds)) + { + error.WriteLine("--bbox must be 'minX,minY,maxX,maxY'."); + return 2; + } + + settings.Bounds = bounds; + break; + case "--size": + if (!TryNext(args, ref i, error, "--size", out var sizeText)) + { + return 2; + } + + if (!TryParseSize(sizeText, out var width, out var height)) + { + error.WriteLine("--size must be 'WIDTH' or 'WIDTHxHEIGHT'."); + return 2; + } + + // An explicit pixel size overrides the default fit-to-box. + settings.MaxDimension = 0; + settings.Width = width; + settings.Height = height; + break; + case "--max-dimension": + if (!TryNext(args, ref i, error, "--max-dimension", out var maxText)) + { + return 2; + } + + if (!int.TryParse(maxText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var maxDimension) || maxDimension <= 0) + { + error.WriteLine("--max-dimension must be a positive integer (pixels)."); + return 2; + } + + settings.MaxDimension = maxDimension; + break; + case "--projection": + if (!TryNext(args, ref i, error, "--projection", out var projectionText)) + { + return 2; + } + + if (!TryParseProjection(projectionText, out var projection)) + { + error.WriteLine("--projection must be 'auto', 'plate-carree', 'web-mercator', 'lambert', or 'goode'."); + return 2; + } + + settings.Projection = projection; + break; + case "--renderer": + if (!TryNext(args, ref i, error, "--renderer", out var rendererText)) + { + return 2; + } + + if (!TryParseRenderer(rendererText, out var renderer)) + { + error.WriteLine("--renderer must be 'builtin', 'skia', or 'imagesharp'."); + return 2; + } + + settings.Renderer = renderer; + break; + default: + if (argument.StartsWith('-')) + { + error.WriteLine($"Unknown option '{argument}'."); + return 2; + } + + positionals.Add(argument); + break; + } + } + + if (positionals.Count is < 2 or > 3) + { + error.WriteLine("Usage: geoconvert-app diff [output.png] [options]"); + return 2; + } + + var output = positionals.Count == 3 ? positionals[2] : null; + request = new(positionals[0], positionals[1], output, settings, mode, colorA, colorB); + return 0; + } + + /// Runs a headless diff: renders the diff image to and prints the summary. + public static int ExecuteDiff(DiffRequest request, TextWriter output, TextWriter error) + { + if (!File.Exists(request.PathA)) + { + error.WriteLine($"Input file not found: {request.PathA}"); + return 1; + } + + if (!File.Exists(request.PathB)) + { + error.WriteLine($"Input file not found: {request.PathB}"); + return 1; + } + + try + { + var a = GeoConverter.Read(request.PathA); + var b = GeoConverter.Read(request.PathB); + + output.Write(MapDiff.Summarize(Path.GetFileName(request.PathA), a, Path.GetFileName(request.PathB), b)); + + if (request.Output is { } destination) + { + var image = MapDiff.Render(a, b, request.Settings, request.Mode, request.ColorA, request.ColorB); + File.WriteAllBytes(destination, image); + output.WriteLine(); + output.WriteLine($"Diff image ({request.Mode}) written to {destination}."); + } + + return 0; + } + catch (GeoConvertException exception) + { + error.WriteLine($"Error: {exception.Message}"); + return 1; + } + } + + public static int Associate(TextWriter output) + { + FileAssociations.Associate(); + output.WriteLine("Bound the supported map formats to GeoConvert:"); + output.WriteLine($" {string.Join(" ", FileAssociations.Extensions)}"); + return 0; + } + + public static int Unassociate(TextWriter output) + { + FileAssociations.Unassociate(); + output.WriteLine("Removed GeoConvert's map file associations."); + return 0; + } + + public static int PrintSettings(TextWriter output, SettingsManager settingsManager) + { + output.WriteLine(settingsManager.SettingsPath); + if (File.Exists(settingsManager.SettingsPath)) + { + output.WriteLine(File.ReadAllText(settingsManager.SettingsPath)); + } + else + { + output.WriteLine("No settings file found."); + } + + output.WriteLine($"File associations bound: {(FileAssociations.IsAssociated() ? "yes" : "no")}"); + return 0; + } + + // --- parsing helpers (shared shapes with the geoconvert CLI's Runner) --- + + static bool TryNext(string[] args, ref int index, TextWriter error, string option, out string value) + { + if (index + 1 >= args.Length) + { + error.WriteLine($"Missing value for {option}."); + value = string.Empty; + return false; + } + + value = args[++index]; + return true; + } + + static bool RequireColor(string text, string option, TextWriter error, out Rgba color) + { + if (TryParseColor(text, out color)) + { + return true; + } + + error.WriteLine($"{option} must be '#RRGGBB' or '#RRGGBBAA'."); + return false; + } + + static bool TryParseMode(string text, out DiffMode mode) + { + switch (text.ToLowerInvariant()) + { + case "overlay": + mode = DiffMode.Overlay; + return true; + case "side-by-side": + case "sidebyside": + case "side": + mode = DiffMode.SideBySide; + return true; + default: + mode = default; + return false; + } + } + + static bool TryParseBounds(string text, out Envelope bounds) + { + bounds = default; + var parts = text.Split(','); + if (parts.Length != 4) + { + return false; + } + + var values = new double[4]; + for (var i = 0; i < 4; i++) + { + if (!double.TryParse(parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out values[i])) + { + return false; + } + } + + bounds = new(values[0], values[1], values[2], values[3]); + return true; + } + + static bool TryParseSize(string text, out int width, out int height) + { + width = 0; + height = 0; + var parts = text.Split('x', 'X'); + if (parts.Length is < 1 or > 2) + { + return false; + } + + if (!int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out width) || width <= 0) + { + return false; + } + + if (parts.Length == 2 && + (!int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out height) || height <= 0)) + { + return false; + } + + return true; + } + + static bool TryParseProjection(string text, out MapProjection projection) + { + switch (text.ToLowerInvariant()) + { + case "auto": + case "automatic": + projection = MapProjection.Auto; + return true; + case "plate-carree": + case "platecarree": + case "equirectangular": + projection = MapProjection.PlateCarree; + return true; + case "web-mercator": + case "webmercator": + case "mercator": + projection = MapProjection.WebMercator; + return true; + case "lambert": + case "lambert-conformal": + case "lambert-conformal-conic": + case "lcc": + projection = MapProjection.Lambert; + return true; + case "goode": + case "homolosine": + case "goode-homolosine": + projection = MapProjection.Goode; + return true; + default: + projection = default; + return false; + } + } + + static bool TryParseRenderer(string text, out RendererBackend renderer) + { + switch (text.ToLowerInvariant()) + { + case "builtin": + case "built-in": + case "default": + renderer = RendererBackend.BuiltIn; + return true; + case "skia": + case "skiasharp": + renderer = RendererBackend.Skia; + return true; + case "imagesharp": + case "image-sharp": + case "sixlabors": + renderer = RendererBackend.ImageSharp; + return true; + default: + renderer = default; + return false; + } + } + + static bool TryParseColor(string text, out Rgba color) + { + color = default; + if (text.Length < 7 || text[0] != '#') + { + return false; + } + + var hex = text.AsSpan(1); + if (hex.Length != 6 && hex.Length != 8) + { + return false; + } + + if (!byte.TryParse(hex.Slice(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var r) || + !byte.TryParse(hex.Slice(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var g) || + !byte.TryParse(hex.Slice(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b)) + { + return false; + } + + byte a = 255; + if (hex.Length == 8 && + !byte.TryParse(hex.Slice(6, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out a)) + { + return false; + } + + color = new(r, g, b, a); + return true; + } + + public static void PrintUsage(TextWriter writer) => + writer.WriteLine( + """ + geoconvert-app - a desktop map converter, renderer and diff tool. + + Run with no arguments to open the window. Pass a map file to open it directly. + + Usage: + geoconvert-app Open the app. + geoconvert-app Open the app with a map loaded. + geoconvert-app diff [output.png] [options] + Compare two maps. With an output path the diff is + rendered headlessly and a summary is printed; without + one the diff opens in the window. + geoconvert-app associate Bind the supported map formats to this app. + geoconvert-app unassociate Remove those file associations. + geoconvert-app settings Show the settings file and association state. + geoconvert-app --list List supported formats. + geoconvert-app --help Show this help. + + diff options: + --mode overlay|side-by-side Overlay both maps on one canvas (default) or place them + side by side at a shared extent. + --color-a <#hex> Colour for the first map (default red). + --color-b <#hex> Colour for the second map (default blue). + --bbox minX,minY,maxX,maxY Extent to render (defaults to the union of both maps). + --size WIDTH[xHEIGHT] Image size in pixels. + --max-dimension Cap the longer edge at this many pixels (fit-to-box). + --projection auto | plate-carree | web-mercator | lambert | goode. + --renderer builtin (default), skia or imagesharp (PNG only). + + Examples: + geoconvert-app world.geojson + geoconvert-app diff before.geojson after.geojson changes.png + geoconvert-app diff a.kml b.kml diff.png --mode side-by-side --size 1600 + """); + + public static void PrintFormats(TextWriter writer) => + writer.WriteLine( + """ + Supported formats: + geojson .geojson .json (read/write) + topojson .topojson (read/write) + shapefile .shp (+ .shx .dbf .prj) (read/write) + flatgeobuf .fgb (read/write) + kml .kml (read/write) + kmz .kmz (read/write) + gpx .gpx (read/write) + wkt .wkt (read/write) + wkb .wkb (read/write) + csv .csv (read/write) + geoparquet .parquet .geoparquet (read/write) + png .png (render output) + svg .svg (render output) + """); +} diff --git a/src/GeoConvert.App/ConversionService.cs b/src/GeoConvert.App/ConversionService.cs new file mode 100644 index 0000000..c0ff5a6 --- /dev/null +++ b/src/GeoConvert.App/ConversionService.cs @@ -0,0 +1,223 @@ +namespace GeoConvert.App; + +/// +/// Wraps and the renderers for the desktop app — the counterpart of the +/// Blazor app's conversion service, but path/stream based and filesystem-aware, so the path-only +/// Shapefile is a first-class format here (the browser version had to exclude it). Everything the GUI +/// and the CLI do — detect, read, simplify, write, render, choose a PNG backend — funnels through here. +/// +public static class ConversionService +{ + static IReadOnlyList AllFormats { get; } = + [ + new(GeoFormat.GeoJson, "GeoJSON", ".geojson", [".geojson", ".json"], CanRead: true, CanWrite: true), + new(GeoFormat.TopoJson, "TopoJSON", ".topojson", [".topojson"], CanRead: true, CanWrite: true), + new(GeoFormat.Shapefile, "Shapefile", ".shp", [".shp"], CanRead: true, CanWrite: true), + new(GeoFormat.FlatGeobuf, "FlatGeobuf", ".fgb", [".fgb"], CanRead: true, CanWrite: true), + new(GeoFormat.Kml, "KML", ".kml", [".kml"], CanRead: true, CanWrite: true), + new(GeoFormat.Kmz, "KMZ", ".kmz", [".kmz"], CanRead: true, CanWrite: true), + new(GeoFormat.Gpx, "GPX", ".gpx", [".gpx"], CanRead: true, CanWrite: true), + new(GeoFormat.Wkt, "WKT", ".wkt", [".wkt"], CanRead: true, CanWrite: true), + new(GeoFormat.Wkb, "WKB", ".wkb", [".wkb"], CanRead: true, CanWrite: true), + new(GeoFormat.Csv, "CSV", ".csv", [".csv"], CanRead: true, CanWrite: true), + new(GeoFormat.GeoParquet, "GeoParquet", ".parquet", [".parquet", ".geoparquet"], CanRead: true, CanWrite: true), + new(GeoFormat.Png, "PNG image", ".png", [".png"], CanRead: false, CanWrite: true), + new(GeoFormat.Svg, "SVG image", ".svg", [".svg"], CanRead: false, CanWrite: true), + ]; + + public static IReadOnlyList Formats => AllFormats; + + /// Formats that can be read into features (everything except the write-only images). + public static IReadOnlyList ReadableFormats { get; } = [.. AllFormats.Where(_ => _.CanRead)]; + + /// Formats that can be written (every format, including the render-only PNG/SVG). + public static IReadOnlyList WritableFormats { get; } = [.. AllFormats.Where(_ => _.CanWrite)]; + + /// Every distinct extension across the readable formats — used for the open dialog and file associations. + public static IReadOnlyList ReadableExtensions { get; } = + [.. ReadableFormats.SelectMany(_ => _.Extensions).Distinct(StringComparer.OrdinalIgnoreCase)]; + + /// The write-only image formats whose write is a render (projection + size apply), not a plain codec write. + public static bool IsRendered(GeoFormat format) => + format is GeoFormat.Png or GeoFormat.Svg; + + public static FormatInfo? Find(GeoFormat format) => + AllFormats.FirstOrDefault(_ => _.Format == format); + + /// Infers the format of a file name, or null when the extension is unknown. + public static FormatInfo? Detect(string fileName) => + GeoConverter.TryDetectFormat(fileName, out var format) ? Find(format) : null; + + public static FeatureCollection Read(string path, GeoFormat format, IProgress? progress = null) => + // GeoConverter.Read is path-based and already special-cases Shapefile's sibling .shp/.shx/.dbf. + GeoConverter.Read(path, format, progress); + + /// + /// Writes to in , applying + /// the relevant options: a render (honouring the chosen ) for + /// PNG/SVG, the option-carrying overloads for KMZ/GeoParquet, and the plain codec write (Shapefile + /// included) otherwise. + /// + public static void Save( + FeatureCollection features, + string path, + GeoFormat format, + RenderSettings render, + KmzSettings kmz, + GeoParquetSettings parquet, + IProgress? progress = null) + { + switch (format) + { + case GeoFormat.Png: + File.WriteAllBytes(path, RenderPng(features, render, progress)); + break; + case GeoFormat.Svg: + MapRenderer.RenderSvg(features, path, RenderOptionsFor(render, progress)); + break; + case GeoFormat.Kmz: + WriteKmz(features, path, kmz); + break; + case GeoFormat.GeoParquet: + WriteGeoParquet(features, path, parquet); + break; + default: + GeoConverter.Write(features, path, format, progress); + break; + } + } + + static void WriteKmz(FeatureCollection features, string path, KmzSettings settings) + { + using var stream = File.Create(path); + Kmz.Write(stream, features, settings.Compression); + } + + static void WriteGeoParquet(FeatureCollection features, string path, GeoParquetSettings settings) + { + using var stream = File.Create(path); + GeoParquet.Write(stream, features, settings.Codec, settings.GzipLevel); + } + + /// + /// Renders a PNG through the chosen backend (built-in software + /// rasterizer or ImageSharp). + /// + public static byte[] RenderPng(FeatureCollection features, RenderSettings render, IProgress? progress = null) + { + var options = RenderOptionsFor(render, progress); + return render.Renderer switch + { + RendererBackend.Skia => SkiaRenderer.RenderPng(features, options), + RendererBackend.ImageSharp => ImageSharpRenderer.RenderPng(features, options), + _ => MapRenderer.RenderPng(features, options), + }; + } + + /// + /// Renders a quick preview PNG. Always uses the built-in renderer (no third-party warm-up, always + /// available) regardless of the selected export backend, and reports no progress — it's a best-effort + /// thumbnail for the window, not the final export. + /// + public static byte[] RenderPreview(FeatureCollection features, RenderSettings render) => + MapRenderer.RenderPng(features, RenderOptionsFor(render, null)); + + public static string RenderSvg(FeatureCollection features, RenderSettings render, IProgress? progress = null) => + MapRenderer.RenderSvg(features, RenderOptionsFor(render, progress)); + + // Common name-like property keys, tried in order, so the "Labels" toggle works without the user + // having to name a property — mirrors the Blazor app. Falls back to the feature id. + static readonly string[] labelKeys = + ["name", "NAME", "Name", "name_en", "NAME_EN", "admin", "ADMIN", "title", "label", "id"]; + + static string? AutoLabel(Feature feature) + { + foreach (var key in labelKeys) + { + if (feature.Properties.TryGetValue(key, out var value) && value is not null) + { + var text = value.ToString(); + if (!string.IsNullOrWhiteSpace(text)) + { + return text; + } + } + } + + return feature.Id?.ToString(); + } + + /// Maps a onto a . + public static RenderOptions RenderOptionsFor(RenderSettings settings, IProgress? progress) + { + var options = new RenderOptions + { + Projection = settings.Projection, + Bounds = settings.Bounds, + Padding = settings.Padding, + Background = settings.Background, + Stroke = settings.Stroke, + Fill = settings.Fill, + StrokeWidth = settings.StrokeWidth, + PointRadius = settings.PointRadius, + StrokeAutoScale = settings.StrokeAutoScale, + LabelSize = settings.LabelSize, + LabelColor = settings.LabelColor, + // Ocean paints the projection's world envelope under every feature (the lobes for Goode, the + // whole canvas otherwise) — see the Blazor service for the full rationale. On by default. + Ocean = settings.OceanEnabled ? settings.Ocean : null, + LabelHalo = settings.HaloEnabled ? settings.LabelHalo : null, + LabelKnockout = settings.KnockoutEnabled ? settings.LabelKnockout : null, + MinFeaturePixels = settings.MinFeaturePixels, + Png = new() { Compression = settings.PngCompression }, + Svg = new() { SimplifyTolerance = settings.SvgSimplifyTolerance }, + Progress = progress, + }; + + // MaxDimension (fit-to-box) wins when set; otherwise an explicit width with a derived or explicit height. + if (settings.MaxDimension > 0) + { + options.MaxDimension = settings.MaxDimension; + } + else + { + if (settings.Width > 0) + { + options.Width = settings.Width; + } + + options.Height = settings.Height; + } + + if (settings.Labels) + { + if (string.IsNullOrWhiteSpace(settings.LabelProperty)) + { + options.Label = AutoLabel; + } + else + { + var key = settings.LabelProperty; + options.Label = _ => + _.Properties.TryGetValue(key, out var value) && value is not null + ? value.ToString() + : null; + } + } + + return options; + } + + /// Builds an open/save dialog filter: an "All supported" clause, every format, then "All files". + public static string BuildDialogFilter(IReadOnlyList formats) + { + var clauses = new List(); + var allPatterns = string.Join( + ';', + formats.SelectMany(_ => _.Extensions).Distinct(StringComparer.OrdinalIgnoreCase).Select(_ => $"*{_}")); + clauses.Add($"All supported ({allPatterns})|{allPatterns}"); + clauses.AddRange(formats.Select(_ => _.DialogFilter)); + clauses.Add("All files (*.*)|*.*"); + return string.Join('|', clauses); + } +} diff --git a/src/GeoConvert.App/DiffMode.cs b/src/GeoConvert.App/DiffMode.cs new file mode 100644 index 0000000..5094d50 --- /dev/null +++ b/src/GeoConvert.App/DiffMode.cs @@ -0,0 +1,12 @@ +namespace GeoConvert.App; + +/// How a map diff is drawn. +public enum DiffMode +{ + /// Both maps drawn on one canvas in distinct colours, so shared geometry blends and + /// differences stand out in pure A- or B-colour. + Overlay, + + /// The two maps drawn separately at the same extent/scale and placed next to each other. + SideBySide, +} diff --git a/src/GeoConvert.App/FileAssociations.cs b/src/GeoConvert.App/FileAssociations.cs new file mode 100644 index 0000000..4bb3a66 --- /dev/null +++ b/src/GeoConvert.App/FileAssociations.cs @@ -0,0 +1,94 @@ +namespace GeoConvert.App; + +/// +/// Registers this app as the handler for the supported map file extensions, per-user (no admin needed) +/// under HKCU\Software\Classes. One ProgId is created and pointed at the running executable, and +/// every readable map extension is bound to it (set as the default and added to its OpenWith list). The +/// binding is fully reversible via . +/// +public static class FileAssociations +{ + const string progId = "GeoConvert.Map"; + const string progIdLabel = "GeoConvert Map"; + + const int shcneAssocchanged = 0x08000000; + const uint shcnfIdlist = 0; + + [DllImport("shell32.dll")] + static extern void SHChangeNotify(int eventId, uint flags, IntPtr item1, IntPtr item2); + + /// The extensions bound to the app — every format that can be read into the editor. + public static IReadOnlyList Extensions => ConversionService.ReadableExtensions; + + static string ExecutablePath => + Environment.ProcessPath ?? + throw new InvalidOperationException("Could not resolve the running executable path."); + + /// True when the ProgId is registered and bound to the first supported extension. + public static bool IsAssociated() + { + using var classes = Registry.CurrentUser.OpenSubKey($@"Software\Classes\{Extensions[0]}"); + return classes?.GetValue(null) as string == progId; + } + + /// Binds every supported map extension to this app. + public static void Associate() + { + var executable = ExecutablePath; + + using (var progId = Registry.CurrentUser.CreateSubKey($@"Software\Classes\{FileAssociations.progId}")) + { + progId.SetValue(null, progIdLabel); + using (var icon = progId.CreateSubKey("DefaultIcon")) + { + icon.SetValue(null, $"\"{executable}\",0"); + } + + using var command = progId.CreateSubKey(@"shell\open\command"); + command.SetValue(null, $"\"{executable}\" \"%1\""); + } + + foreach (var extension in Extensions) + { + using var key = Registry.CurrentUser.CreateSubKey($@"Software\Classes\{extension}"); + // Set as the default handler (the "bind" the user asked for) and also advertise the ProgId in + // the extension's OpenWith list so the app shows up there and the binding is cleanly removable. + key.SetValue(null, progId); + using var openWith = key.CreateSubKey("OpenWithProgids"); + openWith.SetValue(progId, Array.Empty(), RegistryValueKind.None); + } + + NotifyShell(); + } + + /// Removes the bindings created by , leaving other handlers intact. + public static void Unassociate() + { + foreach (var extension in Extensions) + { + using var key = Registry.CurrentUser.OpenSubKey($@"Software\Classes\{extension}", writable: true); + if (key == null) + { + continue; + } + + // Only clear the default if it still points at us — never stomp a handler the user has since + // chosen. + if (key.GetValue(null) as string == progId) + { + // "" is the name of a key's default value (DeleteValue, unlike GetValue, won't take null). + key.DeleteValue(string.Empty, throwOnMissingValue: false); + } + + using var openWith = key.OpenSubKey("OpenWithProgids", writable: true); + openWith?.DeleteValue(progId, throwOnMissingValue: false); + } + + Registry.CurrentUser.DeleteSubKeyTree($@"Software\Classes\{progId}", throwOnMissingSubKey: false); + NotifyShell(); + } + + static void NotifyShell() => + // Tell Explorer the associations changed so icons / "Open with" refresh without a sign-out. + SHChangeNotify(shcneAssocchanged, shcnfIdlist, IntPtr.Zero, IntPtr.Zero); +} diff --git a/src/GeoConvert.App/FirstRun.cs b/src/GeoConvert.App/FirstRun.cs new file mode 100644 index 0000000..f124404 --- /dev/null +++ b/src/GeoConvert.App/FirstRun.cs @@ -0,0 +1,55 @@ +namespace GeoConvert.App; + +/// +/// The one-time, first-launch prompt that offers to bind the supported map formats to this app. Gated by +/// so it appears exactly once, whatever the user answers. +/// +public static class FirstRun +{ + public static void PromptForAssociationsIfNeeded(SettingsManager settingsManager, IWin32Window owner) + { + var settings = settingsManager.Read(); + if (settings.AssociationsPrompted) + { + return; + } + + // Persist first, so a crash in the registry step never re-prompts on every launch. + settingsManager.Update(_ => _.AssociationsPrompted = true); + + var extensions = string.Join(" ", FileAssociations.Extensions); + var result = MessageBox.Show( + owner, + $""" + Bind the supported map formats to GeoConvert, so double-clicking one opens it here? + + This sets GeoConvert as the handler for: + {extensions} + + Note: this includes the shared .json and .csv extensions. The change is per-user (no admin + needed) and can be undone any time from Tools ▸ Remove file associations. + """, + "Associate map files with GeoConvert?", + MessageBoxButtons.YesNo, + MessageBoxIcon.Question); + + if (result != DialogResult.Yes) + { + return; + } + + try + { + FileAssociations.Associate(); + } + catch (Exception exception) + { + MessageBox.Show( + owner, + $"Could not set file associations:\n{exception.Message}", + "GeoConvert", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + } + } +} diff --git a/src/GeoConvert.App/FormatInfo.cs b/src/GeoConvert.App/FormatInfo.cs new file mode 100644 index 0000000..9930828 --- /dev/null +++ b/src/GeoConvert.App/FormatInfo.cs @@ -0,0 +1,25 @@ +namespace GeoConvert.App; + +/// +/// Desktop-facing metadata for a : how to name it, which file extensions it +/// owns, and whether it can be read / written. The desktop has a real filesystem, so (unlike the +/// browser sample) the path-based Shapefile is a first-class format here. +/// +public record FormatInfo( + GeoFormat Format, + string DisplayName, + string Extension, + IReadOnlyList Extensions, + bool CanRead, + bool CanWrite) +{ + /// A single-format file-dialog filter clause, e.g. GeoJSON (*.geojson;*.json)|*.geojson;*.json. + public string DialogFilter + { + get + { + var patterns = string.Join(';', Extensions.Select(_ => $"*{_}")); + return $"{DisplayName} ({patterns})|{patterns}"; + } + } +} diff --git a/src/GeoConvert.App/GeoConvert.App.csproj b/src/GeoConvert.App/GeoConvert.App.csproj new file mode 100644 index 0000000..70cdb18 --- /dev/null +++ b/src/GeoConvert.App/GeoConvert.App.csproj @@ -0,0 +1,84 @@ + + + + + Exe + net10.0;net11.0 + true + enable + $(NoWarn);CA1416;NETSDK1137 + true + true + geoconvert-app + GeoConvert.App + LatestMajor + + false + true + A WinForms desktop app and .NET tool that converts maps between geospatial formats, renders them to PNG/SVG, and diffs two maps. Built on GeoConvert. + geojson;shapefile;flatgeobuf;topojson;kml;kmz;gpx;wkt;wkb;gis;geospatial;conversion;winforms;diff + readme.md + + + + + + + + + Windows + + + + + + + + + + + + + + + + + + Borders + 0.1 + + + + + + + + + + + + + + diff --git a/src/GeoConvert.App/GeoParquetSettings.cs b/src/GeoConvert.App/GeoParquetSettings.cs new file mode 100644 index 0000000..7dd85e5 --- /dev/null +++ b/src/GeoConvert.App/GeoParquetSettings.cs @@ -0,0 +1,11 @@ +namespace GeoConvert.App; + +/// +/// Options for a GeoParquet write — the data-page codec, plus the +/// deflate level used only when the codec is . +/// +public sealed class GeoParquetSettings +{ + public ParquetCompression Codec { get; set; } = ParquetCompression.Snappy; + public CompressionLevel GzipLevel { get; set; } = CompressionLevel.Optimal; +} diff --git a/src/GeoConvert.App/GlobalUsings.cs b/src/GeoConvert.App/GlobalUsings.cs new file mode 100644 index 0000000..6b0ba69 --- /dev/null +++ b/src/GeoConvert.App/GlobalUsings.cs @@ -0,0 +1,10 @@ +global using System.Globalization; +global using System.IO.Compression; +global using System.Runtime.InteropServices; +global using System.Text; +global using System.Text.Json; +global using System.Text.Json.Serialization; +global using GeoConvert; +global using GeoConvert.ImageSharp; +global using GeoConvert.Skia; +global using Microsoft.Win32; diff --git a/src/GeoConvert.App/KmzSettings.cs b/src/GeoConvert.App/KmzSettings.cs new file mode 100644 index 0000000..3857c8d --- /dev/null +++ b/src/GeoConvert.App/KmzSettings.cs @@ -0,0 +1,7 @@ +namespace GeoConvert.App; + +/// Options for a KMZ write — the zip deflate level for the archived doc.kml entry. +public sealed class KmzSettings +{ + public CompressionLevel Compression { get; set; } = CompressionLevel.Optimal; +} diff --git a/src/GeoConvert.App/MapDiff.cs b/src/GeoConvert.App/MapDiff.cs new file mode 100644 index 0000000..3446e76 --- /dev/null +++ b/src/GeoConvert.App/MapDiff.cs @@ -0,0 +1,251 @@ +using System.Drawing.Imaging; + +namespace GeoConvert.App; + +/// +/// Compares two maps. The visual diff reuses the existing renderer with zero new drawing code: an +/// stacks both collections on one canvas via the multi-collection +/// overload with a +/// per-layer colouring A and B differently (so shared geometry blends and +/// differences read as pure A- or B-colour), and renders each map to +/// the same union extent and composites them. The structural reports feature +/// counts, geometry-type histograms, bounds, property keys and their deltas — the headline of a +/// command-line diff. Inputs are flattened (layers collapsed) for the visual diff so per-layer styling +/// stays correct on layered sources; still reports the original layer counts. +/// +public static class MapDiff +{ + public static Rgba DefaultColorA { get; } = new(200, 30, 30); + + public static Rgba DefaultColorB { get; } = new(40, 90, 210); + + /// Renders the diff image for as PNG bytes. + public static byte[] Render( + FeatureCollection a, + FeatureCollection b, + RenderSettings settings, + DiffMode mode, + Rgba colorA, + Rgba colorB) => + mode == DiffMode.SideBySide + ? RenderSideBySide(a, b, settings, colorA, colorB) + : RenderOverlay(a, b, settings, colorA, colorB); + + static byte[] RenderOverlay(FeatureCollection a, FeatureCollection b, RenderSettings settings, Rgba colorA, Rgba colorB) + { + var flatA = Flatten(a); + var flatB = Flatten(b); + + var options = ConversionService.RenderOptionsFor(settings, null); + // The ocean fill would paint a solid layer over one of the maps; labels would clutter the + // comparison. Both off for the diff, regardless of the user's export settings. + options.Ocean = null; + options.Label = null; + options.LayerStyle = layer => + { + if (ReferenceEquals(layer, flatA)) + { + return StyleFor(colorA); + } + + if (ReferenceEquals(layer, flatB)) + { + return StyleFor(colorB); + } + + return null; + }; + + // Bounds left as the user set them (null => the renderer unions both inputs), so the two layers + // share one extent and line up. + return MapRenderer.RenderPng([flatA, flatB], options); + } + + static byte[] RenderSideBySide(FeatureCollection a, FeatureCollection b, RenderSettings settings, Rgba colorA, Rgba colorB) + { + var flatA = Flatten(a); + var flatB = Flatten(b); + + // Force both panels onto the same extent so they are spatially comparable: the user's bbox if + // set, otherwise the union of both maps. + var bounds = settings.Bounds ?? flatA.GetBounds().ExpandToInclude(flatB.GetBounds()); + if (bounds.IsEmpty) + { + throw new GeoConvertException("Cannot render a side-by-side diff: neither map has a spatial extent."); + } + + var bytesA = RenderPanel(flatA, settings, bounds, colorA); + var bytesB = RenderPanel(flatB, settings, bounds, colorB); + + using var imageA = LoadBitmap(bytesA); + using var imageB = LoadBitmap(bytesB); + + const int gap = 8; + var width = imageA.Width + gap + imageB.Width; + var height = Math.Max(imageA.Height, imageB.Height); + using var combined = new Bitmap(width, height); + using (var graphics = Graphics.FromImage(combined)) + { + graphics.Clear(settings.Background.ToColor()); + graphics.DrawImage(imageA, 0, 0); + graphics.DrawImage(imageB, imageA.Width + gap, 0); + } + + using var output = new MemoryStream(); + combined.Save(output, ImageFormat.Png); + return output.ToArray(); + } + + static byte[] RenderPanel(FeatureCollection collection, RenderSettings settings, Envelope bounds, Rgba color) + { + var options = ConversionService.RenderOptionsFor(settings, null); + options.Bounds = bounds; + options.Ocean = null; + options.Label = null; + options.Stroke = color; + options.Fill = color with { A = 70 }; + return MapRenderer.RenderPng(collection, options); + } + + static LayerStyle StyleFor(Rgba color) => + new() + { + Stroke = color, + Fill = color with { A = 70 }, + }; + + static Bitmap LoadBitmap(byte[] png) + { + using var stream = new MemoryStream(png); + return new(stream); + } + + // Collapse a (possibly layered) collection into a single flat layer of all its features. The visual + // diff styles per top-level layer, so flattening keeps A and B each a single styled layer even when + // the source had folders/sub-layers. + static FeatureCollection Flatten(FeatureCollection collection) => + new(collection) + { + Name = collection.Name, + }; + + /// Builds the human-readable structural comparison printed by the CLI and shown in the diff view. + public static string Summarize(string nameA, FeatureCollection a, string nameB, FeatureCollection b) + { + var statsA = MapStats.Analyze(a); + var statsB = MapStats.Analyze(b); + + var builder = new StringBuilder(); + AppendMap(builder, "Map A", nameA, statsA); + builder.AppendLine(); + AppendMap(builder, "Map B", nameB, statsB); + builder.AppendLine(); + AppendDifferences(builder, statsA, statsB); + return builder.ToString(); + } + + static void AppendMap(StringBuilder builder, string label, string name, MapStats stats) + { + builder.AppendLine(CultureInfo.InvariantCulture, $"{label}: {name}"); + builder.AppendLine(CultureInfo.InvariantCulture, $" Features: {stats.FeatureCount}"); + builder.AppendLine(CultureInfo.InvariantCulture, $" Layers: {stats.LayerCount}"); + builder.AppendLine(CultureInfo.InvariantCulture, $" Bounds: {FormatBounds(stats.Bounds)}"); + builder.AppendLine(CultureInfo.InvariantCulture, $" Geometry: {FormatKinds(stats.GeometryKinds)}"); + builder.AppendLine(CultureInfo.InvariantCulture, $" Properties: {FormatKeys(stats.PropertyKeys)}"); + } + + static void AppendDifferences(StringBuilder builder, MapStats a, MapStats b) + { + builder.AppendLine("Differences:"); + builder.AppendLine(CultureInfo.InvariantCulture, $" Features: {FormatDelta(b.FeatureCount - a.FeatureCount)}"); + + var kindLines = new List(); + foreach (var kind in a.GeometryKinds.Keys.Union(b.GeometryKinds.Keys).OrderBy(_ => _, StringComparer.Ordinal)) + { + var delta = b.GeometryKinds.GetValueOrDefault(kind) - a.GeometryKinds.GetValueOrDefault(kind); + if (delta != 0) + { + kindLines.Add($"{kind} {FormatDelta(delta)}"); + } + } + + builder.AppendLine(CultureInfo.InvariantCulture, $" Geometry: {(kindLines.Count == 0 ? "(no change)" : string.Join(", ", kindLines))}"); + + var onlyInA = a.PropertyKeys.Except(b.PropertyKeys, StringComparer.Ordinal).OrderBy(_ => _, StringComparer.Ordinal).ToList(); + var onlyInB = b.PropertyKeys.Except(a.PropertyKeys, StringComparer.Ordinal).OrderBy(_ => _, StringComparer.Ordinal).ToList(); + builder.AppendLine(CultureInfo.InvariantCulture, $" Properties only in A: {(onlyInA.Count == 0 ? "(none)" : string.Join(", ", onlyInA))}"); + builder.AppendLine(CultureInfo.InvariantCulture, $" Properties only in B: {(onlyInB.Count == 0 ? "(none)" : string.Join(", ", onlyInB))}"); + } + + static string FormatDelta(int value) => + value > 0 ? $"+{value}" : value.ToString(CultureInfo.InvariantCulture); + + static string FormatBounds(Envelope bounds) => + bounds.IsEmpty + ? "(empty)" + : string.Format( + CultureInfo.InvariantCulture, + "{0:0.###}, {1:0.###} .. {2:0.###}, {3:0.###}", + bounds.MinX, + bounds.MinY, + bounds.MaxX, + bounds.MaxY); + + static string FormatKinds(IReadOnlyDictionary kinds) => + kinds.Count == 0 + ? "(none)" + : string.Join(", ", kinds.OrderByDescending(_ => _.Value).Select(_ => $"{_.Key} {_.Value}")); + + static string FormatKeys(IReadOnlyCollection keys) => + keys.Count == 0 ? "(none)" : string.Join(", ", keys); + + sealed record MapStats( + int FeatureCount, + int LayerCount, + Envelope Bounds, + IReadOnlyDictionary GeometryKinds, + IReadOnlyCollection PropertyKeys) + { + public static MapStats Analyze(FeatureCollection collection) + { + var kinds = new Dictionary(StringComparer.Ordinal); + var keys = new SortedSet(StringComparer.Ordinal); + foreach (var feature in collection) + { + var kind = KindOf(feature.Geometry); + kinds[kind] = kinds.GetValueOrDefault(kind) + 1; + foreach (var key in feature.Properties.Keys) + { + keys.Add(key); + } + } + + return new(collection.Count, CountLayers(collection), collection.GetBounds(), kinds, keys); + } + + static int CountLayers(FeatureCollection collection) + { + var total = 1; + foreach (var child in collection.Children) + { + total += CountLayers(child); + } + + return total; + } + + static string KindOf(Geometry? geometry) => + geometry switch + { + null => "(none)", + Point => "Point", + LineString => "LineString", + Polygon => "Polygon", + MultiPoint => "MultiPoint", + MultiLineString => "MultiLineString", + MultiPolygon => "MultiPolygon", + GeometryCollection => "GeometryCollection", + _ => geometry.GetType().Name, + }; + } +} diff --git a/src/GeoConvert.App/NativeConsole.cs b/src/GeoConvert.App/NativeConsole.cs new file mode 100644 index 0000000..900c76a --- /dev/null +++ b/src/GeoConvert.App/NativeConsole.cs @@ -0,0 +1,44 @@ +namespace GeoConvert.App; + +/// +/// Console-window management for the hybrid GUI/CLI tool. The app is a console-subsystem exe (so CLI +/// subcommands print and return exit codes normally when run from a terminal). The cost is that an +/// Explorer double-click — a file association launch — allocates a fresh console window. When we detect +/// that case (we own the console alone) and we're about to show the GUI, we hide it so the windowed app +/// looks like a windowed app. Launched from a real terminal we leave the console alone. +/// +static class NativeConsole +{ + const int SwHide = 0; + + [DllImport("kernel32.dll")] + static extern IntPtr GetConsoleWindow(); + + [DllImport("kernel32.dll")] + static extern uint GetConsoleProcessList(uint[] processList, uint processCount); + + [DllImport("user32.dll")] + static extern bool ShowWindow(IntPtr handle, int command); + + /// + /// True when this process is the only one attached to its console — the signature of a console + /// freshly allocated for us by Explorer, rather than a terminal we were launched from (which has at + /// least the shell attached too). + /// + public static bool OwnsConsoleAlone() + { + var processes = new uint[4]; + var count = GetConsoleProcessList(processes, (uint) processes.Length); + return count == 1; + } + + /// Hides this process's console window, if it has one. + public static void HideConsole() + { + var handle = GetConsoleWindow(); + if (handle != IntPtr.Zero) + { + ShowWindow(handle, SwHide); + } + } +} diff --git a/src/GeoConvert.App/OptionChoices.cs b/src/GeoConvert.App/OptionChoices.cs new file mode 100644 index 0000000..056ce01 --- /dev/null +++ b/src/GeoConvert.App/OptionChoices.cs @@ -0,0 +1,49 @@ +namespace GeoConvert.App; + +/// +/// The dropdown choice lists shared by the GUI option editors and the CLI help — the desktop +/// counterpart of the Blazor app's ExportOptionChoices, kept in one place so labels stay +/// consistent across the window, the diff view and the command line. +/// +public static class OptionChoices +{ + public static readonly (MapProjection Value, string Label)[] Projections = + [ + (MapProjection.Auto, "Automatic"), + (MapProjection.PlateCarree, "Plate Carrée"), + (MapProjection.WebMercator, "Web Mercator"), + (MapProjection.Lambert, "Lambert Conformal Conic"), + (MapProjection.Goode, "Goode Homolosine"), + ]; + + public static readonly (RendererBackend Value, string Label)[] Renderers = + [ + (RendererBackend.BuiltIn, "Built-in (dependency-free)"), + (RendererBackend.Skia, "SkiaSharp"), + (RendererBackend.ImageSharp, "ImageSharp"), + ]; + + public static readonly (int Value, string Label)[] Dimensions = + [ + (512, "512 px"), + (1024, "1024 px"), + (2048, "2048 px"), + (4096, "4096 px"), + (8192, "8192 px"), + ]; + + public static readonly (CompressionLevel Value, string Label)[] CompressionLevels = + [ + (CompressionLevel.Optimal, "Optimal"), + (CompressionLevel.SmallestSize, "Smallest size"), + (CompressionLevel.Fastest, "Fastest"), + (CompressionLevel.NoCompression, "None"), + ]; + + public static readonly (ParquetCompression Value, string Label)[] ParquetCodecs = + [ + (ParquetCompression.Snappy, "Snappy"), + (ParquetCompression.Gzip, "GZIP"), + (ParquetCompression.Uncompressed, "Uncompressed"), + ]; +} diff --git a/src/GeoConvert.App/Program.cs b/src/GeoConvert.App/Program.cs new file mode 100644 index 0000000..93a6950 --- /dev/null +++ b/src/GeoConvert.App/Program.cs @@ -0,0 +1,85 @@ +namespace GeoConvert.App; + +static class Program +{ + // The GEOCONVERT_SETTINGS environment variable overrides where settings live — used by tests and + // screenshot tooling so they never touch the real per-user settings file (which gates the one-time + // association prompt). + static readonly SettingsManager settingsManager = new( + Environment.GetEnvironmentVariable("GEOCONVERT_SETTINGS") is { Length: > 0 } path + ? path + : SettingsManager.DefaultSettingsPath); + + [STAThread] + static int Main(string[] args) + { + if (args.Length == 0) + { + return LaunchGui(() => new MainForm(settingsManager, null)); + } + + switch (args[0].ToLowerInvariant()) + { + case "-h": + case "--help": + case "help": + Cli.PrintUsage(Console.Out); + return 0; + case "--list": + case "list": + Cli.PrintFormats(Console.Out); + return 0; + case "associate": + return Cli.Associate(Console.Out); + case "unassociate": + return Cli.Unassociate(Console.Out); + case "settings": + return Cli.PrintSettings(Console.Out, settingsManager); + case "diff": + return Diff(args[1..]); + } + + // Not a subcommand. A lone existing file is the file-association double-click case: open it in the + // window. Anything else is a usage error. + if (args.Length == 1 && File.Exists(args[0])) + { + var file = args[0]; + return LaunchGui(() => new MainForm(settingsManager, file)); + } + + Cli.PrintUsage(Console.Error); + return 2; + } + + static int Diff(string[] diffArgs) + { + var code = Cli.ParseDiff(diffArgs, out var request, Console.Error); + if (code != 0 || request == null) + { + return code; + } + + // No output path => show the comparison in the window; otherwise render it headlessly. + if (request.Output == null) + { + return LaunchGui(() => new DiffForm(request)); + } + + return Cli.ExecuteDiff(request, Console.Out, Console.Error); + } + + static int LaunchGui(Func
createForm) + { + // Launched from Explorer (a file-association double-click), a console-subsystem exe is handed its + // own console window. Hide it so the windowed app presents cleanly. Launched from a terminal we + // share the user's console and leave it be. + if (NativeConsole.OwnsConsoleAlone()) + { + NativeConsole.HideConsole(); + } + + ApplicationConfiguration.Initialize(); + Application.Run(createForm()); + return 0; + } +} diff --git a/src/GeoConvert.App/RenderSettings.cs b/src/GeoConvert.App/RenderSettings.cs new file mode 100644 index 0000000..e55041f --- /dev/null +++ b/src/GeoConvert.App/RenderSettings.cs @@ -0,0 +1,61 @@ +namespace GeoConvert.App; + +/// +/// User-tunable knobs for the PNG/SVG image export — a desktop superset of the Blazor app's render +/// settings. It carries every knob the GUI and CLI surface (the web app's +/// projection / size / colours / strokes / labels) plus the extras a desktop with a filesystem can +/// afford: an explicit render extent (), an explicit pixel size, the PNG +/// backend, and the label knockout. Defaults mirror 's +/// own, with the ocean fill and sub-pixel feature culling pre-enabled, so an untouched instance renders +/// the same map the Blazor preview did. +/// +public sealed class RenderSettings +{ + // Size & layout. + public MapProjection Projection { get; set; } = MapProjection.Auto; + public RendererBackend Renderer { get; set; } = RendererBackend.BuiltIn; + + /// When > 0, caps the longer edge at this many pixels (fit-to-box) and ignores + /// /. The default matches the Blazor preview. + public int MaxDimension { get; set; } = 2048; + public int Width { get; set; } = 2048; + public int Height { get; set; } + + /// Render extent in lon/lat. Null renders the data bounds (the common case). + public Envelope? Bounds { get; set; } + public int Padding { get; set; } = 8; + + // Strokes & features. + public int StrokeWidth { get; set; } = 2; + public int PointRadius { get; set; } = 4; + public bool StrokeAutoScale { get; set; } = true; + public double MinFeaturePixels { get; set; } = 1; + + // Labels. + public bool Labels { get; set; } + + /// The property whose value labels each feature. Null/blank falls back to the common + /// name-like keys (name, NAME, admin, …) then the feature id — the Blazor app's behaviour. + public string? LabelProperty { get; set; } + public double LabelSize { get; set; } = 14; + + // Colors. + public Rgba Background { get; set; } = Rgba.White; + public bool OceanEnabled { get; set; } = true; + public Rgba Ocean { get; set; } = new(200, 220, 240); + public Rgba Stroke { get; set; } = new(30, 30, 30); + public Rgba Fill { get; set; } = new(70, 130, 180, 120); + public Rgba LabelColor { get; set; } = new(20, 20, 20); + public bool HaloEnabled { get; set; } = true; + public Rgba LabelHalo { get; set; } = new(255, 255, 255, 200); + + /// The "knockout" backdrop painted under each label (off by default, like + /// ). Erases the geometry under the text instead of + /// overlaying it. + public bool KnockoutEnabled { get; set; } + public Rgba LabelKnockout { get; set; } = Rgba.White; + + // Format-specific. PngCompression only affects a PNG write; SvgSimplifyTolerance only an SVG one. + public CompressionLevel PngCompression { get; set; } = CompressionLevel.Optimal; + public double SvgSimplifyTolerance { get; set; } +} diff --git a/src/GeoConvert.App/RendererBackend.cs b/src/GeoConvert.App/RendererBackend.cs new file mode 100644 index 0000000..ebbc661 --- /dev/null +++ b/src/GeoConvert.App/RendererBackend.cs @@ -0,0 +1,17 @@ +namespace GeoConvert.App; + +/// +/// Which PNG rasterizer to use, mirroring the geoconvert CLI's renderer flag. SVG always uses the +/// built-in vector writer regardless of this choice. +/// +public enum RendererBackend +{ + /// GeoConvert's dependency-free software rasterizer (). + BuiltIn, + + /// SkiaSharp-backed rasterizer (); labels use Skia's default typeface. + Skia, + + /// SixLabors.ImageSharp-backed rasterizer (); labels use a system font. + ImageSharp, +} diff --git a/src/GeoConvert.App/RgbaColors.cs b/src/GeoConvert.App/RgbaColors.cs new file mode 100644 index 0000000..fca9d25 --- /dev/null +++ b/src/GeoConvert.App/RgbaColors.cs @@ -0,0 +1,17 @@ +namespace GeoConvert.App; + +/// Bridges GeoConvert's and WinForms' . +public static class RgbaColors +{ + public static Color ToColor(this Rgba color) => + Color.FromArgb(color.A, color.R, color.G, color.B); + + public static Rgba ToRgba(this Color color) => + new(color.R, color.G, color.B, color.A); + + /// Replaces only the RGB channels, preserving the existing alpha — what a WinForms + /// (which has no alpha channel) should do when paired with a separate + /// opacity slider. + public static Rgba WithRgbOf(this Rgba color, Color picked) => + new(picked.R, picked.G, picked.B, color.A); +} diff --git a/src/GeoConvert.App/SampleMap.cs b/src/GeoConvert.App/SampleMap.cs new file mode 100644 index 0000000..3ccf7bd --- /dev/null +++ b/src/GeoConvert.App/SampleMap.cs @@ -0,0 +1,17 @@ +namespace GeoConvert.App; + +/// +/// The bundled sample world map — country borders, topology-simplified — the same map the Blazor app +/// ships. MapBundle stages it next to the app at build time as maps/World/borders.fgb (see the +/// csproj); this resolves that path at runtime, whether running from the build output or an installed +/// dotnet tool. +/// +static class SampleMap +{ + /// The bundled map's path, or null when it isn't present beside the app. + public static string? Locate() + { + var path = Path.Combine(AppContext.BaseDirectory, "maps", "World", "borders.fgb"); + return File.Exists(path) ? path : null; + } +} diff --git a/src/GeoConvert.App/Settings.cs b/src/GeoConvert.App/Settings.cs new file mode 100644 index 0000000..78d4386 --- /dev/null +++ b/src/GeoConvert.App/Settings.cs @@ -0,0 +1,15 @@ +namespace GeoConvert.App; + +/// +/// Persisted user preferences, stored as JSON under . +/// Kept deliberately small — the only thing that has to survive between runs is whether the first-run +/// file-association prompt has already been shown. +/// +public class Settings +{ + /// + /// True once the user has been asked (on first launch) whether to bind the supported map formats to + /// this app. Gates the one-time prompt so it never reappears, whatever the user answered. + /// + public bool AssociationsPrompted { get; set; } +} diff --git a/src/GeoConvert.App/SettingsManager.cs b/src/GeoConvert.App/SettingsManager.cs new file mode 100644 index 0000000..8b77ae7 --- /dev/null +++ b/src/GeoConvert.App/SettingsManager.cs @@ -0,0 +1,69 @@ +namespace GeoConvert.App; + +/// +/// Reads and writes as indented JSON. Modelled on MsOfficeDiff's settings +/// manager, but synchronous and dependency-free (System.Text.Json, no logging package): a read failure +/// degrades to defaults rather than throwing, so a corrupt file never blocks startup. +/// +public class SettingsManager +{ + readonly string settingsPath; + + public SettingsManager(string settingsPath) + { + var directory = Path.GetDirectoryName(settingsPath); + if (directory != null) + { + Directory.CreateDirectory(directory); + } + + this.settingsPath = settingsPath; + } + + static readonly JsonSerializerOptions jsonOptions = new() + { + WriteIndented = true, + }; + + public static string DefaultSettingsPath { get; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".config", + "GeoConvert", + "settings.json"); + + public string SettingsPath => settingsPath; + + public Settings Read() + { + if (!File.Exists(settingsPath)) + { + return new(); + } + + try + { + using var stream = File.OpenRead(settingsPath); + return JsonSerializer.Deserialize(stream) ?? new(); + } + catch + { + // A malformed settings file is not worth failing startup over — fall back to defaults. The + // next Write rewrites it cleanly. + return new(); + } + } + + public void Write(Settings settings) + { + using var stream = File.Create(settingsPath); + JsonSerializer.Serialize(stream, settings, jsonOptions); + } + + /// Reads the settings, applies , and writes them back. + public void Update(Action mutate) + { + var settings = Read(); + mutate(settings); + Write(settings); + } +} diff --git a/src/GeoConvert.App/SimplifySettings.cs b/src/GeoConvert.App/SimplifySettings.cs new file mode 100644 index 0000000..ede1243 --- /dev/null +++ b/src/GeoConvert.App/SimplifySettings.cs @@ -0,0 +1,28 @@ +namespace GeoConvert.App; + +/// +/// Optional lossy vertex reduction applied before writing or rendering — the GUI/CLI surface of +/// . Off by default; when on, the loaded features are thinned (a new graph, the +/// original is untouched) using the chosen and tolerance, with +/// switching to the shared-boundary variant so adjacent polygons stay joined. +/// +public sealed class SimplifySettings +{ + public bool Enabled { get; set; } + public double Tolerance { get; set; } = 0.01; + public SimplifyMethod Method { get; set; } = SimplifyMethod.DouglasPeucker; + public bool Topology { get; set; } + + /// Returns thinned per these settings, or unchanged when off. + public FeatureCollection Apply(FeatureCollection collection) + { + if (!Enabled || Tolerance <= 0) + { + return collection; + } + + return Topology + ? Simplifier.SimplifyTopology(collection, Tolerance, Method) + : Simplifier.Simplify(collection, Tolerance, Method); + } +} diff --git a/src/GeoConvert.App/Ui/AboutForm.cs b/src/GeoConvert.App/Ui/AboutForm.cs new file mode 100644 index 0000000..13d533e --- /dev/null +++ b/src/GeoConvert.App/Ui/AboutForm.cs @@ -0,0 +1,91 @@ +using System.Diagnostics; + +namespace GeoConvert.App; + +/// +/// The Help ▸ About dialog. A plain form rather than a MessageBox, so it stays silent (the information +/// MessageBox plays a system sound) and can host a clickable link to the project. Auto-sizes to its +/// content so it lays out correctly at any display DPI. +/// +sealed class AboutForm : Form +{ + const string projectUrl = "https://github.com/SimonCropp/GeoConvert"; + + public AboutForm() + { + Text = "About GeoConvert"; + FormBorderStyle = FormBorderStyle.FixedDialog; + StartPosition = FormStartPosition.CenterParent; + MinimizeBox = false; + MaximizeBox = false; + ShowInTaskbar = false; + AutoSize = true; + AutoSizeMode = AutoSizeMode.GrowAndShrink; + + // Not docked: a Dock=Fill panel inside an AutoSize form is circular (each defers to the other) + // and resolves a few pixels short. Undocked + AutoSize, the form simply sizes to the panel. + var layout = new TableLayoutPanel + { + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + ColumnCount = 1, + Padding = new(16), + }; + + var title = new Label + { + Text = "GeoConvert", + AutoSize = true, + Font = new(Font.FontFamily, Font.Size + 3, FontStyle.Bold), + Margin = new(3, 3, 3, 8), + }; + + var description = new Label + { + Text = + "Convert maps between GeoJSON, TopoJSON, Shapefile, FlatGeobuf, KML/KMZ, GPX, WKT, WKB,\n" + + "CSV and GeoParquet; render to PNG/SVG; and compare two maps.", + AutoSize = true, + Margin = new(3, 3, 3, 10), + }; + + var link = new LinkLabel + { + Text = projectUrl, + AutoSize = true, + Margin = new(3, 3, 3, 14), + }; + link.LinkClicked += (_, _) => OpenProject(); + + var ok = new Button + { + Text = "OK", + DialogResult = DialogResult.OK, + AutoSize = true, + Anchor = AnchorStyles.Right, + Margin = new(3), + }; + + layout.Controls.Add(title); + layout.Controls.Add(description); + layout.Controls.Add(link); + layout.Controls.Add(ok); + Controls.Add(layout); + + AcceptButton = ok; + CancelButton = ok; + } + + static void OpenProject() + { + try + { + // UseShellExecute lets the OS open the URL in the default browser. + Process.Start(new ProcessStartInfo(projectUrl) { UseShellExecute = true }); + } + catch + { + // Opening a link is best-effort; if no handler is registered, do nothing rather than crash. + } + } +} diff --git a/src/GeoConvert.App/Ui/Combos.cs b/src/GeoConvert.App/Ui/Combos.cs new file mode 100644 index 0000000..f8efc43 --- /dev/null +++ b/src/GeoConvert.App/Ui/Combos.cs @@ -0,0 +1,53 @@ +namespace GeoConvert.App; + +/// Builds value-carrying es (label shown, typed value behind it). +static class Combos +{ + public static ComboBox Build(IReadOnlyList<(T Value, string Label)> choices, T current, Action onChange) + where T : notnull + { + var combo = new ComboBox + { + DropDownStyle = ComboBoxStyle.DropDownList, + Width = 170, + Margin = new(3), + }; + foreach (var (value, label) in choices) + { + combo.Items.Add(new Choice(value, label)); + } + + for (var index = 0; index < combo.Items.Count; index++) + { + if (EqualityComparer.Default.Equals(((Choice) combo.Items[index]!).Value, current)) + { + combo.SelectedIndex = index; + break; + } + } + + combo.SelectedIndexChanged += (_, _) => onChange(((Choice) combo.SelectedItem!).Value); + return combo; + } + + /// Selects the item carrying , raising the change handler as a user pick would. + public static void Select(ComboBox combo, T value) + where T : notnull + { + for (var index = 0; index < combo.Items.Count; index++) + { + if (combo.Items[index] is Choice choice && EqualityComparer.Default.Equals(choice.Value, value)) + { + combo.SelectedIndex = index; + return; + } + } + } + + sealed class Choice(T value, string label) + { + public T Value { get; } = value; + + public override string ToString() => label; + } +} diff --git a/src/GeoConvert.App/Ui/DiffForm.cs b/src/GeoConvert.App/Ui/DiffForm.cs new file mode 100644 index 0000000..a2e07d3 --- /dev/null +++ b/src/GeoConvert.App/Ui/DiffForm.cs @@ -0,0 +1,362 @@ +namespace GeoConvert.App; + +/// +/// The map comparison window. Pick two maps (or arrive preloaded from the diff command line), +/// see the visual diff — an overlay of both in distinct colours, or a side-by-side at a shared extent — +/// alongside a structural summary (feature counts, geometry histograms, bounds, property deltas), and +/// save the diff image. +/// +sealed class DiffForm : Form +{ + readonly RenderSettings settings; + DiffMode mode; + Rgba colorA; + Rgba colorB; + string? pathA; + string? pathB; + + FeatureCollection? mapA; + FeatureCollection? mapB; + byte[]? currentImage; + int renderToken; + bool initialLoadDone; + + TextBox pathBoxA = null!; + TextBox pathBoxB = null!; + PictureBox preview = null!; + TextBox summary = null!; + Button saveButton = null!; + Button swatchA = null!; + Button swatchB = null!; + SplitContainer split = null!; + + public DiffForm() + : this(new(), DiffMode.Overlay, MapDiff.DefaultColorA, MapDiff.DefaultColorB, null, null) + { + } + + public DiffForm(Cli.DiffRequest request) + : this(request.Settings, request.Mode, request.ColorA, request.ColorB, request.PathA, request.PathB) + { + } + + DiffForm(RenderSettings settings, DiffMode mode, Rgba colorA, Rgba colorB, string? pathA, string? pathB) + { + this.settings = settings; + this.mode = mode; + this.colorA = colorA; + this.colorB = colorB; + this.pathA = pathA; + this.pathB = pathB; + BuildUi(); + } + + protected override void OnShown(EventArgs args) + { + base.OnShown(args); + if (initialLoadDone) + { + return; + } + + initialLoadDone = true; + if (pathA != null && pathB != null) + { + BeginInvoke(() => _ = LoadBothAsync()); + } + } + + void BuildUi() + { + Text = "Compare maps — GeoConvert"; + StartPosition = FormStartPosition.CenterScreen; + Size = new(1100, 720); + MinimumSize = new(820, 520); + + split = new() + { + Dock = DockStyle.Fill, + FixedPanel = FixedPanel.Panel2, + }; + preview = new() + { + Dock = DockStyle.Fill, + SizeMode = PictureBoxSizeMode.Zoom, + BackColor = Color.FromArgb(245, 245, 245), + }; + summary = new() + { + Dock = DockStyle.Fill, + Multiline = true, + ReadOnly = true, + WordWrap = false, + ScrollBars = ScrollBars.Both, + Font = new("Consolas", 9F), + BackColor = Color.White, + }; + split.Panel1.Controls.Add(preview); + split.Panel2.Controls.Add(summary); + + saveButton = new() + { + Dock = DockStyle.Bottom, + Height = 38, + Text = "Save diff image…", + Enabled = false, + }; + saveButton.Click += (_, _) => SaveImage(); + + Controls.Add(split); + Controls.Add(saveButton); + Controls.Add(BuildToolbar()); + Controls.Add(BuildInputs()); + } + + protected override void OnLoad(EventArgs args) + { + base.OnLoad(args); + SplitLayout.ConfigureSplit(split, 360); + } + + TableLayoutPanel BuildInputs() + { + var table = new TableLayoutPanel + { + Dock = DockStyle.Top, + ColumnCount = 3, + RowCount = 2, + // Auto-size the rows (rather than a fixed-height Absolute row) so they scale with the display + // DPI; a fixed row height left the Dock=Fill labels top-aligned against the (DPI-scaled) text + // boxes at 125%+. + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + Padding = new(6, 6, 6, 4), + }; + table.ColumnStyles.Add(new(SizeType.Absolute, 60)); + table.ColumnStyles.Add(new(SizeType.Percent, 100)); + table.ColumnStyles.Add(new(SizeType.Absolute, 90)); + + pathBoxA = AddInputRow(table, "Map A:", pathA, _ => LoadAInto(_)); + pathBoxB = AddInputRow(table, "Map B:", pathB, _ => LoadBInto(_)); + return table; + } + + TextBox AddInputRow(TableLayoutPanel table, string label, string? value, Action onPicked) + { + // The label fills its fixed-height cell and centres its text vertically (MiddleLeft), so it lines + // up with the text box. The box and button anchor left+right at their natural height and the + // TableLayoutPanel centres them in the row. + table.Controls.Add(new Label { Text = label, Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleLeft, Margin = new(3, 5, 3, 5) }); + var box = new TextBox { Anchor = AnchorStyles.Left | AnchorStyles.Right, ReadOnly = true, Text = value ?? string.Empty, Margin = new(3, 5, 3, 5) }; + table.Controls.Add(box); + var browse = new Button { Text = "Browse…", Anchor = AnchorStyles.Left | AnchorStyles.Right, Margin = new(3, 5, 3, 5) }; + browse.Click += (_, _) => + { + using var dialog = new OpenFileDialog + { + Title = label, + Filter = ConversionService.BuildDialogFilter(ConversionService.ReadableFormats), + }; + if (dialog.ShowDialog(this) == DialogResult.OK) + { + box.Text = dialog.FileName; + onPicked(dialog.FileName); + } + }; + table.Controls.Add(browse); + return box; + } + + TableLayoutPanel BuildToolbar() + { + // A single-row TableLayoutPanel (not a FlowLayoutPanel) so each item anchors left and the cell + // centres it vertically — labels line up with the combos at any DPI. A FlowLayoutPanel top-aligns + // its children, which needed hand-tuned top margins that drifted once the controls scaled. + var bar = new TableLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + ColumnCount = 10, + RowCount = 1, + Padding = new(6, 2, 6, 2), + }; + for (var column = 0; column < bar.ColumnCount; column++) + { + bar.ColumnStyles.Add(new(SizeType.AutoSize)); + } + + void Add(Control control, int gapLeft) + { + control.Anchor = AnchorStyles.Left; + control.Margin = new(gapLeft, 3, 3, 3); + bar.Controls.Add(control); + } + + Add(new Label { Text = "Mode", AutoSize = true }, 3); + Add( + Combos.Build( + [(DiffMode.Overlay, "Overlay"), (DiffMode.SideBySide, "Side by side")], + mode, + value => + { + mode = value; + _ = RenderAsync(); + }), + 0); + + Add(new Label { Text = "Projection", AutoSize = true }, 10); + Add( + Combos.Build( + OptionChoices.Projections, + settings.Projection, + value => + { + settings.Projection = value; + _ = RenderAsync(); + }), + 0); + + Add(new Label { Text = "Resolution", AutoSize = true }, 10); + Add( + Combos.Build( + OptionChoices.Dimensions, + settings.MaxDimension > 0 ? settings.MaxDimension : 2048, + value => + { + settings.MaxDimension = value; + _ = RenderAsync(); + }), + 0); + + Add(new Label { Text = "A", AutoSize = true }, 10); + swatchA = ColorSwatch(() => colorA, _ => colorA = _); + Add(swatchA, 1); + Add(new Label { Text = "B", AutoSize = true }, 8); + swatchB = ColorSwatch(() => colorB, _ => colorB = _); + Add(swatchB, 1); + + return bar; + } + + Button ColorSwatch(Func get, Action set) + { + var current = get(); + var swatch = new Button + { + Width = 40, + Height = 24, + FlatStyle = FlatStyle.Flat, + BackColor = Color.FromArgb(255, current.R, current.G, current.B), + Margin = new(1, 4, 1, 3), + }; + swatch.Click += (_, _) => + { + using var dialog = new ColorDialog { Color = swatch.BackColor, FullOpen = true }; + if (dialog.ShowDialog(this) == DialogResult.OK) + { + set(dialog.Color.ToRgba()); + swatch.BackColor = Color.FromArgb(255, dialog.Color.R, dialog.Color.G, dialog.Color.B); + _ = RenderAsync(); + } + }; + return swatch; + } + + void LoadAInto(string path) => _ = LoadAsync(path, isFirst: true); + + void LoadBInto(string path) => _ = LoadAsync(path, isFirst: false); + + async Task LoadBothAsync() + { + await LoadAsync(pathA!, isFirst: true, render: false); + await LoadAsync(pathB!, isFirst: false, render: false); + await RenderAsync(); + } + + async Task LoadAsync(string path, bool isFirst, bool render = true) + { + try + { + var collection = await Task.Run(() => GeoConverter.Read(path)); + if (isFirst) + { + mapA = collection; + pathA = path; + pathBoxA.Text = path; + } + else + { + mapB = collection; + pathB = path; + pathBoxB.Text = path; + } + + if (render) + { + await RenderAsync(); + } + } + catch (Exception exception) + { + MessageBox.Show(this, $"Could not read '{Path.GetFileName(path)}':\n{exception.Message}", "GeoConvert", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + async Task RenderAsync() + { + if (mapA is not { } a || mapB is not { } b) + { + return; + } + + var token = ++renderToken; + var localMode = mode; + var localColorA = colorA; + var localColorB = colorB; + try + { + var result = await Task.Run(() => + { + var image = MapDiff.Render(a, b, settings, localMode, localColorA, localColorB); + var text = MapDiff.Summarize(Path.GetFileName(pathA!), a, Path.GetFileName(pathB!), b); + return (image, text); + }); + + if (token != renderToken) + { + return; + } + + currentImage = result.image; + preview.Image?.Dispose(); + preview.Image = Images.DecodePng(result.image); + summary.Text = result.text.ReplaceLineEndings(); + saveButton.Enabled = true; + } + catch (Exception exception) + { + MessageBox.Show(this, $"Could not render the diff:\n{exception.Message}", "GeoConvert", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + void SaveImage() + { + if (currentImage is not { } image) + { + return; + } + + using var dialog = new SaveFileDialog + { + Title = "Save diff image", + Filter = "PNG image (*.png)|*.png", + FileName = "diff.png", + DefaultExt = "png", + }; + if (dialog.ShowDialog(this) == DialogResult.OK) + { + File.WriteAllBytes(dialog.FileName, image); + } + } +} diff --git a/src/GeoConvert.App/Ui/Images.cs b/src/GeoConvert.App/Ui/Images.cs new file mode 100644 index 0000000..1d2bf47 --- /dev/null +++ b/src/GeoConvert.App/Ui/Images.cs @@ -0,0 +1,15 @@ +namespace GeoConvert.App; + +static class Images +{ + /// + /// Fully materialises PNG bytes into a so the backing stream can be disposed + /// immediately — a Bitmap built straight from a stream keeps a lazy reference to it. + /// + public static Bitmap DecodePng(byte[] png) + { + using var stream = new MemoryStream(png); + using var decoded = new Bitmap(stream); + return new(decoded); + } +} diff --git a/src/GeoConvert.App/Ui/MainForm.cs b/src/GeoConvert.App/Ui/MainForm.cs new file mode 100644 index 0000000..3816b80 --- /dev/null +++ b/src/GeoConvert.App/Ui/MainForm.cs @@ -0,0 +1,383 @@ +namespace GeoConvert.App; + +/// +/// The main window: open a map, see a live preview, tune the full set of render/convert options, and +/// save to any supported format — the desktop counterpart of the Blazor converter page. Reads, writes +/// and previews run off the UI thread with progress reported back through . +/// +sealed class MainForm : Form +{ + readonly SettingsManager settingsManager; + readonly RenderSettings render = new(); + readonly SimplifySettings simplify = new(); + readonly KmzSettings kmz = new(); + readonly GeoParquetSettings parquet = new(); + readonly IProgress progress; + + OptionsPanel optionsPanel = null!; + PictureBox preview = null!; + Button saveButton = null!; + Label fileLabel = null!; + ToolStripStatusLabel statusLabel = null!; + ToolStripProgressBar progressBar = null!; + + FeatureCollection? features; + string? sourcePath; + FormatInfo? sourceFormat; + string? initialFile; + int previewToken; + bool busy; + + // Fixed width, in pixels, of the right-hand options column (wide enough for the 440px option groups + // plus the scrollbar, so no horizontal scroll appears). + const int optionsWidth = 480; + + public MainForm(SettingsManager settingsManager, string? initialFile) + { + this.settingsManager = settingsManager; + this.initialFile = initialFile; + progress = new Progress(OnProgress); + + BuildUi(); + UpdateState(); + } + + protected override void OnShown(EventArgs args) + { + base.OnShown(args); + FirstRun.PromptForAssociationsIfNeeded(settingsManager, this); + + // Load any file passed on the command line (the file-association open) now that the window — and + // its handle — exists, so the read runs on the live message loop rather than from the constructor. + if (initialFile is { } file) + { + initialFile = null; + _ = LoadAsync(file); + } + } + + void BuildUi() + { + Text = "GeoConvert"; + StartPosition = FormStartPosition.CenterScreen; + Size = new(1100, 720); + MinimumSize = new(820, 520); + + preview = new() + { + Dock = DockStyle.Fill, + SizeMode = PictureBoxSizeMode.Zoom, + BackColor = Color.FromArgb(245, 245, 245), + }; + fileLabel = new() + { + Dock = DockStyle.Top, + AutoSize = false, + Height = 28, + TextAlign = ContentAlignment.MiddleLeft, + Padding = new(8, 0, 0, 0), + Text = "No map loaded", + }; + var previewHost = new Panel + { + Dock = DockStyle.Fill + }; + previewHost.Controls.Add(preview); + previewHost.Controls.Add(fileLabel); + + optionsPanel = new(render, simplify, kmz, parquet) + { + Dock = DockStyle.Fill + }; + optionsPanel.Changed += (_, _) => _ = RefreshPreviewAsync(); + optionsPanel.TargetChanged += (_, _) => UpdateSaveLabel(); + + saveButton = new() + { + Dock = DockStyle.Bottom, + Height = 40, + Text = "Save As…", + Enabled = false, + }; + saveButton.Click += (_, _) => _ = SaveAsync(); + + // The options live in a fixed-width column pinned to the right edge — a plain docked panel, not a + // SplitContainer, so there is no draggable splitter and the column never grows or shrinks with the + // window. The preview takes all the remaining width. + var optionsHost = new Panel + { + Dock = DockStyle.Right, + Width = optionsWidth + }; + optionsHost.Controls.Add(optionsPanel); + optionsHost.Controls.Add(saveButton); + + var status = new StatusStrip(); + statusLabel = new() + { + Spring = true, + TextAlign = ContentAlignment.MiddleLeft + }; + progressBar = new() + { + Visible = false, + Width = 200 + }; + status.Items.Add(statusLabel); + status.Items.Add(progressBar); + + // Add the fill host first so it claims the leftover area; the menu and status (added last) span the + // full width top and bottom, with the fixed options column between them on the right. + Controls.Add(previewHost); + Controls.Add(optionsHost); + Controls.Add(status); + Controls.Add(BuildMenu()); + } + + MenuStrip BuildMenu() + { + var menu = new MenuStrip(); + + var file = new ToolStripMenuItem("&File"); + file.DropDownItems.Add("&Open…", null, (_, _) => OpenFile()); + file.DropDownItems.Add("&Load sample world map", null, (_, _) => OpenSample()); + file.DropDownItems.Add("&Save As…", null, (_, _) => _ = SaveAsync()); + file.DropDownItems.Add(new ToolStripSeparator()); + file.DropDownItems.Add("E&xit", null, (_, _) => Close()); + + var tools = new ToolStripMenuItem("&Tools"); + tools.DropDownItems.Add("&Compare maps…", null, (_, _) => new DiffForm().Show(this)); + tools.DropDownItems.Add(new ToolStripSeparator()); + tools.DropDownItems.Add("&Associate map file types", null, (_, _) => AssociateFromMenu()); + tools.DropDownItems.Add("&Remove file associations", null, (_, _) => UnassociateFromMenu()); + + var help = new ToolStripMenuItem("&Help"); + help.DropDownItems.Add("&About", null, (_, _) => ShowAbout()); + + menu.Items.Add(file); + menu.Items.Add(tools); + menu.Items.Add(help); + return menu; + } + + void OpenFile() + { + using var dialog = new OpenFileDialog + { + Title = "Open a map", + Filter = ConversionService.BuildDialogFilter(ConversionService.ReadableFormats), + }; + if (dialog.ShowDialog(this) == DialogResult.OK) + { + _ = LoadAsync(dialog.FileName); + } + } + + void OpenSample() + { + if (SampleMap.Locate() is { } path) + { + _ = LoadAsync(path); + return; + } + + MessageBox.Show(this, "The bundled sample world map isn't available next to the app.", "GeoConvert", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + + async Task LoadAsync(string path) + { + var detected = ConversionService.Detect(path); + if (detected is not {CanRead: true}) + { + MessageBox.Show(this, $"Can't read '{Path.GetFileName(path)}': unsupported map format.", "GeoConvert", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + SetBusy(true, $"Reading {detected.DisplayName}…"); + try + { + var collection = await Task.Run(() => ConversionService.Read(path, detected.Format, progress)); + features = collection; + sourcePath = path; + sourceFormat = detected; + fileLabel.Text = $"{Path.GetFileName(path)} · {detected.DisplayName} · {collection.Count} feature{(collection.Count == 1 ? "" : "s")}"; + await RefreshPreviewAsync(); + } + catch (Exception exception) + { + features = null; + ShowError("Could not read the map", exception); + } + finally + { + SetBusy(false, null); + UpdateState(); + } + } + + async Task RefreshPreviewAsync() + { + if (features is not {Count: > 0} collection) + { + preview.Image?.Dispose(); + preview.Image = null; + return; + } + + var token = ++previewToken; + try + { + var image = await Task.Run(() => + { + var prepared = simplify.Apply(collection); + var png = ConversionService.RenderPreview(prepared, render); + return Images.DecodePng(png); + }); + + // A newer refresh started while this one ran — discard this stale image. + if (token != previewToken) + { + image.Dispose(); + return; + } + + preview.Image?.Dispose(); + preview.Image = image; + } + catch + { + // Preview is best-effort (e.g. a map with no spatial extent can't be rendered) — just leave + // the previous image. Saving still surfaces real errors. + } + } + + async Task SaveAsync() + { + if (features is not { } collection || sourceFormat == null || busy) + { + return; + } + + var format = optionsPanel.SelectedFormat; + var info = ConversionService.Find(format)!; + + using var dialog = new SaveFileDialog + { + Title = "Save map", + Filter = info.DialogFilter + "|All files (*.*)|*.*", + FileName = Path.GetFileNameWithoutExtension(sourcePath) + info.Extension, + DefaultExt = info.Extension.TrimStart('.'), + }; + if (dialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + var destination = dialog.FileName; + SetBusy(true, ConversionService.IsRendered(format) ? "Rendering…" : $"Writing {info.DisplayName}…"); + try + { + await Task.Run(() => + { + var prepared = simplify.Apply(collection); + ConversionService.Save(prepared, destination, format, render, kmz, parquet, progress); + }); + statusLabel.Text = $"Saved {Path.GetFileName(destination)}"; + } + catch (Exception exception) + { + ShowError($"Could not save as {info.DisplayName}", exception); + } + finally + { + SetBusy(false, null); + } + } + + void AssociateFromMenu() + { + try + { + FileAssociations.Associate(); + MessageBox.Show(this, "GeoConvert is now the handler for the supported map formats.", "GeoConvert", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception exception) + { + ShowError("Could not set file associations", exception); + } + } + + void UnassociateFromMenu() + { + try + { + FileAssociations.Unassociate(); + MessageBox.Show(this, "Removed GeoConvert's map file associations.", "GeoConvert", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception exception) + { + ShowError("Could not remove file associations", exception); + } + } + + void ShowAbout() + { + // A custom form (not a MessageBox) so the dialog is silent and can show a clickable project link. + using var about = new AboutForm(); + about.ShowDialog(this); + } + + void UpdateState() + { + saveButton.Enabled = features is {Count: > 0} && !busy; + UpdateSaveLabel(); + } + + void UpdateSaveLabel() + { + var info = ConversionService.Find(optionsPanel.SelectedFormat); + saveButton.Text = info == null ? "Save As…" : $"Save As {info.DisplayName}…"; + } + + void SetBusy(bool value, string? message) + { + busy = value; + progressBar.Visible = value; + if (!value) + { + progressBar.Style = ProgressBarStyle.Blocks; + progressBar.Value = 0; + } + + if (message != null) + { + statusLabel.Text = message; + } + + saveButton.Enabled = features is {Count: > 0} && !busy; + Cursor = value ? Cursors.AppStarting : Cursors.Default; + } + + void OnProgress(ConvertProgress report) + { + if (!busy) + { + return; + } + + if (report.Fraction is { } fraction) + { + progressBar.Style = ProgressBarStyle.Blocks; + progressBar.Value = (int) Math.Clamp(fraction * 100, 0, 100); + } + else + { + // No derivable fraction (e.g. the read phase, where the total isn't known yet) — show motion + // rather than a frozen bar. + progressBar.Style = ProgressBarStyle.Marquee; + } + } + + void ShowError(string action, Exception exception) => + MessageBox.Show(this, $"{action}:\n{exception.Message}", "GeoConvert", MessageBoxButtons.OK, MessageBoxIcon.Error); +} diff --git a/src/GeoConvert.App/Ui/OptionsPanel.cs b/src/GeoConvert.App/Ui/OptionsPanel.cs new file mode 100644 index 0000000..518b906 --- /dev/null +++ b/src/GeoConvert.App/Ui/OptionsPanel.cs @@ -0,0 +1,458 @@ +namespace GeoConvert.App; + +/// +/// The export options editor — the desktop equivalent of the Blazor app's ExportOptions component. +/// Surfaces every knob plus the per-format options (KMZ deflate level, +/// GeoParquet codec) and the optional pre-pass, showing only the +/// sections relevant to the chosen output format. Raises when a preview-affecting +/// knob moves and when the output format changes. +/// +sealed class OptionsPanel : FlowLayoutPanel +{ + readonly RenderSettings render; + readonly SimplifySettings simplify; + readonly KmzSettings kmz; + readonly GeoParquetSettings parquet; + + GroupBox imageSection = null!; + GroupBox pngSection = null!; + GroupBox svgSection = null!; + GroupBox kmzSection = null!; + GroupBox parquetSection = null!; + GroupBox noteSection = null!; + ComboBox outputCombo = null!; + + TableLayoutPanel currentTable = null!; + + public OptionsPanel(RenderSettings render, SimplifySettings simplify, KmzSettings kmz, GeoParquetSettings parquet) + { + this.render = render; + this.simplify = simplify; + this.kmz = kmz; + this.parquet = parquet; + + FlowDirection = FlowDirection.TopDown; + WrapContents = false; + AutoScroll = true; + Padding = new(4); + + BuildProjectionSection(); + BuildOutputSection(); + BuildImageSection(); + BuildPngSection(); + BuildSvgSection(); + BuildKmzSection(); + BuildParquetSection(); + BuildSimplifySection(); + BuildNoteSection(); + + UpdateVisibility(); + } + + /// The currently selected output format. + public GeoFormat SelectedFormat { get; private set; } = GeoFormat.Kml; + + /// Raised when a preview-affecting option changes (so the host can re-render the preview). + public event EventHandler? Changed; + + /// Raised when the output format changes. + public event EventHandler? TargetChanged; + + void RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty); + + // --- sections --- + + void BuildProjectionSection() + { + // Above Output and always shown: the projection drives the live preview too, not just the + // PNG/SVG export, so it stays available whatever the chosen output format. + BeginSection("Projection"); + AddRadioGroup(OptionChoices.Projections, render.Projection, _ => render.Projection = _); + } + + void BuildOutputSection() + { + BeginSection("Output"); + outputCombo = AddCombo( + "Format", + [.. ConversionService.WritableFormats.Select(_ => (_.Format, _.DisplayName))], + SelectedFormat, + value => + { + SelectedFormat = value; + UpdateVisibility(); + TargetChanged?.Invoke(this, EventArgs.Empty); + }); + } + + /// Selects the output format programmatically, exactly as choosing it in the combo would. + internal void SelectFormat(GeoFormat format) => Combos.Select(outputCombo, format); + + void BuildImageSection() + { + imageSection = BeginSection("Image (PNG / SVG)"); + AddCombo("Resolution", OptionChoices.Dimensions, render.MaxDimension, _ => render.MaxDimension = _); + AddInt("Padding (px)", 0, 500, render.Padding, _ => render.Padding = _); + AddInt("Stroke width (px)", 0, 50, render.StrokeWidth, _ => render.StrokeWidth = _); + AddInt("Point radius (px)", 0, 50, render.PointRadius, _ => render.PointRadius = _); + AddCheck("Auto-scale strokes to zoom", render.StrokeAutoScale, _ => render.StrokeAutoScale = _); + AddDouble("Min feature size (px)", 0, 64, 1, render.MinFeaturePixels, _ => render.MinFeaturePixels = _); + AddCheck("Show labels", render.Labels, _ => render.Labels = _); + AddText("Label property (blank = auto)", render.LabelProperty ?? string.Empty, _ => render.LabelProperty = _); + AddDouble("Label size (px)", 1, 200, 1, render.LabelSize, _ => render.LabelSize = _); + AddColor("Background", () => render.Background, _ => render.Background = _, withAlpha: false); + AddCheck("Ocean fill", render.OceanEnabled, _ => render.OceanEnabled = _); + AddColor("Ocean colour", () => render.Ocean, _ => render.Ocean = _, withAlpha: true); + AddColor("Stroke", () => render.Stroke, _ => render.Stroke = _, withAlpha: false); + AddColor("Polygon fill", () => render.Fill, _ => render.Fill = _, withAlpha: true); + AddColor("Label text", () => render.LabelColor, _ => render.LabelColor = _, withAlpha: false); + AddCheck("Label halo", render.HaloEnabled, _ => render.HaloEnabled = _); + AddColor("Halo colour", () => render.LabelHalo, _ => render.LabelHalo = _, withAlpha: true); + AddCheck("Label knockout", render.KnockoutEnabled, _ => render.KnockoutEnabled = _); + AddColor("Knockout colour", () => render.LabelKnockout, _ => render.LabelKnockout = _, withAlpha: true); + } + + void BuildPngSection() + { + pngSection = BeginSection("PNG"); + AddCombo("Renderer", OptionChoices.Renderers, render.Renderer, _ => render.Renderer = _); + AddCombo("Compression", OptionChoices.CompressionLevels, render.PngCompression, _ => render.PngCompression = _, affectsPreview: false); + } + + void BuildSvgSection() + { + svgSection = BeginSection("SVG"); + AddDouble("Simplify tolerance (px)", 0, 20, 1, render.SvgSimplifyTolerance, _ => render.SvgSimplifyTolerance = _, affectsPreview: false); + } + + void BuildKmzSection() + { + kmzSection = BeginSection("KMZ"); + AddCombo("Compression", OptionChoices.CompressionLevels, kmz.Compression, _ => kmz.Compression = _, affectsPreview: false); + } + + void BuildParquetSection() + { + parquetSection = BeginSection("GeoParquet"); + AddCombo("Codec", OptionChoices.ParquetCodecs, parquet.Codec, _ => parquet.Codec = _, affectsPreview: false); + AddCombo("GZIP level", OptionChoices.CompressionLevels, parquet.GzipLevel, _ => parquet.GzipLevel = _, affectsPreview: false); + } + + void BuildSimplifySection() + { + BeginSection("Simplify (optional pre-pass)"); + var enabled = AddCheck("Simplify geometry", simplify.Enabled, _ => simplify.Enabled = _); + + // The tolerance / method / topology options only apply when simplification is on, so collapse + // them when "Simplify geometry" is unchecked. Capture the controls added below so the toggle can + // hide both the labels and the inputs (an AutoSize TableLayoutPanel row collapses when empty). + var dependentStart = currentTable.Controls.Count; + AddDouble("Tolerance", 0, 1000, 4, simplify.Tolerance, _ => simplify.Tolerance = _); + AddCombo( + "Method", + [(SimplifyMethod.DouglasPeucker, "Douglas–Peucker"), (SimplifyMethod.Visvalingam, "Visvalingam")], + simplify.Method, + _ => simplify.Method = _); + AddCheck("Preserve shared boundaries", simplify.Topology, _ => simplify.Topology = _); + + var dependents = new List(); + for (var index = dependentStart; index < currentTable.Controls.Count; index++) + { + dependents.Add(currentTable.Controls[index]); + } + + void Sync() + { + foreach (var dependent in dependents) + { + dependent.Visible = enabled.Checked; + } + } + + enabled.CheckedChanged += (_, _) => Sync(); + Sync(); + } + + void BuildNoteSection() + { + noteSection = BeginSection("Format"); + var note = new Label + { + AutoSize = true, + MaximumSize = new(400, 0), + Margin = new(3), + Text = "This format writes geometry and properties directly. Use the Simplify section above to thin vertices before writing.", + }; + currentTable.Controls.Add(note); + currentTable.SetColumnSpan(note, 2); + } + + void UpdateVisibility() + { + var format = SelectedFormat; + imageSection.Visible = ConversionService.IsRendered(format); + pngSection.Visible = format == GeoFormat.Png; + svgSection.Visible = format == GeoFormat.Svg; + kmzSection.Visible = format == GeoFormat.Kmz; + parquetSection.Visible = format == GeoFormat.GeoParquet; + noteSection.Visible = format is not (GeoFormat.Png or GeoFormat.Svg or GeoFormat.Kmz or GeoFormat.GeoParquet); + } + + // --- row/control builders --- + + GroupBox BeginSection(string title) + { + var table = new TableLayoutPanel + { + ColumnCount = 2, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + Dock = DockStyle.Top, + Padding = new(4), + }; + table.ColumnStyles.Add(new(SizeType.Absolute, 215)); + table.ColumnStyles.Add(new(SizeType.Absolute, 200)); + + var box = new GroupBox + { + Text = title, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + // Pin the width (Min == Max), leaving only the height to AutoSize. Without this an AutoSize + // GroupBox wrapping a Dock=Top table can't resolve its width (each defers to the other) and + // collapses to a sliver. + MinimumSize = new(440, 0), + MaximumSize = new(440, 0), + Margin = new(3), + Padding = new(6, 3, 6, 6), + }; + box.Controls.Add(table); + Controls.Add(box); + currentTable = table; + return box; + } + + void Row(string label, Control control) + { + // Fill the label cell and centre its text vertically so it lines up with the input regardless of + // the input's height; centre the input in the row too (Anchor without Top/Bottom). The column is + // wide enough (215px) that no label wraps. + var caption = new Label + { + Text = label, + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.MiddleLeft, + Margin = new(3, 0, 3, 0), + }; + currentTable.Controls.Add(caption); + control.Anchor = AnchorStyles.Left; + currentTable.Controls.Add(control); + } + + // A full-width column of mutually-exclusive radio buttons (one container => one radio group). Used + // where a choice reads better laid out than hidden in a dropdown — e.g. the projection. + void AddRadioGroup(IReadOnlyList<(T Value, string Label)> choices, T current, Action set, bool affectsPreview = true) + where T : notnull + { + var group = new FlowLayoutPanel + { + FlowDirection = FlowDirection.TopDown, + WrapContents = false, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + Margin = new(3), + }; + foreach (var (value, text) in choices) + { + var radio = new RadioButton + { + Text = text, + AutoSize = true, + Checked = EqualityComparer.Default.Equals(value, current), + Margin = new(0, 1, 0, 1), + }; + radio.CheckedChanged += (_, _) => + { + if (!radio.Checked) + { + return; + } + + set(value); + if (affectsPreview) + { + RaiseChanged(); + } + }; + group.Controls.Add(radio); + } + + currentTable.Controls.Add(group); + currentTable.SetColumnSpan(group, 2); + } + + ComboBox AddCombo(string label, IReadOnlyList<(T Value, string Label)> choices, T current, Action set, bool affectsPreview = true) + where T : notnull + { + var combo = Combos.Build( + choices, + current, + value => + { + set(value); + if (affectsPreview) + { + RaiseChanged(); + } + }); + combo.Width = 190; + Row(label, combo); + return combo; + } + + void AddInt(string label, int min, int max, int current, Action set, bool affectsPreview = true) + { + var numeric = new NumericUpDown + { + Minimum = min, + Maximum = max, + Value = Math.Clamp(current, min, max), + Width = 190, + Margin = new(3), + }; + numeric.ValueChanged += (_, _) => + { + set((int) numeric.Value); + if (affectsPreview) + { + RaiseChanged(); + } + }; + Row(label, numeric); + } + + void AddDouble(string label, double min, double max, int decimals, double current, Action set, bool affectsPreview = true) + { + var numeric = new NumericUpDown + { + Minimum = (decimal) min, + Maximum = (decimal) max, + DecimalPlaces = decimals, + Increment = (decimal) Math.Pow(10, -decimals), + Value = (decimal) Math.Clamp(current, min, max), + Width = 190, + Margin = new(3), + }; + numeric.ValueChanged += (_, _) => + { + set((double) numeric.Value); + if (affectsPreview) + { + RaiseChanged(); + } + }; + Row(label, numeric); + } + + CheckBox AddCheck(string label, bool current, Action set, bool affectsPreview = true) + { + var check = new CheckBox + { + Checked = current, + AutoSize = true, + Margin = new(3), + }; + check.CheckedChanged += (_, _) => + { + set(check.Checked); + if (affectsPreview) + { + RaiseChanged(); + } + }; + Row(label, check); + return check; + } + + void AddText(string label, string current, Action set, bool affectsPreview = true) + { + var text = new TextBox + { + Text = current, + Width = 190, + Margin = new(3), + }; + text.TextChanged += (_, _) => + { + set(text.Text.Length == 0 ? null : text.Text); + if (affectsPreview) + { + RaiseChanged(); + } + }; + Row(label, text); + } + + void AddColor(string label, Func get, Action set, bool withAlpha, bool affectsPreview = true) + { + var holder = new FlowLayoutPanel + { + AutoSize = true, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = false, + Margin = new(3), + }; + + var swatch = new Button + { + Width = 44, + Height = 24, + BackColor = Opaque(get()), + FlatStyle = FlatStyle.Flat, + Margin = new(0, 0, 6, 0), + }; + swatch.Click += (_, _) => + { + using var dialog = new ColorDialog { Color = Opaque(get()), FullOpen = true }; + if (dialog.ShowDialog(this) == DialogResult.OK) + { + var updated = get().WithRgbOf(dialog.Color); + set(updated); + swatch.BackColor = Opaque(updated); + if (affectsPreview) + { + RaiseChanged(); + } + } + }; + holder.Controls.Add(swatch); + + if (withAlpha) + { + var alpha = new TrackBar + { + Minimum = 0, + Maximum = 255, + Value = get().A, + Width = 120, + Height = 26, + TickStyle = TickStyle.None, + }; + alpha.ValueChanged += (_, _) => + { + set(get() with { A = (byte) alpha.Value }); + if (affectsPreview) + { + RaiseChanged(); + } + }; + holder.Controls.Add(alpha); + } + + Row(label, holder); + } + + // A WinForms button can't render alpha, so the swatch shows the opaque RGB; the alpha slider conveys + // transparency for the colours that carry it. + static Color Opaque(Rgba color) => Color.FromArgb(255, color.R, color.G, color.B); +} diff --git a/src/GeoConvert.App/Ui/SplitLayout.cs b/src/GeoConvert.App/Ui/SplitLayout.cs new file mode 100644 index 0000000..f56b908 --- /dev/null +++ b/src/GeoConvert.App/Ui/SplitLayout.cs @@ -0,0 +1,28 @@ +namespace GeoConvert.App; + +static class SplitLayout +{ + /// + /// Sizes a dock-filled so Panel2 ends up about + /// pixels wide, clamping the splitter into its valid range. Call this once the container has its real + /// size (e.g. from ) — setting + /// while the container is still at its tiny construction-time default throws, because the default + /// splitter distance then sits outside [Panel1MinSize, Width - Panel2MinSize]. + /// + public static void ConfigureSplit(SplitContainer split, int panel2Width) + { + if (split.Width <= 0) + { + return; + } + + split.Panel2MinSize = Math.Min(panel2Width, Math.Max(80, split.Width / 3)); + var max = split.Width - split.Panel2MinSize; + if (max < split.Panel1MinSize) + { + return; + } + + split.SplitterDistance = Math.Clamp(split.Width - panel2Width, split.Panel1MinSize, max); + } +} diff --git a/src/GeoConvert.App/nuget-readme.md b/src/GeoConvert.App/nuget-readme.md new file mode 100644 index 0000000..848d328 --- /dev/null +++ b/src/GeoConvert.App/nuget-readme.md @@ -0,0 +1,46 @@ +# GeoConvert.App + +A Windows desktop app — and .NET tool — that converts maps between geospatial formats, renders them to +PNG/SVG, and diffs two maps. It puts a GUI on top of [GeoConvert](https://www.nuget.org/packages/GeoConvert/) +and grows out of the same feature set as the GeoConvert Blazor sample. + +## Install + +``` +dotnet tool install -g GeoConvert.App +``` + +This installs the `geoconvert-app` command (Windows, .NET 10 or later). + +## The app + +Run `geoconvert-app` (or open a map file with it) to launch the window: + +- **Open** a GeoJSON, TopoJSON, Shapefile, FlatGeobuf, KML, KMZ, GPX, WKT, WKB, CSV or GeoParquet file + and see a live preview. +- **Convert** to any supported format. For PNG/SVG output the full render options are exposed — + projection, resolution, padding, strokes, point radius, stroke auto-scale, min-feature culling, + labels (with halo / knockout), colours and ocean fill — plus the PNG renderer backend, PNG + compression, SVG simplify tolerance, KMZ deflate level and GeoParquet codec. +- **Simplify** geometry as an optional pre-pass (Douglas–Peucker or Visvalingam, with a + topology-preserving mode for shared borders). +- **Compare maps** (Tools ▸ Compare maps…) — an overlay of the two maps in distinct colours, or a + side-by-side at a shared extent, alongside a structural summary. + +### First run + +On first launch the app offers to bind the supported map file types to itself (per-user, no admin), so +double-clicking a map opens it here. This can be changed any time from Tools ▸ Associate / Remove file +associations, or the `associate` / `unassociate` commands. + +## Command line + +The diff is scriptable headlessly: + +``` +geoconvert-app diff before.geojson after.geojson changes.png +geoconvert-app diff a.kml b.kml diff.png --mode side-by-side --size 1600 --projection lambert +``` + +With an output path the diff image is written and a summary printed; without one the comparison opens in +the window. Other commands: `associate`, `unassociate`, `settings`, `--list`, `--help`. diff --git a/src/GeoConvert.Skia/SkiaSurface.cs b/src/GeoConvert.Skia/SkiaSurface.cs index fd080df..e574a4a 100644 --- a/src/GeoConvert.Skia/SkiaSurface.cs +++ b/src/GeoConvert.Skia/SkiaSurface.cs @@ -49,13 +49,8 @@ public void FillPolygon((double X, double Y)[][] rings, Rgba color) continue; } - path.MoveTo((float)ring[0].X, (float)ring[0].Y); - for (var i = 1; i < ring.Length; i++) - { - path.LineTo((float)ring[i].X, (float)ring[i].Y); - } - - path.Close(); + // Each ring is a closed sub-path; even-odd fill makes interior rings cut holes. + AppendChain(path, ring, close: true); } using var paint = Fill(color); @@ -71,11 +66,8 @@ public void StrokePath(IReadOnlyList<(double X, double Y)> points, double width, } using var path = new SKPath(); - path.MoveTo((float)points[0].X, (float)points[0].Y); - for (var i = 1; i < points.Count; i++) - { - path.LineTo((float)points[i].X, (float)points[i].Y); - } + // Open polyline — no closing segment back to the start. + AppendChain(path, points, close: false); using var paint = new SKPaint { @@ -120,11 +112,11 @@ public void DrawText(string text, double leftX, double baselineY, double size, R StrokeWidth = Math.Max(1f, emSize / 6f), StrokeJoin = SKStrokeJoin.Round, }; - canvas.DrawText(text, (float)leftX, (float)baselineY, font, haloPaint); + canvas.DrawText(text, (float)leftX, (float)baselineY, SKTextAlign.Left, font, haloPaint); } using var paint = Fill(color); - canvas.DrawText(text, (float)leftX, (float)baselineY, font, paint); + canvas.DrawText(text, (float)leftX, (float)baselineY, SKTextAlign.Left, font, paint); } /// Encodes the painted bitmap as a PNG to . Skia chooses its own @@ -137,6 +129,27 @@ public void Encode(Stream stream, CompressionLevel compression) data.SaveTo(stream); } + // SkiaSharp 4.148 marks the imperative SKPath build methods (MoveTo/LineTo/Close) obsolete in + // favour of SKPathBuilder, but that type is not shipped in this package version — the imperative + // surface is the only available way to build a path. Keep the obsolete calls confined here and + // suppress CS0618 (warnings are errors) at the single point that touches them. Callers guard + // against empty input, so points[0] is always present. +#pragma warning disable CS0618 // SKPathBuilder (the suggested replacement) is absent from SkiaSharp 4.148.0. + static void AppendChain(SKPath path, IReadOnlyList<(double X, double Y)> points, bool close) + { + path.MoveTo((float)points[0].X, (float)points[0].Y); + for (var i = 1; i < points.Count; i++) + { + path.LineTo((float)points[i].X, (float)points[i].Y); + } + + if (close) + { + path.Close(); + } + } +#pragma warning restore CS0618 + static SKPaint Fill(Rgba color) => new() { diff --git a/src/GeoConvert.Web.Tests/Components/ExportOptionsTests.cs b/src/GeoConvert.Web.Tests/Components/ExportOptionsTests.cs index 6dea8cf..083d05b 100644 --- a/src/GeoConvert.Web.Tests/Components/ExportOptionsTests.cs +++ b/src/GeoConvert.Web.Tests/Components/ExportOptionsTests.cs @@ -139,7 +139,7 @@ public async Task GeoParquet_GzipLevelShownOnlyForGzipCodec() { var cut = Render(_ => _ .Add(component => component.Target, GeoFormat.GeoParquet) - .Add(component => component.Parquet, new GeoParquetSettings())); + .Add(component => component.Parquet, new())); // Snappy (the default) ignores the deflate level, so the GZIP-level control is hidden. await Assert.That(cut.FindAll("#parquet-gzip").Count).IsEqualTo(0); diff --git a/src/GeoConvert.Web/Pages/Index.razor.cs b/src/GeoConvert.Web/Pages/Index.razor.cs index 2082c0d..b0d3399 100644 --- a/src/GeoConvert.Web/Pages/Index.razor.cs +++ b/src/GeoConvert.Web/Pages/Index.razor.cs @@ -171,7 +171,7 @@ async Task DownloadSampleAsync(string url) { buffer.Write(chunk, 0, count); read += count; - progress?.Report(new ConvertProgress(ProgressPhase.Reading, 0, null, read, null)); + progress?.Report(new(ProgressPhase.Reading, 0, null, read, null)); } return buffer.ToArray(); diff --git a/src/GeoConvert.Web/wwwroot/favicon.png b/src/GeoConvert.Web/wwwroot/favicon.png new file mode 100644 index 0000000..229f79b Binary files /dev/null and b/src/GeoConvert.Web/wwwroot/favicon.png differ diff --git a/src/GeoConvert.Web/wwwroot/index.html b/src/GeoConvert.Web/wwwroot/index.html index 465eaea..23f35cf 100644 --- a/src/GeoConvert.Web/wwwroot/index.html +++ b/src/GeoConvert.Web/wwwroot/index.html @@ -5,6 +5,7 @@ GeoConvert — Map Format Converter + diff --git a/src/GeoConvert.slnx b/src/GeoConvert.slnx index 005cef3..e4eecfa 100644 --- a/src/GeoConvert.slnx +++ b/src/GeoConvert.slnx @@ -1,5 +1,7 @@ + + diff --git a/src/Tests/RenderBackendTests.cs b/src/Tests/RenderBackendTests.cs index 631d07a..d2cf8fa 100644 --- a/src/Tests/RenderBackendTests.cs +++ b/src/Tests/RenderBackendTests.cs @@ -50,7 +50,7 @@ public async Task PaintSurface_validates_before_invoking_the_factory() try { MapRenderer.PaintSurface( - [new FeatureCollection()], + [new()], new(), (width, height) => { diff --git a/src/Tests/SvgTests.cs b/src/Tests/SvgTests.cs index 74ea33e..352f81a 100644 --- a/src/Tests/SvgTests.cs +++ b/src/Tests/SvgTests.cs @@ -410,8 +410,8 @@ public async Task Simplify_tolerance_thins_rings_and_polylines() { var x = i / 10.0; // A barely-perceptible wobble (well under a pixel once projected) on an otherwise straight edge. - ring.Add(new(x, 5 + (i % 2) * 0.001)); - line.Add(new(x, 2 + (i % 2) * 0.001)); + ring.Add(new(x, 5 + i % 2 * 0.001)); + line.Add(new(x, 2 + i % 2 * 0.001)); } ring.Add(new(10, 0)); diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj index 0da842a..595f7b7 100644 --- a/src/Tests/Tests.csproj +++ b/src/Tests/Tests.csproj @@ -19,9 +19,6 @@ - - - PreserveNewest